feat: pipeline templates, analytics opt-out, 83 conversion presets, positioning + e2e modernization
Lands five integrated branches: pipeline templates (#355), analytics opt-out (#354), 83 conversion presets bringing the catalog to 240 tools (#356), self-hosted positioning (#353), and e2e modernization (#351). Integration fixes: aligned stale web analytics tests with the opt-out/allow-list model, closed 3 CodeQL incomplete-sanitization alerts in the i18n generator, resolved settings/index/docs/format-matrix conflicts, and corrected tool counts to 240.
@@ -162,7 +162,7 @@ jobs:
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm playwright install --with-deps chromium
|
||||
- name: Run smoke specs
|
||||
# tools-all.spec.ts is the full 157-tool per-tool render matrix (~170 tests)
|
||||
# tools-all.spec.ts is the full 240-tool per-tool render matrix (~250 tests)
|
||||
# -- a comprehensive check, not a PR-gate smoke test. It runs in the nightly
|
||||
# e2e-full bucket, which executes the whole suite sharded. Keep this gate to
|
||||
# genuinely fast sanity specs so it finishes well inside the timeout.
|
||||
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
- name: Install Playwright browsers (chromium + webkit)
|
||||
run: pnpm playwright install --with-deps chromium webkit
|
||||
- name: Run all device projects
|
||||
run: pnpm playwright test --project=mobile-chromium --project=mobile-webkit --project=tablet-webkit --project=tablet-chromium
|
||||
run: pnpm playwright test --project=mobile-chromium --project=mobile-webkit --project=tablet-webkit --project=tablet-chromium --grep-invert "@visual"
|
||||
env:
|
||||
PW_WORKERS: "2"
|
||||
- name: Upload report on failure
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **SnapOtter v2.0.0 is coming soon.** The current Docker image (`latest`) is v1.x and includes image tools only. v2.0 adds 157 tools across image, video, audio, documents, and files. We're fixing a last-minute issue with local AI installs before publishing the new image. Stay tuned!
|
||||
> **SnapOtter v2.0.0 is coming soon.** The current Docker image (`latest`) is v1.x and includes image tools only. v2.0 adds 240 tools across image, video, audio, documents, and files. We're fixing a last-minute issue with local AI installs before publishing the new image. Stay tuned!
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/r/snapotter/snapotter"><img src="https://img.shields.io/docker/v/snapotter/snapotter?label=Docker%20Hub&logo=docker" alt="Docker Hub"></a>
|
||||
@@ -18,16 +18,23 @@
|
||||
<a href="https://github.com/sponsors/snapotter-hq"><img src="https://img.shields.io/badge/Sponsor-pink?logo=githubsponsors&logoColor=white" alt="Sponsor"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Self-hosted file toolkit. 240 tools across image, video, audio, PDF, and files.</strong><br />
|
||||
The open-source alternative to Smallpdf, iLovePDF, TinyPNG, CloudConvert, and Otter.ai, in one stack you host yourself.
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
Stirling-PDF stops at PDFs. ConvertX stops at conversions. SnapOtter runs all five, and your files never leave your server. Edit images, convert video, transcribe audio, repair PDFs, batch your files: one Docker stack, on hardware you own.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **157 tools across 5 modalities:**
|
||||
- **Image (64):** resize, crop, compress, convert, watermark, color adjust, beautify screenshots, generate memes, vectorize, GIF tools, find duplicates, passport photos, and more. Supports 55+ input formats (including 23 camera RAW formats) and 14 output formats
|
||||
- **Video (29):** convert, compress, trim, resize, crop, merge, video-to-GIF, extract audio, stabilize, change FPS, burn/extract subtitles, and more
|
||||
- **Audio (17):** convert, trim, normalize, volume, fade, pitch shift, silence removal, noise reduction, merge/split, waveform, and more
|
||||
- **Documents / PDF (37):** merge, split, compress, convert (Word/Excel/PowerPoint/EPUB), protect/unlock, redact, watermark, page numbers, OCR, and more
|
||||
- **Files (10):** CSV/JSON/XML/YAML conversion, CSV merge/split, chart maker, ZIP create/extract
|
||||
- **240 tools across 5 modalities:**
|
||||
- **Image (105):** resize, crop, compress, convert, watermark, color adjust, beautify screenshots, generate memes, vectorize, GIF tools, find duplicates, passport photos, plus dedicated format converters (JPG to PNG, HEIC to JPG, WebP to PNG, image to PDF, and more). Supports 55+ input formats (including 23 camera RAW formats) and 14 output formats
|
||||
- **Video (57):** convert, compress, trim, resize, crop, merge, video-to-GIF, extract audio, stabilize, change FPS, burn/extract subtitles, plus dedicated converters (MOV to MP4, MKV to MP4, MP4 to MP3, and more)
|
||||
- **Audio (27):** convert, trim, normalize, volume, fade, pitch shift, silence removal, noise reduction, merge/split, waveform, plus dedicated converters (M4A to MP3, AAC to MP3, OGG to WAV, and more)
|
||||
- **Documents / PDF (40):** merge, split, compress, convert (Word/Excel/PowerPoint/EPUB), protect/unlock, redact, watermark, page numbers, OCR, plus PDF to JPG/PNG/TIFF
|
||||
- **Files (11):** CSV/JSON/XML/YAML conversion, CSV merge/split, Excel to CSV, chart maker, ZIP create/extract
|
||||
- **Image editor:** Layer-based editor with brushes, shapes, adjustments, filters, curves, and keyboard shortcuts. Runs in your browser, processes on your hardware
|
||||
- **Local AI:** Remove backgrounds, upscale images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR from images and PDFs), transcribe audio, auto-generate video subtitles, expand canvas, and fix transparency. All on your hardware, no internet required
|
||||
- **OIDC / SSO:** Login with Google, GitHub, Okta, or any OpenID Connect provider
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ensureAnonymousUser,
|
||||
ensureBuiltinRoles,
|
||||
ensureDefaultAdmin,
|
||||
ensureDefaultTeam,
|
||||
getAuthUser,
|
||||
} from "./plugins/auth.js";
|
||||
import { registerMfa } from "./plugins/mfa.js";
|
||||
@@ -54,6 +55,7 @@ import { filePreviewRoutes } from "./routes/file-preview.js";
|
||||
import { fileRoutes } from "./routes/files.js";
|
||||
import { registerMemeTemplates } from "./routes/meme-templates.js";
|
||||
import { registerPipelineRoutes } from "./routes/pipeline.js";
|
||||
import { preferencesRoutes } from "./routes/preferences.js";
|
||||
import { registerProgressRoutes } from "./routes/progress.js";
|
||||
import { rolesRoutes } from "./routes/roles.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
@@ -123,6 +125,7 @@ if (env.SQLITE_MIGRATE_PATH) {
|
||||
// inserted via data statements. The pg baseline is DDL-only, so roles are
|
||||
// seeded here at boot time. onConflictDoNothing makes this idempotent.
|
||||
await ensureBuiltinRoles();
|
||||
await ensureDefaultTeam();
|
||||
|
||||
if (env.AUTH_ENABLED) {
|
||||
await ensureDefaultAdmin();
|
||||
@@ -173,6 +176,8 @@ if (!env.COOKIE_SECRET) {
|
||||
}
|
||||
|
||||
await initAnalytics();
|
||||
const { primeAnalyticsGate } = await import("./lib/analytics-gate.js");
|
||||
await primeAnalyticsGate();
|
||||
|
||||
// Enterprise features (license-gated)
|
||||
let enterpriseLicense: { org: string; plan: string } | null = null;
|
||||
@@ -211,6 +216,8 @@ if (env.STORAGE_MODE === "s3") {
|
||||
|
||||
// Start the cooperative cancellation listener (Redis pub/sub)
|
||||
await startCancelListener();
|
||||
const { startAnalyticsGateListener } = await import("./lib/analytics-gate.js");
|
||||
await startAnalyticsGateListener();
|
||||
|
||||
// Set up AI feature directories and recover from interrupted installs. Both are
|
||||
// best-effort and must never block boot: ensureAiDirs swallows its own errors,
|
||||
@@ -362,6 +369,9 @@ await registerIpAllowlist(app);
|
||||
// Public config routes (no auth required)
|
||||
await configRoutes(app);
|
||||
|
||||
// Per-user preferences (any authenticated user)
|
||||
await preferencesRoutes(app);
|
||||
|
||||
// Auth middleware (must be registered before routes it protects)
|
||||
await authMiddleware(app);
|
||||
|
||||
@@ -761,6 +771,8 @@ async function shutdown(signal: string) {
|
||||
await closeQueueEvents();
|
||||
await closeQueues();
|
||||
await stopCancelListener();
|
||||
const { stopAnalyticsGateListener } = await import("./lib/analytics-gate.js");
|
||||
await stopAnalyticsGateListener();
|
||||
await closeRedis();
|
||||
console.log("Redis connections closed");
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { ANALYTICS_BAKED } from "@snapotter/shared";
|
||||
import { analyticsEnabled, gatePrimed } from "./lib/analytics-gate.js";
|
||||
|
||||
const FILE_EXT_PATTERN =
|
||||
/\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi;
|
||||
const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai)\//g;
|
||||
// Sentry inits at process load, before the gate cache is primed. Until the
|
||||
// first successful read, stay silent rather than emit on the default-ON cache,
|
||||
// so an opted-out instance never reports even a boot-window crash.
|
||||
const sentryActive = () => gatePrimed() && analyticsEnabled();
|
||||
|
||||
if (ANALYTICS_BAKED.enabled && ANALYTICS_BAKED.sentryDsn) {
|
||||
// Collapse any absolute path in a stack frame filename to its basename, so
|
||||
// even our own source paths never carry a workspace or job directory.
|
||||
function basename(p: string): string {
|
||||
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
if (ANALYTICS_BAKED.sentryDsn) {
|
||||
try {
|
||||
const Sentry = await import("@sentry/node");
|
||||
const { APP_VERSION } = await import("@snapotter/shared");
|
||||
@@ -15,52 +24,42 @@ if (ANALYTICS_BAKED.enabled && ANALYTICS_BAKED.sentryDsn) {
|
||||
environment: process.env.NODE_ENV || "production",
|
||||
tracesSampleRate: ANALYTICS_BAKED.sampleRate,
|
||||
sendDefaultPii: false,
|
||||
// Runtime opt-out: drop the whole transaction when analytics is off.
|
||||
tracesSampler: () => (sentryActive() ? ANALYTICS_BAKED.sampleRate : 0),
|
||||
beforeSend(event) {
|
||||
if (event.user) {
|
||||
delete event.user.email;
|
||||
delete event.user.username;
|
||||
}
|
||||
if (!sentryActive()) return null; // kill switch (covers auto-captured errors)
|
||||
// Allow-list: emit only error type + a basename-collapsed stack.
|
||||
event.message = undefined;
|
||||
event.logentry = undefined; // structured twin of message (captureMessage path)
|
||||
event.server_name = undefined; // hostname is not anonymous
|
||||
event.request = undefined;
|
||||
event.extra = undefined;
|
||||
event.contexts = undefined;
|
||||
event.breadcrumbs = undefined;
|
||||
event.user = undefined;
|
||||
if (event.exception?.values) {
|
||||
for (const ex of event.exception.values) {
|
||||
if (
|
||||
ex.value &&
|
||||
(ex.value.includes("Rate limit exceeded") ||
|
||||
ex.value.includes("Body cannot be empty") ||
|
||||
ex.value.includes("Unsupported Media Type") ||
|
||||
ex.value.includes("Request body size did not match") ||
|
||||
ex.value.includes("Premature close"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (ex.value) {
|
||||
ex.value = ex.value
|
||||
.replace(FILE_EXT_PATTERN, ".[REDACTED]")
|
||||
.replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
}
|
||||
ex.value = ex.type; // never the raw message body
|
||||
if (ex.stacktrace?.frames) {
|
||||
for (const frame of ex.stacktrace.frames) {
|
||||
if (frame.filename) {
|
||||
frame.filename = frame.filename
|
||||
.replace(FILE_EXT_PATTERN, ".[REDACTED]")
|
||||
.replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
}
|
||||
if (frame.filename) frame.filename = basename(frame.filename);
|
||||
frame.abs_path = undefined;
|
||||
frame.vars = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
},
|
||||
beforeBreadcrumb(breadcrumb) {
|
||||
if (breadcrumb.message) {
|
||||
breadcrumb.message = breadcrumb.message
|
||||
.replace(FILE_EXT_PATTERN, ".[REDACTED]")
|
||||
.replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
}
|
||||
return breadcrumb;
|
||||
beforeBreadcrumb() {
|
||||
return null; // breadcrumbs can carry URLs/messages with content; drop them
|
||||
},
|
||||
beforeSendTransaction(event) {
|
||||
return sentryActive() ? event : null;
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[sentry] initialized with performance tracing, release:", APP_VERSION);
|
||||
console.log("[sentry] initialized, release:", APP_VERSION);
|
||||
} catch {
|
||||
// @sentry/node not available
|
||||
}
|
||||
|
||||
@@ -25,12 +25,13 @@ import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { context, propagation, ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { ANALYTICS_BAKED, ANALYTICS_EVENTS, getBundleForTool, TOOLS } from "@snapotter/shared";
|
||||
import { ANALYTICS_EVENTS, getBundleForTool, TOOLS } from "@snapotter/shared";
|
||||
import { type Job, UnrecoverableError, Worker } from "bullmq";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { captureException, trackEvent } from "../lib/analytics.js";
|
||||
import { analyticsEnabled } from "../lib/analytics-gate.js";
|
||||
import { resolveConcurrency } from "../lib/env.js";
|
||||
import { friendlyError } from "../lib/errors.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
@@ -315,7 +316,7 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
||||
jobDuration.observe({ pool: data.pool }, durationMs / 1000);
|
||||
|
||||
// Analytics: emit tool_used on success
|
||||
if (ANALYTICS_BAKED.enabled) {
|
||||
if (analyticsEnabled()) {
|
||||
const tool = TOOLS.find((t) => t.id === data.toolId);
|
||||
void trackEvent(
|
||||
ANALYTICS_EVENTS.TOOL_USED,
|
||||
@@ -420,7 +421,7 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
||||
}
|
||||
|
||||
// Analytics: emit tool_used on failure
|
||||
if (ANALYTICS_BAKED.enabled) {
|
||||
if (analyticsEnabled()) {
|
||||
const tool = TOOLS.find((t) => t.id === data.toolId);
|
||||
void trackEvent(
|
||||
ANALYTICS_EVENTS.TOOL_USED,
|
||||
@@ -648,7 +649,7 @@ async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobRe
|
||||
}
|
||||
|
||||
// Analytics: emit pipeline_executed on failure
|
||||
if (ANALYTICS_BAKED.enabled) {
|
||||
if (analyticsEnabled()) {
|
||||
void trackEvent(
|
||||
ANALYTICS_EVENTS.PIPELINE_EXECUTED,
|
||||
{
|
||||
@@ -732,7 +733,7 @@ async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobRe
|
||||
}
|
||||
|
||||
// Analytics: emit pipeline_executed on success
|
||||
if (ANALYTICS_BAKED.enabled) {
|
||||
if (analyticsEnabled()) {
|
||||
void trackEvent(
|
||||
ANALYTICS_EVENTS.PIPELINE_EXECUTED,
|
||||
{
|
||||
@@ -875,7 +876,7 @@ export function startWorkers(): void {
|
||||
});
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
if (ANALYTICS_BAKED.enabled && job) {
|
||||
if (analyticsEnabled() && job) {
|
||||
void captureException(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
@@ -903,7 +904,7 @@ export function startWorkers(): void {
|
||||
});
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
if (ANALYTICS_BAKED.enabled && job) {
|
||||
if (analyticsEnabled() && job) {
|
||||
void captureException(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Defense in depth: only these keys may leave the server per event, and only as
|
||||
// primitives. Free-text fields (error_message, params, search query) are never
|
||||
// allow-listed, so tool settings and filenames cannot reach PostHog.
|
||||
const ALLOWED: Record<string, ReadonlySet<string>> = {
|
||||
tool_used: new Set(["tool_id", "status", "duration_ms", "category", "is_ai_tool", "error_code"]),
|
||||
pipeline_executed: new Set([
|
||||
"step_count",
|
||||
"tool_ids",
|
||||
"is_batch",
|
||||
"file_count",
|
||||
"duration_ms",
|
||||
"status",
|
||||
]),
|
||||
ai_bundle_action: new Set(["bundle_id", "action", "duration_ms"]),
|
||||
};
|
||||
|
||||
function isAllowedValue(value: unknown): boolean {
|
||||
if (value === null) return false;
|
||||
const t = typeof value;
|
||||
if (t === "string" || t === "number" || t === "boolean") return true;
|
||||
// tool_ids is an array of strings (low-cardinality ids); allow that one shape.
|
||||
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
||||
}
|
||||
|
||||
export function sanitizeEventProperties(
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const allow = ALLOWED[event];
|
||||
if (!allow) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
if (allow.has(key) && isAllowedValue(value)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ANALYTICS_BAKED } from "@snapotter/shared";
|
||||
import type Redis from "ioredis";
|
||||
|
||||
const TTL_MS = 30_000;
|
||||
const SETTING_KEY = "analyticsEnabled";
|
||||
|
||||
// `undefined` from a reader means the key is absent (default ON).
|
||||
type GateReader = () => Promise<boolean | undefined>;
|
||||
|
||||
let cachedEnabled = true; // last known toggle value (default ON)
|
||||
let knownDisabled = false; // have we positively read "disabled"? fail-closed anchor
|
||||
let primed = false; // has a successful read happened yet? gates Sentry at cold start
|
||||
let fetchedAt = 0; // Date.now() of the last read attempt
|
||||
let reader: GateReader = defaultReader;
|
||||
|
||||
async function defaultReader(): Promise<boolean | undefined> {
|
||||
const { db, schema } = await import("../db/index.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
const rows = await db
|
||||
.select({ value: schema.settings.value })
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, SETTING_KEY))
|
||||
.limit(1);
|
||||
if (rows.length === 0) return undefined;
|
||||
return rows[0].value !== "false";
|
||||
}
|
||||
|
||||
/** Compile-time bake, with a NON-PRODUCTION-only override so tests can force it on. */
|
||||
export function bakedEnabled(): boolean {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const o = process.env.ANALYTICS_BAKED_OVERRIDE;
|
||||
if (o === "on") return true;
|
||||
if (o === "off") return false;
|
||||
}
|
||||
return ANALYTICS_BAKED.enabled;
|
||||
}
|
||||
|
||||
/** Synchronous effective gate. Safe to call from Sentry beforeSend. Never blocks. */
|
||||
export function analyticsEnabled(): boolean {
|
||||
if (!bakedEnabled()) return false;
|
||||
if (Date.now() - fetchedAt > TTL_MS) {
|
||||
void refreshAnalyticsGate(); // background refresh; serve the cached value now
|
||||
}
|
||||
return cachedEnabled;
|
||||
}
|
||||
|
||||
/** Read the toggle and update the cache. Fails closed on read error. */
|
||||
export async function refreshAnalyticsGate(): Promise<void> {
|
||||
try {
|
||||
const v = await reader();
|
||||
const on = v === undefined ? true : v;
|
||||
cachedEnabled = on;
|
||||
knownDisabled = !on;
|
||||
primed = true;
|
||||
fetchedAt = Date.now();
|
||||
} catch {
|
||||
// DB read failed. If we ever positively saw "disabled", keep serving disabled
|
||||
// rather than reverting to the ON default. Otherwise keep the last value.
|
||||
if (knownDisabled) cachedEnabled = false;
|
||||
fetchedAt = Date.now(); // do not hammer the DB on repeated errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Warm the cache at boot before traffic is served. */
|
||||
export async function primeAnalyticsGate(): Promise<void> {
|
||||
await refreshAnalyticsGate();
|
||||
}
|
||||
|
||||
/**
|
||||
* True once a successful read has populated the cache. Backend Sentry inits at
|
||||
* process load (before the cache is primed), so its hooks check this to stay
|
||||
* silent during the boot window rather than emitting on the default-ON cache.
|
||||
*/
|
||||
export function gatePrimed(): boolean {
|
||||
return primed;
|
||||
}
|
||||
|
||||
// Test seams (no-ops in production paths).
|
||||
export function __setReaderForTests(r: GateReader | null): void {
|
||||
reader = r ?? defaultReader;
|
||||
}
|
||||
export function __resetGateForTests(): void {
|
||||
cachedEnabled = true;
|
||||
knownDisabled = false;
|
||||
primed = false;
|
||||
fetchedAt = 0;
|
||||
reader = defaultReader;
|
||||
}
|
||||
|
||||
let gateSubscriber: Redis | null = null;
|
||||
const CHANNEL = async () => {
|
||||
const { bullPrefix } = await import("../jobs/types.js");
|
||||
return `${bullPrefix()}:analytics-gate`;
|
||||
};
|
||||
|
||||
/** Subscribe so a setting change on any replica refreshes this process's cache. */
|
||||
export async function startAnalyticsGateListener(): Promise<void> {
|
||||
const { createRedisConnection } = await import("../jobs/connection.js");
|
||||
gateSubscriber = createRedisConnection();
|
||||
gateSubscriber.on("error", (err) => console.error("Analytics gate subscriber error", err));
|
||||
await gateSubscriber.subscribe(await CHANNEL());
|
||||
gateSubscriber.on("message", () => {
|
||||
void refreshAnalyticsGate();
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish so every replica drops its cache after a toggle write. */
|
||||
export async function publishAnalyticsGateInvalidation(): Promise<void> {
|
||||
const { sharedRedis } = await import("../jobs/connection.js");
|
||||
await sharedRedis().publish(await CHANNEL(), "1");
|
||||
}
|
||||
|
||||
export async function stopAnalyticsGateListener(): Promise<void> {
|
||||
if (gateSubscriber) {
|
||||
await gateSubscriber.quit();
|
||||
gateSubscriber = null;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import { ANALYTICS_BAKED } from "@snapotter/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { PostHog } from "posthog-node";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { sanitizeEventProperties } from "./analytics-allowlist.js";
|
||||
import { analyticsEnabled, bakedEnabled } from "./analytics-gate.js";
|
||||
|
||||
let posthogClient: PostHog | null = null;
|
||||
|
||||
export async function initAnalytics(): Promise<void> {
|
||||
if (!ANALYTICS_BAKED.enabled) return;
|
||||
if (!bakedEnabled()) return;
|
||||
|
||||
if (ANALYTICS_BAKED.posthogApiKey) {
|
||||
try {
|
||||
@@ -24,7 +26,7 @@ export async function initAnalytics(): Promise<void> {
|
||||
|
||||
export async function captureException(error: unknown): Promise<void> {
|
||||
try {
|
||||
if (!ANALYTICS_BAKED.enabled) return;
|
||||
if (!analyticsEnabled()) return;
|
||||
const Sentry = await import("@sentry/node");
|
||||
Sentry.captureException(error);
|
||||
} catch {
|
||||
@@ -53,14 +55,14 @@ export async function trackEvent(
|
||||
distinctId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!ANALYTICS_BAKED.enabled || !posthogClient) return;
|
||||
if (!analyticsEnabled() || !posthogClient) return;
|
||||
if (ANALYTICS_BAKED.sampleRate < 1.0) {
|
||||
if (ANALYTICS_BAKED.sampleRate <= 0.0 || Math.random() >= ANALYTICS_BAKED.sampleRate) return;
|
||||
}
|
||||
posthogClient.capture({
|
||||
distinctId: distinctId ?? (await getInstanceId()),
|
||||
event,
|
||||
properties,
|
||||
properties: sanitizeEventProperties(event, properties),
|
||||
});
|
||||
} catch {
|
||||
// analytics must never throw
|
||||
|
||||
@@ -33,6 +33,7 @@ const envSchema = z
|
||||
CONCURRENT_JOBS: z.coerce.number().default(0),
|
||||
MAX_MEGAPIXELS: z.coerce.number().default(0),
|
||||
RATE_LIMIT_PER_MIN: z.coerce.number().default(300),
|
||||
API_KEYS_RATE_LIMIT_PER_MIN: z.coerce.number().default(30),
|
||||
DATABASE_URL: z.string().default("postgres://snapotter:snapotter@localhost:5432/snapotter"),
|
||||
SQLITE_MIGRATE_PATH: z.string().default(""),
|
||||
FILES_STORAGE_PATH: z.string().default("./data/files"),
|
||||
|
||||
@@ -52,7 +52,9 @@ async function findDecodeCmd(): Promise<string> {
|
||||
*/
|
||||
export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
|
||||
const cmd = await findDecodeCmd();
|
||||
const id = randomUUID();
|
||||
// Include the PID so concurrent processes (and test workers) write to
|
||||
// distinct, attributable temp paths in the shared tmpdir.
|
||||
const id = `${process.pid}-${randomUUID()}`;
|
||||
const inputPath = join(tmpdir(), `heic-in-${id}.heic`);
|
||||
const outputPath = join(tmpdir(), `heic-out-${id}.png`);
|
||||
const suffixedPath = outputPath.replace(/\.png$/, "-1.png");
|
||||
@@ -101,7 +103,7 @@ export async function ensureSharpCompat(buffer: Buffer): Promise<Buffer> {
|
||||
}
|
||||
|
||||
export async function encodeHeic(buffer: Buffer, quality = 80): Promise<Buffer> {
|
||||
const id = randomUUID();
|
||||
const id = `${process.pid}-${randomUUID()}`;
|
||||
const inputPath = join(tmpdir(), `heic-in-${id}.png`);
|
||||
const outputPath = join(tmpdir(), `heic-out-${id}.heic`);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ info:
|
||||
title: SnapOtter API
|
||||
version: 1.17.1
|
||||
description: |
|
||||
REST API for SnapOtter, a self-hosted file processing suite with 157 tools across image, video, audio, document, and data.
|
||||
REST API for SnapOtter, a self-hosted file processing suite with 240 tools across image, video, audio, document, and data.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -5429,15 +5429,6 @@ paths:
|
||||
type: string
|
||||
teamName:
|
||||
type: string
|
||||
analyticsEnabled:
|
||||
type: boolean
|
||||
nullable: true
|
||||
analyticsConsentShownAt:
|
||||
type: integer
|
||||
nullable: true
|
||||
analyticsConsentRemindAt:
|
||||
type: integer
|
||||
nullable: true
|
||||
expiresAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -5509,15 +5500,6 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
analyticsEnabled:
|
||||
type: boolean
|
||||
nullable: true
|
||||
analyticsConsentShownAt:
|
||||
type: integer
|
||||
nullable: true
|
||||
analyticsConsentRemindAt:
|
||||
type: integer
|
||||
nullable: true
|
||||
expiresAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -6416,8 +6398,10 @@ paths:
|
||||
tags: [Analytics]
|
||||
summary: Get analytics configuration
|
||||
description: |
|
||||
Returns analytics provider configuration. When ANALYTICS_ENABLED is
|
||||
false on the server, all values are empty. Public endpoint.
|
||||
Returns the effective analytics provider configuration. Analytics is
|
||||
enabled only when both the compile-time bake and the instance
|
||||
analyticsEnabled setting allow it. When disabled, the keys, DSN, and
|
||||
instanceId are blank. Public endpoint.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
@@ -6429,6 +6413,11 @@ paths:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
description: |
|
||||
Effective analytics state. True only when both the
|
||||
compile-time bake and the instance analyticsEnabled
|
||||
setting are on. When false, the keys, DSN, and instanceId
|
||||
are blank.
|
||||
posthogApiKey:
|
||||
type: string
|
||||
posthogHost:
|
||||
|
||||
@@ -146,7 +146,24 @@ export function createSessionToken(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
// ── Default admin creation ─────────────────────────────────────────
|
||||
// ── Default team + admin creation ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed the "Default" team that the rest of the platform assumes exists.
|
||||
*
|
||||
* The frontend People form always submits team "Default", and register,
|
||||
* external-auth-resolver, and SCIM all fall back to a "default-team-00000000"
|
||||
* placeholder ID for it. None of those create the row, so a fresh install has
|
||||
* no Default team and adding a member via the UI fails with "Team not found".
|
||||
* Seeding it here (idempotent) fixes that. The ID matches the placeholder the
|
||||
* fallback paths use so a real row and a fallback reference line up.
|
||||
*/
|
||||
export async function ensureDefaultTeam(): Promise<void> {
|
||||
await db
|
||||
.insert(schema.teams)
|
||||
.values({ id: "default-team-00000000", name: "Default" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function ensureAnonymousUser(): Promise<void> {
|
||||
const [existing] = await db.select().from(schema.users).where(eq(schema.users.id, "anonymous"));
|
||||
|
||||
@@ -2,16 +2,29 @@ import { ANALYTICS_BAKED } from "@snapotter/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { analyticsEnabled } from "../lib/analytics-gate.js";
|
||||
|
||||
export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/v1/config/analytics", async () => {
|
||||
// Effective state: compile-time bake AND the runtime instance opt-out.
|
||||
if (!analyticsEnabled()) {
|
||||
return {
|
||||
enabled: false,
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
instanceId: "",
|
||||
};
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "instance_id"));
|
||||
|
||||
return {
|
||||
enabled: ANALYTICS_BAKED.enabled,
|
||||
enabled: true,
|
||||
posthogApiKey: ANALYTICS_BAKED.posthogApiKey,
|
||||
posthogHost: ANALYTICS_BAKED.posthogHost,
|
||||
sentryDsn: ANALYTICS_BAKED.sentryDsn,
|
||||
|
||||
@@ -9,11 +9,17 @@ import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { auditFromRequest } from "../lib/audit.js";
|
||||
import { getPermissions, hasEffectivePermission } from "../permissions.js";
|
||||
import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js";
|
||||
|
||||
// Per-route cap on the API-key management endpoints. Defaults to 30/min as an
|
||||
// anti-abuse guard; raised via env in the e2e suite, where many api-keys specs
|
||||
// hit the list endpoint in quick succession on a shared IP.
|
||||
const API_KEYS_RATE_LIMIT = { max: env.API_KEYS_RATE_LIMIT_PER_MIN, timeWindow: "1 minute" };
|
||||
|
||||
const createApiKeySchema = z.object({
|
||||
name: z.string().max(100, "Key name must be 100 characters or fewer").optional(),
|
||||
permissions: z.array(z.string()).optional(),
|
||||
@@ -24,7 +30,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||
// POST /api/v1/api-keys — Generate a new API key
|
||||
app.post(
|
||||
"/api/v1/api-keys",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
{ config: { rateLimit: API_KEYS_RATE_LIMIT } },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = requireAuth(request, reply);
|
||||
if (!user) return;
|
||||
@@ -110,7 +116,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/v1/api-keys — List user's API keys (never returns the key itself)
|
||||
app.get(
|
||||
"/api/v1/api-keys",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
{ config: { rateLimit: API_KEYS_RATE_LIMIT } },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = requireAuth(request, reply);
|
||||
if (!user) return;
|
||||
@@ -118,6 +124,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||
const selectFields = {
|
||||
id: schema.apiKeys.id,
|
||||
name: schema.apiKeys.name,
|
||||
keyPrefix: schema.apiKeys.keyPrefix,
|
||||
permissions: schema.apiKeys.permissions,
|
||||
createdAt: schema.apiKeys.createdAt,
|
||||
lastUsedAt: schema.apiKeys.lastUsedAt,
|
||||
@@ -134,6 +141,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||
apiKeys: keys.map((k) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
prefix: k.keyPrefix ?? "",
|
||||
permissions: k.permissions ?? null,
|
||||
createdAt: k.createdAt.toISOString(),
|
||||
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
|
||||
@@ -146,7 +154,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||
// DELETE /api/v1/api-keys/:id — Delete an API key
|
||||
app.delete(
|
||||
"/api/v1/api-keys/:id",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
{ config: { rateLimit: API_KEYS_RATE_LIMIT } },
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
const user = requireAuth(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
@@ -39,7 +39,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
|
||||
lines.push(`# ${spec.info.title}`);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"> Self-hosted file processing API with 157 tools across image, video, audio, document, and data. Convert, compress, edit, transcribe, OCR, and more.",
|
||||
"> Self-hosted file processing API with 240 tools across image, video, audio, document, and data. Convert, compress, edit, transcribe, OCR, and more.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## Docs");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Per-user preferences.
|
||||
*
|
||||
* Lets any authenticated user read and write their own preferences (e.g. the
|
||||
* default home view). Distinct from /v1/settings, which is the admin-only
|
||||
* instance configuration. Values are stored per (userId, key) in the
|
||||
* user_preferences table.
|
||||
*/
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
|
||||
const putSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
export async function preferencesRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/v1/preferences - the current user's preferences as a key->value map
|
||||
app.get(
|
||||
"/api/v1/preferences",
|
||||
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = requireAuth(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, user.id));
|
||||
|
||||
const preferences: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
preferences[row.key] = (row.value as { value: unknown }).value;
|
||||
}
|
||||
return reply.send({ preferences });
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/v1/preferences - upsert one or more of the current user's preferences
|
||||
app.put(
|
||||
"/api/v1/preferences",
|
||||
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = requireAuth(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
const parsed = putSchema.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Invalid preferences body" });
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(parsed.data)) {
|
||||
await db
|
||||
.insert(schema.userPreferences)
|
||||
.values({ userId: user.id, key, value: { value } })
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.userPreferences.userId, schema.userPreferences.key],
|
||||
set: { value: { value } },
|
||||
});
|
||||
}
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -144,6 +144,20 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.some((e) => e.key === "analyticsEnabled")) {
|
||||
// The setting is already persisted. A Redis hiccup here must not turn a
|
||||
// successful save into a 500; the TTL refresh converges replicas anyway.
|
||||
try {
|
||||
const { refreshAnalyticsGate, publishAnalyticsGateInvalidation } = await import(
|
||||
"../lib/analytics-gate.js"
|
||||
);
|
||||
await refreshAnalyticsGate(); // this replica, immediately
|
||||
await publishAnalyticsGateInvalidation(); // all other replicas
|
||||
} catch (err) {
|
||||
request.log.warn({ err }, "analytics gate invalidation failed (save still applied)");
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ ok: true, updatedCount: entries.length });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { BASE_CONFIG, CONVERSION_PRESETS } from "@snapotter/shared";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute, getToolConfig } from "../tool-factory.js";
|
||||
import { registerImageToPdfPreset } from "./image-to-pdf.js";
|
||||
import { registerPdfToImagePreset } from "./pdf-to-image.js";
|
||||
import { registerSvgToRasterPreset } from "./svg-to-raster.js";
|
||||
|
||||
/**
|
||||
* Narrow settings schema per preset base. Presets lock the output format, so
|
||||
* they expose only an optional quality/video knob (or nothing). The locked
|
||||
* format is merged in before the base's own processV2 re-validates.
|
||||
*/
|
||||
function presetSchema(base: string) {
|
||||
if (base === "convert" || base === "svg-to-raster") {
|
||||
return z.object({ quality: z.number().min(1).max(100).optional() });
|
||||
}
|
||||
if (base === "convert-video") {
|
||||
return z.object({ quality: z.enum(["high", "balanced", "small"]).optional() });
|
||||
}
|
||||
if (base === "image-to-pdf") {
|
||||
return z.object({
|
||||
pageSize: z.enum(["A4", "Letter", "A3", "A5"]).optional(),
|
||||
orientation: z.enum(["portrait", "landscape"]).optional(),
|
||||
});
|
||||
}
|
||||
return z.object({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all conversion-preset routes. Registry-group presets delegate to the
|
||||
* base tool's registered processV2 with their locked settings merged in, so they
|
||||
* MUST be registered after the base tools (ensured by index.ts ordering). The
|
||||
* three custom ZIP bases (image-to-pdf, pdf-to-image, svg-to-raster) reuse their
|
||||
* own parameterized registrars.
|
||||
*/
|
||||
export function registerConversionPresets(app: FastifyInstance) {
|
||||
for (const preset of CONVERSION_PRESETS) {
|
||||
const cfg = BASE_CONFIG[preset.base];
|
||||
if (cfg.group === "image-to-pdf") {
|
||||
registerImageToPdfPreset(app, preset.id, preset.sourceInputs);
|
||||
continue;
|
||||
}
|
||||
if (cfg.group === "pdf-to-image") {
|
||||
registerPdfToImagePreset(app, preset.id, preset.locked.format as string);
|
||||
continue;
|
||||
}
|
||||
if (cfg.group === "svg-to-raster") {
|
||||
registerSvgToRasterPreset(app, preset.id, preset.locked.outputFormat as string);
|
||||
continue;
|
||||
}
|
||||
|
||||
// group "registry": delegate to the base tool's processV2 with locked settings merged in.
|
||||
const baseConfig = getToolConfig(preset.base);
|
||||
if (!baseConfig) {
|
||||
throw new Error(
|
||||
`Preset "${preset.id}" base "${preset.base}" is not registered in the tool registry`,
|
||||
);
|
||||
}
|
||||
createToolRoute(app, {
|
||||
toolId: preset.id,
|
||||
settingsSchema: presetSchema(preset.base),
|
||||
process: async () => {
|
||||
throw new Error(`${preset.id} is v2-only`);
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const merged = { ...preset.locked, ...(ctx.settings as Record<string, unknown>) };
|
||||
if (!baseConfig.processV2) {
|
||||
throw new Error(`Base "${preset.base}" has no processV2 for preset "${preset.id}"`);
|
||||
}
|
||||
return baseConfig.processV2({ ...ctx, settings: merged });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z.enum(["mp4", "mov", "webm"]).default("mp4"),
|
||||
format: z.enum(["mp4", "mov", "webm", "avi", "mkv"]).default("mp4"),
|
||||
quality: z.enum(["high", "balanced", "small"]).default("balanced"),
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ const CONTENT_TYPES: Record<string, string> = {
|
||||
mp4: "video/mp4",
|
||||
mov: "video/quicktime",
|
||||
webm: "video/webm",
|
||||
avi: "video/x-msvideo",
|
||||
mkv: "video/x-matroska",
|
||||
};
|
||||
|
||||
export function registerConvertVideo(app: FastifyInstance) {
|
||||
@@ -48,6 +50,45 @@ export function registerConvertVideo(app: FastifyInstance) {
|
||||
out,
|
||||
];
|
||||
}
|
||||
if (settings.format === "avi") {
|
||||
return [
|
||||
"-i",
|
||||
inPath,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
CRF[settings.quality].h264,
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
"192k",
|
||||
out,
|
||||
];
|
||||
}
|
||||
if (settings.format === "mkv") {
|
||||
return [
|
||||
"-i",
|
||||
inPath,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
CRF[settings.quality].h264,
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
resolveEncoder("aac"),
|
||||
"-b:a",
|
||||
"128k",
|
||||
out,
|
||||
];
|
||||
}
|
||||
// mp4 and mov
|
||||
return [
|
||||
"-i",
|
||||
inPath,
|
||||
|
||||
@@ -4,13 +4,14 @@ import { runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z.enum(["mp3", "wav", "m4a"]).default("mp3"),
|
||||
format: z.enum(["mp3", "wav", "m4a", "ogg"]).default("mp3"),
|
||||
});
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
m4a: "audio/mp4",
|
||||
ogg: "audio/ogg",
|
||||
};
|
||||
|
||||
export function registerExtractAudio(app: FastifyInstance) {
|
||||
@@ -33,6 +34,8 @@ export function registerExtractAudio(app: FastifyInstance) {
|
||||
return ["-i", inPath, "-vn", "-c:a", "pcm_s16le", out];
|
||||
case "m4a":
|
||||
return ["-i", inPath, "-vn", "-c:a", "aac", "-b:a", "192k", out];
|
||||
case "ogg":
|
||||
return ["-i", inPath, "-vn", "-c:a", "libvorbis", "-q:a", "5", out];
|
||||
default:
|
||||
return ["-i", inPath, "-vn", "-c:a", "libmp3lame", "-b:a", "192k", out];
|
||||
}
|
||||
|
||||
@@ -5,9 +5,15 @@ import { runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z.enum(["mp4", "webm"]).default("mp4"),
|
||||
format: z.enum(["mp4", "webm", "mov"]).default("mp4"),
|
||||
});
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
mov: "video/quicktime",
|
||||
};
|
||||
|
||||
export function registerGifToVideo(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "gif-to-video",
|
||||
@@ -19,7 +25,7 @@ export function registerGifToVideo(app: FastifyInstance) {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}.${settings.format}`;
|
||||
const contentType = settings.format === "mp4" ? "video/mp4" : "video/webm";
|
||||
const contentType = CONTENT_TYPES[settings.format];
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
|
||||
if (settings.format === "webm") {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { apiToolPath } from "@snapotter/shared";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import PDFDocument from "pdfkit";
|
||||
@@ -86,6 +87,12 @@ async function compressImagesForTarget(
|
||||
return { buffers: bestBuffers, quality: bestQuality, targetMet: finalSize <= budget };
|
||||
}
|
||||
|
||||
/** Case-insensitive check that a filename ends with one of the accepted extensions. */
|
||||
function matchesAccept(filename: string, accept: string[]): boolean {
|
||||
const lower = filename.toLowerCase();
|
||||
return accept.some((ext) => lower.endsWith(ext.toLowerCase()));
|
||||
}
|
||||
|
||||
async function flattenAlpha(buf: Buffer): Promise<Buffer> {
|
||||
const meta = await sharp(buf).metadata();
|
||||
if (meta.hasAlpha) {
|
||||
@@ -96,8 +103,11 @@ async function flattenAlpha(buf: Buffer): Promise<Buffer> {
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function registerImageToPdf(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/image/image-to-pdf", async (request, reply) => {
|
||||
export function registerImageToPdfRoute(
|
||||
app: FastifyInstance,
|
||||
opts: { toolId: string; accept?: string[] },
|
||||
) {
|
||||
app.post(apiToolPath(opts.toolId), async (request, reply) => {
|
||||
const files: Array<{ buffer: Buffer; filename: string }> = [];
|
||||
let settingsRaw: string | null = null;
|
||||
|
||||
@@ -131,6 +141,16 @@ export function registerImageToPdf(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: "No image files provided" });
|
||||
}
|
||||
|
||||
if (opts.accept) {
|
||||
const accept = opts.accept;
|
||||
const invalid = files.find((file) => !matchesAccept(file.filename, accept));
|
||||
if (invalid) {
|
||||
return reply.status(400).send({
|
||||
error: `Invalid image "${invalid.filename}": this converter only accepts ${accept.join(", ")} files`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
try {
|
||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
@@ -338,3 +358,15 @@ export function registerImageToPdf(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerImageToPdf(app: FastifyInstance) {
|
||||
registerImageToPdfRoute(app, { toolId: "image-to-pdf" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an "<image format> to PDF" conversion preset that reuses the
|
||||
* image-to-pdf route logic with inputs narrowed to the given extensions.
|
||||
*/
|
||||
export function registerImageToPdfPreset(app: FastifyInstance, toolId: string, accept: string[]) {
|
||||
registerImageToPdfRoute(app, { toolId, accept });
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { registerCompress } from "./compress.js";
|
||||
import { registerCompressPdf } from "./compress-pdf.js";
|
||||
import { registerCompressVideo } from "./compress-video.js";
|
||||
import { registerContentAwareResize } from "./content-aware-resize.js";
|
||||
import { registerConversionPresets } from "./conversion-presets.js";
|
||||
import { registerConvert } from "./convert.js";
|
||||
import { registerConvertAudio } from "./convert-audio.js";
|
||||
import { registerConvertDocument } from "./convert-document.js";
|
||||
@@ -393,4 +394,8 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.log.info(
|
||||
`Tool routes: ${registered} active, ${skipped} skipped (${toolRegistrations.length} total)`,
|
||||
);
|
||||
|
||||
// Conversion presets delegate to base tools' registered processV2, so they
|
||||
// must be registered after the base loop above has populated the registry.
|
||||
registerConversionPresets(app);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { apiToolPath } from "@snapotter/shared";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as mupdf from "mupdf";
|
||||
@@ -179,9 +180,14 @@ function isPdfBuffer(buf: Buffer): boolean {
|
||||
}
|
||||
|
||||
// ── Route registration ───────────────────────────────────────────
|
||||
export function registerPdfToImage(app: FastifyInstance) {
|
||||
export function registerPdfToImageRoute(
|
||||
app: FastifyInstance,
|
||||
opts: { toolId: string; lockedFormat?: string },
|
||||
) {
|
||||
const basePath = apiToolPath(opts.toolId);
|
||||
|
||||
// ── Info endpoint ────────────────────────────────────────────
|
||||
app.post("/api/v1/tools/pdf/pdf-to-image/info", async (request, reply) => {
|
||||
app.post(`${basePath}/info`, async (request, reply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
try {
|
||||
const result = await readPdfFromParts(request);
|
||||
@@ -223,7 +229,7 @@ export function registerPdfToImage(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// ── Preview endpoint (thumbnails) ─────────────────────────────
|
||||
app.post("/api/v1/tools/pdf/pdf-to-image/preview", async (request, reply) => {
|
||||
app.post(`${basePath}/preview`, async (request, reply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
try {
|
||||
const result = await readPdfFromParts(request);
|
||||
@@ -288,7 +294,7 @@ export function registerPdfToImage(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// ── Main processing endpoint ─────────────────────────────────
|
||||
app.post("/api/v1/tools/pdf/pdf-to-image", async (request, reply) => {
|
||||
app.post(basePath, async (request, reply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let settingsRaw: string | null = null;
|
||||
|
||||
@@ -325,6 +331,10 @@ export function registerPdfToImage(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
if (opts.lockedFormat) {
|
||||
settings.format = opts.lockedFormat as typeof settings.format;
|
||||
}
|
||||
|
||||
let doc: mupdf.Document | null = null;
|
||||
try {
|
||||
doc = mupdf.Document.openDocument(fileBuffer, "application/pdf");
|
||||
@@ -405,3 +415,19 @@ export function registerPdfToImage(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerPdfToImage(app: FastifyInstance) {
|
||||
registerPdfToImageRoute(app, { toolId: "pdf-to-image" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a "PDF to <format>" conversion preset that reuses the pdf-to-image
|
||||
* route logic (ZIP output, info/preview endpoints) with the format locked.
|
||||
*/
|
||||
export function registerPdfToImagePreset(
|
||||
app: FastifyInstance,
|
||||
toolId: string,
|
||||
lockedFormat: string,
|
||||
) {
|
||||
registerPdfToImageRoute(app, { toolId, lockedFormat });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { apiToolPath } from "@snapotter/shared";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import PQueue from "p-queue";
|
||||
@@ -17,6 +18,12 @@ import { updateJobProgress } from "../progress.js";
|
||||
|
||||
const NON_PREVIEWABLE = new Set(["tiff", "heif"]);
|
||||
|
||||
/** Case-insensitive check that a filename ends with one of the accepted extensions. */
|
||||
function matchesAccept(filename: string, accept: string[]): boolean {
|
||||
const lower = filename.toLowerCase();
|
||||
return accept.some((ext) => lower.endsWith(ext.toLowerCase()));
|
||||
}
|
||||
|
||||
const settingsSchema = z.object({
|
||||
width: z.number().min(1).max(65536).optional(),
|
||||
height: z.number().min(1).max(65536).optional(),
|
||||
@@ -103,9 +110,14 @@ async function convertSvg(
|
||||
* SVG to raster conversion.
|
||||
* Custom route since input is SVG (not validated as image by magic bytes).
|
||||
*/
|
||||
export function registerSvgToRaster(app: FastifyInstance) {
|
||||
export function registerSvgToRasterRoute(
|
||||
app: FastifyInstance,
|
||||
opts: { toolId: string; accept?: string[]; lockedFormat?: string },
|
||||
) {
|
||||
const basePath = apiToolPath(opts.toolId);
|
||||
|
||||
// --- Batch endpoint (registered first for route priority) ---
|
||||
app.post("/api/v1/tools/image/svg-to-raster/batch", async (request, reply) => {
|
||||
app.post(`${basePath}/batch`, async (request, reply) => {
|
||||
const files: ParsedSvgFile[] = [];
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
@@ -151,6 +163,16 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.accept) {
|
||||
const accept = opts.accept;
|
||||
const invalid = files.find((file) => !matchesAccept(file.filename, accept));
|
||||
if (invalid) {
|
||||
return reply.status(400).send({
|
||||
error: "File is not a valid SVG. This tool only accepts SVG files.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
try {
|
||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
@@ -166,6 +188,10 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
if (opts.lockedFormat) {
|
||||
settings.outputFormat = opts.lockedFormat as typeof settings.outputFormat;
|
||||
}
|
||||
|
||||
const jobId = clientJobId || randomUUID();
|
||||
const queue = new PQueue({ concurrency: resolveConcurrency(env) });
|
||||
const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
|
||||
@@ -347,9 +373,10 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// --- Single-file endpoint ---
|
||||
app.post("/api/v1/tools/image/svg-to-raster", async (request, reply) => {
|
||||
app.post(basePath, async (request, reply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "output";
|
||||
let uploadFilename = "output";
|
||||
let settingsRaw: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -361,7 +388,8 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
fileBuffer = Buffer.concat(chunks);
|
||||
filename = sanitizeFilename(part.filename ?? "output").replace(/\.svgz?$/i, "");
|
||||
uploadFilename = sanitizeFilename(part.filename ?? "output");
|
||||
filename = uploadFilename.replace(/\.svgz?$/i, "");
|
||||
} else if (part.fieldname === "settings") {
|
||||
settingsRaw = part.value as string;
|
||||
}
|
||||
@@ -377,6 +405,12 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: "No SVG file provided" });
|
||||
}
|
||||
|
||||
if (opts.accept && !matchesAccept(uploadFilename, opts.accept)) {
|
||||
return reply.status(400).send({
|
||||
error: "File is not a valid SVG. This tool only accepts SVG files.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
fileBuffer = decompressSvgz(fileBuffer);
|
||||
} catch (err) {
|
||||
@@ -413,6 +447,10 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
if (opts.lockedFormat) {
|
||||
settings.outputFormat = opts.lockedFormat as typeof settings.outputFormat;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
buffer,
|
||||
@@ -453,3 +491,23 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG to raster conversion.
|
||||
* Custom route since input is SVG (not validated as image by magic bytes).
|
||||
*/
|
||||
export function registerSvgToRaster(app: FastifyInstance) {
|
||||
registerSvgToRasterRoute(app, { toolId: "svg-to-raster" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an "SVG to <format>" conversion preset that reuses the svg-to-raster
|
||||
* route logic with the output format locked and inputs narrowed to SVG.
|
||||
*/
|
||||
export function registerSvgToRasterPreset(
|
||||
app: FastifyInstance,
|
||||
toolId: string,
|
||||
lockedFormat: string,
|
||||
) {
|
||||
registerSvgToRasterRoute(app, { toolId, lockedFormat, accept: [".svg", ".svgz"] });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta property="og:title" content="SnapOtter Demo" />
|
||||
<meta property="og:description" content="Try SnapOtter - open-source, self-hosted file processing. 157 tools, 100% local." />
|
||||
<meta property="og:description" content="Try SnapOtter - open-source, self-hosted file processing. 240 tools, 100% local." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://demo.snapotter.com" />
|
||||
<meta property="og:image" content="https://snapotter.com/og-image.png" />
|
||||
|
||||
@@ -155,22 +155,27 @@ function matchRoute(url: string, method: string): Response | null {
|
||||
enableExperimentalTools: "false",
|
||||
defaultToolView: "sidebar",
|
||||
defaultTheme: "system",
|
||||
analyticsEnabled: "false",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/v1/settings" && method === "PUT") {
|
||||
return json({ ok: true, updatedCount: 0 });
|
||||
}
|
||||
|
||||
if (path === "/api/v1/features" && method === "GET") {
|
||||
return json({ bundles: FEATURE_BUNDLES });
|
||||
}
|
||||
|
||||
if (path === "/api/v1/config/analytics" && method === "GET") {
|
||||
return json({
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
instanceId: "demo-instance",
|
||||
instanceId: "",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import pkg from "../../../package.json";
|
||||
export default defineConfig({
|
||||
title: "SnapOtter",
|
||||
description:
|
||||
"Documentation for SnapOtter - A Self-Hosted File Manipulation Suite. 157 tools for image, video, audio, PDF, and data processing. Local AI, pipelines, REST API.",
|
||||
"Documentation for SnapOtter - A Self-Hosted File Manipulation Suite. 240 tools for image, video, audio, PDF, and data processing. Local AI, pipelines, REST API.",
|
||||
base: "/",
|
||||
appearance: { initialValue: "light" },
|
||||
srcDir: ".",
|
||||
@@ -86,7 +86,7 @@ export default defineConfig({
|
||||
`,
|
||||
customTemplateVariables: {
|
||||
description:
|
||||
"SnapOtter is a self-hosted, open-source file processing platform with 157 tools across image, video, audio, PDF, and data. Includes AI/ML tools. Runs via Docker Compose with GPU auto-detection.",
|
||||
"SnapOtter is a self-hosted, open-source file processing platform with 240 tools across image, video, audio, PDF, and data. Includes AI/ML tools. Runs via Docker Compose with GPU auto-detection.",
|
||||
details:
|
||||
"Process images (resize, compress, convert, remove backgrounds, upscale, OCR), videos (trim, merge, subtitles), audio (normalize, transcribe, convert), PDFs (merge, split, watermark, redact), and data files (CSV, JSON, XML conversion) - without sending files to external services.",
|
||||
},
|
||||
@@ -123,6 +123,7 @@ export default defineConfig({
|
||||
{ text: "Database", link: "/guide/database" },
|
||||
{ text: "Deployment", link: "/guide/deployment" },
|
||||
{ text: "Security & Hardening", link: "/guide/security" },
|
||||
{ text: "What SnapOtter collects", link: "/guide/telemetry" },
|
||||
{ text: "Supported Formats", link: "/guide/supported-formats" },
|
||||
{ text: "Hardware requirements", link: "/guide/deployment#hardware-requirements" },
|
||||
{ text: "Docker tags", link: "/guide/docker-tags" },
|
||||
|
||||
@@ -22,7 +22,7 @@ const modalities = [
|
||||
{ label: "Video", count: 29, href: "/tools/convert-video" },
|
||||
{ label: "Audio", count: 17, href: "/tools/convert-audio" },
|
||||
{ label: "PDF", count: 37, href: "/tools/merge-pdf" },
|
||||
{ label: "Data", count: 10, href: "/tools/chart-maker" },
|
||||
{ label: "Files", count: 10, href: "/tools/chart-maker" },
|
||||
];
|
||||
const shared = [
|
||||
{ label: "REST API", sub: "Keys, endpoints & OpenAPI", href: "/api/rest" },
|
||||
@@ -45,8 +45,8 @@ function copyCommand() {
|
||||
<p class="eyebrow">Self-hosted · Open source · AGPLv3</p>
|
||||
<h1 class="hero-title">SnapOtter Documentation</h1>
|
||||
<p class="hero-sub">
|
||||
<strong>157 tools</strong> for image, video, audio, PDF & data, running entirely on your
|
||||
hardware. Choose your path below, or get running in one command:
|
||||
<strong>Self-hosted file processing.</strong> 240 tools across image, video, audio, PDF &
|
||||
files, all on your own hardware. Choose your path below, or get running in one command:
|
||||
</p>
|
||||
<div class="cmd" title="Click to copy" @click="copyCommand">
|
||||
<code>$ {{ command }}</code>
|
||||
@@ -90,7 +90,7 @@ function copyCommand() {
|
||||
</section>
|
||||
|
||||
<section class="mod">
|
||||
<p class="mod-head"><strong>157 tools across 5 modalities</strong> <span>browse the full reference by type</span></p>
|
||||
<p class="mod-head"><strong>240 tools across 5 modalities</strong> <span>browse the full reference by type</span></p>
|
||||
<div class="chips">
|
||||
<a v-for="m in modalities" :key="m.href" class="chip" :href="m.href">
|
||||
<span class="chip-label">{{ m.label }}</span>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
.algolia
|
||||
[command-dialog-wrapper]:has([command-input]:placeholder-shown)
|
||||
[command-empty=""]::after {
|
||||
content: "Search 157 tools, guides, and the API reference";
|
||||
content: "Search 240 tools, guides, and the API reference";
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
|
||||
@@ -533,8 +533,8 @@ Query parameters:
|
||||
|
||||
| Method | Path | Access | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `GET` | `/api/v1/config/analytics` | Public | Get analytics configuration (PostHog key, Sentry DSN, sample rate). Returns empty values if `ANALYTICS_ENABLED=false`. |
|
||||
| `PUT` | `/api/v1/user/analytics` | Auth | Set the current user's analytics consent (`enabled: true/false`) or defer with `remindLater: true`. |
|
||||
| `GET` | `/api/v1/config/analytics` | Public | Get the effective analytics configuration (PostHog key, Sentry DSN, sample rate). Keys, DSN, and instance ID are blank when analytics is off, either from the compile-time bake or the instance `analyticsEnabled` setting. |
|
||||
| `PUT` | `/api/v1/settings` | Admin (`settings:write`) | Set the instance-wide opt-out. Send a JSON body `{ "analyticsEnabled": "false" }` to turn analytics off for everyone, or `"true"` to turn it back on. |
|
||||
|
||||
## Features / AI Bundles
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
|
||||
|
||||
### API (`apps/api`)
|
||||
|
||||
A Fastify v5 server exposing 157 tool routes across five modalities (image, video, audio, document, data) that handles:
|
||||
A Fastify v5 server exposing 240 tool routes across five modalities (image, video, audio, document, data) that handles:
|
||||
- File uploads, temporary workspace management, and persistent file storage
|
||||
- User file library with version chains (`user_files` table) - each processed result links back to its source file and records which tool was applied, with auto-generated thumbnails for the Files page
|
||||
- Tool execution (routes each tool request to the image engine or AI bridge)
|
||||
|
||||
@@ -525,11 +525,13 @@ Semantic-release determines the version from commit history. The `latest` Docker
|
||||
|
||||
## Analytics
|
||||
|
||||
SnapOtter includes basic analytics (tool usage patterns, error reports) to help catch bugs and improve features. Your files, file names, and personal data are never part of this. SnapOtter works normally with analytics disabled.
|
||||
SnapOtter includes anonymous product analytics (tool usage patterns, error reports) to help catch bugs and improve features. It is on by default. Your files, file names, and personal data are never part of this. SnapOtter works normally with analytics disabled.
|
||||
|
||||
### Disabling analytics
|
||||
|
||||
You can disable analytics at any time by cloning the repository and rebuilding:
|
||||
The runtime opt-out is a one-click admin toggle. Open Settings > System > Privacy and turn off Anonymous Product Analytics. It stops immediately for the whole instance, no rebuild required.
|
||||
|
||||
For an image that can never emit analytics, set the build-time hard-off by cloning the repository and rebuilding:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/snapotter-hq/SnapOtter.git
|
||||
|
||||
@@ -109,7 +109,7 @@ pnpm dev
|
||||
|
||||
## What You Can Do
|
||||
|
||||
### File Processing (157 Tools)
|
||||
### File Processing (240 Tools)
|
||||
|
||||
| Modality | Count | Example Tools |
|
||||
|----------|-------|---------------|
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Security hardening guide for SnapOtter. Container security, network
|
||||
|
||||
# Security & Hardening
|
||||
|
||||
SnapOtter processes files entirely on your infrastructure. No telemetry, no external API calls, no phone-home behavior. Files never leave the container.
|
||||
SnapOtter processes files entirely on your infrastructure. It sends anonymous, content-free product analytics and crash reports by default to help improve the project. It never sends your files, file names, file contents, OCR output, image metadata, or document text. An administrator can turn it off in one click under Settings > System > Privacy, no rebuild required. File processing always stays inside your container.
|
||||
|
||||
The container runs as a dedicated non-root user (`snapotter`) with all Linux capabilities dropped except the minimum required set. For the full vulnerability disclosure policy and security architecture, see [SECURITY.md](https://github.com/snapotter-hq/SnapOtter/blob/main/SECURITY.md) on GitHub.
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# What SnapOtter collects
|
||||
|
||||
Anonymous Product Analytics is on by default and set for the whole instance by an administrator. Turn it off under Settings > System > Privacy.
|
||||
|
||||
## Events we send (when enabled)
|
||||
|
||||
- tool_used: tool id, status, duration, category, whether it is an AI tool, an error code on failure.
|
||||
- pipeline_executed: step count, tool ids, batch flag, file count, duration, status.
|
||||
- ai_bundle_action: bundle id, action, duration.
|
||||
- Frontend usage: which tool pages open, files added (counts only), tool started, downloads, saves, search (result count only), batch processed.
|
||||
- Crash reports: error type and a source stack with file basenames only.
|
||||
|
||||
## What we never collect
|
||||
|
||||
- File names or paths
|
||||
- File contents
|
||||
- OCR output text
|
||||
- Image metadata (EXIF)
|
||||
- Extracted document text
|
||||
- Your IP address or account identity
|
||||
|
||||
## Turning it off
|
||||
|
||||
Admins: Settings > System > Privacy, flip "Anonymous Product Analytics" off. It stops immediately, instance-wide. To build an image that can never emit, set the `SNAPOTTER_ANALYTICS=off` build arg.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: page
|
||||
title: SnapOtter Docs
|
||||
description: "Documentation for SnapOtter, a self-hosted file manipulation suite. 157 tools for image, video, audio, PDF, and data processing. Local AI, pipelines, REST API."
|
||||
description: "Self-hosted file processing. 240 tools for image, video, audio, PDF, and files. Local AI, pipelines, REST API. Your files never leave your server."
|
||||
sidebar: false
|
||||
---
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 319 KiB |
@@ -313,3 +313,169 @@
|
||||
/tools/create-zip /tools/files/create-zip/ 301
|
||||
/tools/extract-zip/ /tools/files/extract-zip/ 301
|
||||
/tools/extract-zip /tools/files/extract-zip/ 301
|
||||
/tools/jpg-to-png/ /tools/image/jpg-to-png/ 301
|
||||
/tools/jpg-to-png /tools/image/jpg-to-png/ 301
|
||||
/tools/png-to-jpg/ /tools/image/png-to-jpg/ 301
|
||||
/tools/png-to-jpg /tools/image/png-to-jpg/ 301
|
||||
/tools/jpg-to-webp/ /tools/image/jpg-to-webp/ 301
|
||||
/tools/jpg-to-webp /tools/image/jpg-to-webp/ 301
|
||||
/tools/png-to-webp/ /tools/image/png-to-webp/ 301
|
||||
/tools/png-to-webp /tools/image/png-to-webp/ 301
|
||||
/tools/webp-to-jpg/ /tools/image/webp-to-jpg/ 301
|
||||
/tools/webp-to-jpg /tools/image/webp-to-jpg/ 301
|
||||
/tools/webp-to-png/ /tools/image/webp-to-png/ 301
|
||||
/tools/webp-to-png /tools/image/webp-to-png/ 301
|
||||
/tools/jpg-to-avif/ /tools/image/jpg-to-avif/ 301
|
||||
/tools/jpg-to-avif /tools/image/jpg-to-avif/ 301
|
||||
/tools/png-to-avif/ /tools/image/png-to-avif/ 301
|
||||
/tools/png-to-avif /tools/image/png-to-avif/ 301
|
||||
/tools/webp-to-avif/ /tools/image/webp-to-avif/ 301
|
||||
/tools/webp-to-avif /tools/image/webp-to-avif/ 301
|
||||
/tools/heic-to-jpg/ /tools/image/heic-to-jpg/ 301
|
||||
/tools/heic-to-jpg /tools/image/heic-to-jpg/ 301
|
||||
/tools/heic-to-png/ /tools/image/heic-to-png/ 301
|
||||
/tools/heic-to-png /tools/image/heic-to-png/ 301
|
||||
/tools/heic-to-avif/ /tools/image/heic-to-avif/ 301
|
||||
/tools/heic-to-avif /tools/image/heic-to-avif/ 301
|
||||
/tools/jpg-to-gif/ /tools/image/jpg-to-gif/ 301
|
||||
/tools/jpg-to-gif /tools/image/jpg-to-gif/ 301
|
||||
/tools/png-to-gif/ /tools/image/png-to-gif/ 301
|
||||
/tools/png-to-gif /tools/image/png-to-gif/ 301
|
||||
/tools/gif-to-jpg/ /tools/image/gif-to-jpg/ 301
|
||||
/tools/gif-to-jpg /tools/image/gif-to-jpg/ 301
|
||||
/tools/gif-to-png/ /tools/image/gif-to-png/ 301
|
||||
/tools/gif-to-png /tools/image/gif-to-png/ 301
|
||||
/tools/webp-to-gif/ /tools/image/webp-to-gif/ 301
|
||||
/tools/webp-to-gif /tools/image/webp-to-gif/ 301
|
||||
/tools/jpg-to-tiff/ /tools/image/jpg-to-tiff/ 301
|
||||
/tools/jpg-to-tiff /tools/image/jpg-to-tiff/ 301
|
||||
/tools/png-to-tiff/ /tools/image/png-to-tiff/ 301
|
||||
/tools/png-to-tiff /tools/image/png-to-tiff/ 301
|
||||
/tools/tiff-to-jpg/ /tools/image/tiff-to-jpg/ 301
|
||||
/tools/tiff-to-jpg /tools/image/tiff-to-jpg/ 301
|
||||
/tools/tiff-to-png/ /tools/image/tiff-to-png/ 301
|
||||
/tools/tiff-to-png /tools/image/tiff-to-png/ 301
|
||||
/tools/psd-to-jpg/ /tools/image/psd-to-jpg/ 301
|
||||
/tools/psd-to-jpg /tools/image/psd-to-jpg/ 301
|
||||
/tools/psd-to-png/ /tools/image/psd-to-png/ 301
|
||||
/tools/psd-to-png /tools/image/psd-to-png/ 301
|
||||
/tools/png-to-eps/ /tools/image/png-to-eps/ 301
|
||||
/tools/png-to-eps /tools/image/png-to-eps/ 301
|
||||
/tools/jpg-to-eps/ /tools/image/jpg-to-eps/ 301
|
||||
/tools/jpg-to-eps /tools/image/jpg-to-eps/ 301
|
||||
/tools/eps-to-png/ /tools/image/eps-to-png/ 301
|
||||
/tools/eps-to-png /tools/image/eps-to-png/ 301
|
||||
/tools/eps-to-jpg/ /tools/image/eps-to-jpg/ 301
|
||||
/tools/eps-to-jpg /tools/image/eps-to-jpg/ 301
|
||||
/tools/png-to-svg/ /tools/image/png-to-svg/ 301
|
||||
/tools/png-to-svg /tools/image/png-to-svg/ 301
|
||||
/tools/jpg-to-svg/ /tools/image/jpg-to-svg/ 301
|
||||
/tools/jpg-to-svg /tools/image/jpg-to-svg/ 301
|
||||
/tools/tiff-to-svg/ /tools/image/tiff-to-svg/ 301
|
||||
/tools/tiff-to-svg /tools/image/tiff-to-svg/ 301
|
||||
/tools/psd-to-svg/ /tools/image/psd-to-svg/ 301
|
||||
/tools/psd-to-svg /tools/image/psd-to-svg/ 301
|
||||
/tools/eps-to-svg/ /tools/image/eps-to-svg/ 301
|
||||
/tools/eps-to-svg /tools/image/eps-to-svg/ 301
|
||||
/tools/svg-to-png/ /tools/image/svg-to-png/ 301
|
||||
/tools/svg-to-png /tools/image/svg-to-png/ 301
|
||||
/tools/svg-to-jpg/ /tools/image/svg-to-jpg/ 301
|
||||
/tools/svg-to-jpg /tools/image/svg-to-jpg/ 301
|
||||
/tools/jpg-to-pdf/ /tools/image/jpg-to-pdf/ 301
|
||||
/tools/jpg-to-pdf /tools/image/jpg-to-pdf/ 301
|
||||
/tools/png-to-pdf/ /tools/image/png-to-pdf/ 301
|
||||
/tools/png-to-pdf /tools/image/png-to-pdf/ 301
|
||||
/tools/heic-to-pdf/ /tools/image/heic-to-pdf/ 301
|
||||
/tools/heic-to-pdf /tools/image/heic-to-pdf/ 301
|
||||
/tools/tiff-to-pdf/ /tools/image/tiff-to-pdf/ 301
|
||||
/tools/tiff-to-pdf /tools/image/tiff-to-pdf/ 301
|
||||
/tools/webp-to-pdf/ /tools/image/webp-to-pdf/ 301
|
||||
/tools/webp-to-pdf /tools/image/webp-to-pdf/ 301
|
||||
/tools/gif-to-pdf/ /tools/image/gif-to-pdf/ 301
|
||||
/tools/gif-to-pdf /tools/image/gif-to-pdf/ 301
|
||||
/tools/eps-to-pdf/ /tools/image/eps-to-pdf/ 301
|
||||
/tools/eps-to-pdf /tools/image/eps-to-pdf/ 301
|
||||
/tools/pdf-to-jpg/ /tools/pdf/pdf-to-jpg/ 301
|
||||
/tools/pdf-to-jpg /tools/pdf/pdf-to-jpg/ 301
|
||||
/tools/pdf-to-png/ /tools/pdf/pdf-to-png/ 301
|
||||
/tools/pdf-to-png /tools/pdf/pdf-to-png/ 301
|
||||
/tools/pdf-to-tiff/ /tools/pdf/pdf-to-tiff/ 301
|
||||
/tools/pdf-to-tiff /tools/pdf/pdf-to-tiff/ 301
|
||||
/tools/mov-to-mp4/ /tools/video/mov-to-mp4/ 301
|
||||
/tools/mov-to-mp4 /tools/video/mov-to-mp4/ 301
|
||||
/tools/webm-to-mp4/ /tools/video/webm-to-mp4/ 301
|
||||
/tools/webm-to-mp4 /tools/video/webm-to-mp4/ 301
|
||||
/tools/mkv-to-mp4/ /tools/video/mkv-to-mp4/ 301
|
||||
/tools/mkv-to-mp4 /tools/video/mkv-to-mp4/ 301
|
||||
/tools/avi-to-mp4/ /tools/video/avi-to-mp4/ 301
|
||||
/tools/avi-to-mp4 /tools/video/avi-to-mp4/ 301
|
||||
/tools/mp4-to-mov/ /tools/video/mp4-to-mov/ 301
|
||||
/tools/mp4-to-mov /tools/video/mp4-to-mov/ 301
|
||||
/tools/mp4-to-webm/ /tools/video/mp4-to-webm/ 301
|
||||
/tools/mp4-to-webm /tools/video/mp4-to-webm/ 301
|
||||
/tools/webm-to-mov/ /tools/video/webm-to-mov/ 301
|
||||
/tools/webm-to-mov /tools/video/webm-to-mov/ 301
|
||||
/tools/mkv-to-mov/ /tools/video/mkv-to-mov/ 301
|
||||
/tools/mkv-to-mov /tools/video/mkv-to-mov/ 301
|
||||
/tools/avi-to-mov/ /tools/video/avi-to-mov/ 301
|
||||
/tools/avi-to-mov /tools/video/avi-to-mov/ 301
|
||||
/tools/mp4-to-avi/ /tools/video/mp4-to-avi/ 301
|
||||
/tools/mp4-to-avi /tools/video/mp4-to-avi/ 301
|
||||
/tools/mov-to-avi/ /tools/video/mov-to-avi/ 301
|
||||
/tools/mov-to-avi /tools/video/mov-to-avi/ 301
|
||||
/tools/mkv-to-avi/ /tools/video/mkv-to-avi/ 301
|
||||
/tools/mkv-to-avi /tools/video/mkv-to-avi/ 301
|
||||
/tools/avi-to-mkv/ /tools/video/avi-to-mkv/ 301
|
||||
/tools/avi-to-mkv /tools/video/avi-to-mkv/ 301
|
||||
/tools/mp4-to-gif/ /tools/video/mp4-to-gif/ 301
|
||||
/tools/mp4-to-gif /tools/video/mp4-to-gif/ 301
|
||||
/tools/mov-to-gif/ /tools/video/mov-to-gif/ 301
|
||||
/tools/mov-to-gif /tools/video/mov-to-gif/ 301
|
||||
/tools/mkv-to-gif/ /tools/video/mkv-to-gif/ 301
|
||||
/tools/mkv-to-gif /tools/video/mkv-to-gif/ 301
|
||||
/tools/avi-to-gif/ /tools/video/avi-to-gif/ 301
|
||||
/tools/avi-to-gif /tools/video/avi-to-gif/ 301
|
||||
/tools/gif-to-mp4/ /tools/video/gif-to-mp4/ 301
|
||||
/tools/gif-to-mp4 /tools/video/gif-to-mp4/ 301
|
||||
/tools/gif-to-webm/ /tools/video/gif-to-webm/ 301
|
||||
/tools/gif-to-webm /tools/video/gif-to-webm/ 301
|
||||
/tools/gif-to-mov/ /tools/video/gif-to-mov/ 301
|
||||
/tools/gif-to-mov /tools/video/gif-to-mov/ 301
|
||||
/tools/mp4-to-mp3/ /tools/video/mp4-to-mp3/ 301
|
||||
/tools/mp4-to-mp3 /tools/video/mp4-to-mp3/ 301
|
||||
/tools/mov-to-mp3/ /tools/video/mov-to-mp3/ 301
|
||||
/tools/mov-to-mp3 /tools/video/mov-to-mp3/ 301
|
||||
/tools/mkv-to-mp3/ /tools/video/mkv-to-mp3/ 301
|
||||
/tools/mkv-to-mp3 /tools/video/mkv-to-mp3/ 301
|
||||
/tools/webm-to-mp3/ /tools/video/webm-to-mp3/ 301
|
||||
/tools/webm-to-mp3 /tools/video/webm-to-mp3/ 301
|
||||
/tools/avi-to-mp3/ /tools/video/avi-to-mp3/ 301
|
||||
/tools/avi-to-mp3 /tools/video/avi-to-mp3/ 301
|
||||
/tools/mp4-to-wav/ /tools/video/mp4-to-wav/ 301
|
||||
/tools/mp4-to-wav /tools/video/mp4-to-wav/ 301
|
||||
/tools/mov-to-wav/ /tools/video/mov-to-wav/ 301
|
||||
/tools/mov-to-wav /tools/video/mov-to-wav/ 301
|
||||
/tools/mp4-to-ogg/ /tools/video/mp4-to-ogg/ 301
|
||||
/tools/mp4-to-ogg /tools/video/mp4-to-ogg/ 301
|
||||
/tools/m4a-to-mp3/ /tools/audio/m4a-to-mp3/ 301
|
||||
/tools/m4a-to-mp3 /tools/audio/m4a-to-mp3/ 301
|
||||
/tools/m4a-to-wav/ /tools/audio/m4a-to-wav/ 301
|
||||
/tools/m4a-to-wav /tools/audio/m4a-to-wav/ 301
|
||||
/tools/aac-to-mp3/ /tools/audio/aac-to-mp3/ 301
|
||||
/tools/aac-to-mp3 /tools/audio/aac-to-mp3/ 301
|
||||
/tools/aac-to-wav/ /tools/audio/aac-to-wav/ 301
|
||||
/tools/aac-to-wav /tools/audio/aac-to-wav/ 301
|
||||
/tools/aac-to-flac/ /tools/audio/aac-to-flac/ 301
|
||||
/tools/aac-to-flac /tools/audio/aac-to-flac/ 301
|
||||
/tools/ogg-to-mp3/ /tools/audio/ogg-to-mp3/ 301
|
||||
/tools/ogg-to-mp3 /tools/audio/ogg-to-mp3/ 301
|
||||
/tools/ogg-to-wav/ /tools/audio/ogg-to-wav/ 301
|
||||
/tools/ogg-to-wav /tools/audio/ogg-to-wav/ 301
|
||||
/tools/wav-to-mp3/ /tools/audio/wav-to-mp3/ 301
|
||||
/tools/wav-to-mp3 /tools/audio/wav-to-mp3/ 301
|
||||
/tools/mp3-to-wav/ /tools/audio/mp3-to-wav/ 301
|
||||
/tools/mp3-to-wav /tools/audio/mp3-to-wav/ 301
|
||||
/tools/flac-to-mp3/ /tools/audio/flac-to-mp3/ 301
|
||||
/tools/flac-to-mp3 /tools/audio/flac-to-mp3/ 301
|
||||
/tools/excel-to-csv/ /tools/files/excel-to-csv/ 301
|
||||
/tools/excel-to-csv /tools/files/excel-to-csv/ 301
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SnapOtter
|
||||
|
||||
> Open-source, self-hostable file processing suite with 157 tools across 5 modalities (image, video, audio, document, data). Single Docker container, no external services. Dual-licensed AGPLv3 / commercial.
|
||||
> Open-source, self-hostable file processing suite with 240 tools across 5 modalities (image, video, audio, document, data). Single Docker container, no external services. Dual-licensed AGPLv3 / commercial.
|
||||
|
||||
SnapOtter runs entirely on your own infrastructure. Files are processed locally with no data sent to external APIs. Deploy once with Docker and use it as much as you need. Supports GDPR, HIPAA, and CCPA compliance by architecture.
|
||||
|
||||
@@ -60,7 +60,7 @@ SAML SSO (Okta, Azure AD, Google Workspace, any OIDC), SCIM Provisioning, Multi-
|
||||
|
||||
- Self-hosted: all processing on your infrastructure, no cloud dependency
|
||||
- Privacy-first: files never leave your network, air-gapped capable
|
||||
- 157 tools across 5 modalities (image, video, audio, document, data)
|
||||
- 240 tools across 5 modalities (image, video, audio, document, data)
|
||||
- 15 local AI models, no external API calls
|
||||
- Batch processing with ZIP output
|
||||
- Pipeline automation: chain tools sequentially
|
||||
|
||||
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 319 KiB |
@@ -19,6 +19,7 @@ const columns = [
|
||||
{ label: "Enterprise", href: "/#enterprise" },
|
||||
{ label: "Self-Hosted", href: "/#features" },
|
||||
{ label: "Open Source", href: "/#open-source" },
|
||||
{ label: "Alternatives", href: "/alternatives" },
|
||||
{ label: "Pricing", href: "/#pricing" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -32,8 +32,8 @@ const trustBadges = [
|
||||
|
||||
<!-- Headline: one sentence, rotating modality + fixed privacy promise -->
|
||||
<h1 class="animate-fade-up mt-8 font-display text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl" style="animation-delay: 0.1s;">
|
||||
Every <span class="word-rotator" aria-hidden="true"><span class="word-rotator-track"><span>file</span><span>image</span><span>video</span><span>audio</span><span>PDF</span><span>data</span><span>file</span></span></span><span class="sr-only">file</span> tool you need.
|
||||
<span class="mt-3 block bg-gradient-to-r from-primary to-primary-light bg-clip-text text-2xl font-bold text-transparent sm:text-3xl md:text-4xl">Nothing leaves your network.</span>
|
||||
Every <span class="word-rotator" aria-hidden="true"><span class="word-rotator-track"><span>file</span><span>image</span><span>video</span><span>audio</span><span>PDF</span><span>files</span><span>file</span></span></span><span class="sr-only">file</span> tool you need.
|
||||
<span class="mt-3 block bg-gradient-to-r from-primary to-primary-light bg-clip-text text-2xl font-bold text-transparent sm:text-3xl md:text-4xl">Your files never leave your network.</span>
|
||||
</h1>
|
||||
|
||||
<!-- Subtitle: one calm supporting line -->
|
||||
|
||||
@@ -53,6 +53,7 @@ function renderIcon(iconName: string): string {
|
||||
data-name={tool.name.toLowerCase()}
|
||||
data-desc={tool.description.toLowerCase()}
|
||||
data-mod={(mod?.label || "").toLowerCase()}
|
||||
data-keywords={(tool.keywords ?? []).join(" ")}
|
||||
hidden
|
||||
>
|
||||
<a
|
||||
@@ -88,63 +89,59 @@ function renderIcon(iconName: string): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var input = document.getElementById("hero-tool-search");
|
||||
var box = document.getElementById("hero-search-results");
|
||||
var empty = document.getElementById("hero-search-empty");
|
||||
var items = Array.prototype.slice.call(
|
||||
document.querySelectorAll("#hero-search-list .hero-result"),
|
||||
);
|
||||
var MAX = 7;
|
||||
var firstHref = null;
|
||||
<script>
|
||||
import { matchTool } from "../lib/tool-search";
|
||||
|
||||
function close() {
|
||||
const input = document.getElementById("hero-tool-search") as HTMLInputElement | null;
|
||||
const box = document.getElementById("hero-search-results");
|
||||
const empty = document.getElementById("hero-search-empty");
|
||||
const items = Array.from(
|
||||
document.querySelectorAll<HTMLLIElement>("#hero-search-list .hero-result"),
|
||||
);
|
||||
const MAX = 7;
|
||||
let firstHref: string | null = null;
|
||||
|
||||
if (input && box && empty) {
|
||||
const close = () => {
|
||||
box.classList.add("hidden");
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
};
|
||||
|
||||
function open() {
|
||||
const open = () => {
|
||||
box.classList.remove("hidden");
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
};
|
||||
|
||||
function run() {
|
||||
var q = input.value.toLowerCase().trim();
|
||||
const run = () => {
|
||||
const q = input.value.trim();
|
||||
if (!q) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
var tokens = q.split(/\s+/);
|
||||
var shown = 0;
|
||||
let shown = 0;
|
||||
firstHref = null;
|
||||
items.forEach(function (li) {
|
||||
var hay =
|
||||
(li.getAttribute("data-name") || "") +
|
||||
" " +
|
||||
(li.getAttribute("data-desc") || "") +
|
||||
" " +
|
||||
(li.getAttribute("data-mod") || "");
|
||||
var hit = tokens.every(function (t) {
|
||||
return hay.indexOf(t) !== -1;
|
||||
});
|
||||
for (const li of items) {
|
||||
const hay =
|
||||
`${li.getAttribute("data-name") || ""} ${li.getAttribute("data-desc") || ""} ` +
|
||||
`${li.getAttribute("data-mod") || ""} ${li.getAttribute("data-keywords") || ""}`;
|
||||
const hit = matchTool(q, hay);
|
||||
if (hit && shown < MAX) {
|
||||
li.hidden = false;
|
||||
shown++;
|
||||
if (!firstHref) firstHref = li.querySelector("a").getAttribute("href");
|
||||
if (!firstHref) firstHref = li.querySelector("a")?.getAttribute("href") ?? null;
|
||||
} else {
|
||||
li.hidden = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
empty.classList.toggle("hidden", shown !== 0);
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener("input", run);
|
||||
input.addEventListener("focus", function () {
|
||||
input.addEventListener("focus", () => {
|
||||
if (input.value.trim()) run();
|
||||
});
|
||||
input.addEventListener("keydown", function (e) {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && firstHref) {
|
||||
window.location.href = firstHref;
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -152,8 +149,8 @@ function renderIcon(iconName: string): string {
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!box.contains(e.target) && e.target !== input) close();
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!box.contains(e.target as Node) && e.target !== input) close();
|
||||
});
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,6 +8,7 @@ const links = [
|
||||
{ label: "Features", href: "/#features" },
|
||||
{ label: "Enterprise", href: "/#enterprise" },
|
||||
{ label: "Pricing", href: "/#pricing" },
|
||||
{ label: "Alternatives", href: "/alternatives" },
|
||||
{ label: "Docs", href: "https://docs.snapotter.com", external: true },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@ const { display: pullsDisplay } = await getImagePulls();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: "157",
|
||||
value: "240",
|
||||
label: "Processing Tools",
|
||||
sublabel: "Across 5 file modalities",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
// SEO comparison pages: "open-source alternative to <X>" content.
|
||||
// Each entry renders a static page at /alternatives/<slug> plus a card on the hub.
|
||||
// Keep competitor claims general and defensible; the differentiator is self-hosting
|
||||
// plus breadth across all five modalities, not point-by-point feature parity.
|
||||
|
||||
export interface ComparisonRow {
|
||||
feature: string;
|
||||
snapotter: string;
|
||||
competitor: string;
|
||||
/** true when SnapOtter has the advantage on this row (drives the check styling) */
|
||||
snapotterWins: boolean;
|
||||
}
|
||||
|
||||
export interface AltFaq {
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
export interface Alternative {
|
||||
slug: string;
|
||||
/** Competitor brand name, e.g. "Smallpdf" */
|
||||
competitor: string;
|
||||
/** Short category label for cards, e.g. "PDF tools" */
|
||||
category: string;
|
||||
/** SEO <title>, written as the phrase people search */
|
||||
pageTitle: string;
|
||||
/** On-page H1 */
|
||||
h1: string;
|
||||
/** Meta description */
|
||||
metaDescription: string;
|
||||
/** One or two sentence lede under the H1 */
|
||||
intro: string;
|
||||
/** What SnapOtter brings beyond this competitor's single lane */
|
||||
breadth: string;
|
||||
/** true when the competitor is itself open-source / self-hostable (shifts framing to breadth) */
|
||||
competitorOpenSource: boolean;
|
||||
rows: ComparisonRow[];
|
||||
faqs: AltFaq[];
|
||||
}
|
||||
|
||||
const cloudRows = (category: string, pricing: string, beyond: string): ComparisonRow[] => [
|
||||
{
|
||||
feature: "Where your files go",
|
||||
snapotter: "Your own server",
|
||||
competitor: "Their cloud",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Pricing",
|
||||
snapotter: "Free, open source (AGPLv3)",
|
||||
competitor: pricing,
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "File-size limits",
|
||||
snapotter: "Bounded by your hardware",
|
||||
competitor: "Capped by plan",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Works offline / air-gapped",
|
||||
snapotter: "Yes",
|
||||
competitor: "No, needs their servers",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: `Beyond ${category}`,
|
||||
snapotter: "Image, video, audio, PDF, and files",
|
||||
competitor: beyond,
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Open source",
|
||||
snapotter: "Yes, AGPLv3",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "REST API and pipelines",
|
||||
snapotter: "Built in, self-hosted",
|
||||
competitor: "API on paid plans",
|
||||
snapotterWins: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const ALTERNATIVES: Alternative[] = [
|
||||
{
|
||||
slug: "smallpdf",
|
||||
competitor: "Smallpdf",
|
||||
category: "PDF tools",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to Smallpdf",
|
||||
h1: "The open-source, self-hosted alternative to Smallpdf",
|
||||
metaDescription:
|
||||
"Smallpdf uploads your documents to its servers. SnapOtter runs the same PDF tools on yours. 40 PDF tools plus 200 more for image, video, audio, and files. Open source, AGPLv3.",
|
||||
intro:
|
||||
"Smallpdf is a cloud PDF suite, so every file you touch goes up to its servers. SnapOtter runs the same kinds of tools on hardware you own, and the file never leaves it.",
|
||||
breadth:
|
||||
"Smallpdf does PDFs. SnapOtter ships 40 PDF tools (merge, split, compress, convert, redact, OCR, and more) and another 200 across image, video, audio, and files, so one stack covers what you'd otherwise spread across several accounts.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("PDFs", "Subscription, free tier with daily limits", "PDF only"),
|
||||
faqs: [
|
||||
{
|
||||
q: "Is there a free, self-hosted alternative to Smallpdf?",
|
||||
a: "Yes. SnapOtter is open source under AGPLv3 and runs on your own server with Docker. It covers merge, split, compress, convert, protect, redact, watermark, page numbers, and OCR, with no per-file fees.",
|
||||
},
|
||||
{
|
||||
q: "Do my documents get uploaded anywhere?",
|
||||
a: "No. SnapOtter processes files on the machine you run it on. Nothing is sent to a third-party PDF service, which is the whole point of self-hosting it.",
|
||||
},
|
||||
{
|
||||
q: "Does it do more than PDFs?",
|
||||
a: "Yes. Beyond its 40 PDF tools, SnapOtter handles image, video, audio, and file tasks too, 240 tools in one stack.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "ilovepdf",
|
||||
competitor: "iLovePDF",
|
||||
category: "PDF tools",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to iLovePDF",
|
||||
h1: "The open-source, self-hosted alternative to iLovePDF",
|
||||
metaDescription:
|
||||
"iLovePDF processes your files in the cloud. SnapOtter runs PDF merge, split, compress, convert, and OCR on your own server. 240 tools across five file types. Open source, AGPLv3.",
|
||||
intro:
|
||||
"iLovePDF is a hosted PDF service, which means your documents are uploaded to process them. SnapOtter gives you the same toolset to run yourself, with the file staying on your server.",
|
||||
breadth:
|
||||
"iLovePDF stays inside PDFs. SnapOtter pairs 40 PDF tools with image, video, audio, and file tools, so the same deployment handles a contract, a screen recording, and a podcast edit.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("PDFs", "Subscription, free tier with limits", "PDF only"),
|
||||
faqs: [
|
||||
{
|
||||
q: "What's a self-hosted alternative to iLovePDF?",
|
||||
a: "SnapOtter. It's open source (AGPLv3), deploys with Docker, and gives you merge, split, compress, convert, protect, OCR, and the rest on your own hardware.",
|
||||
},
|
||||
{
|
||||
q: "Is SnapOtter free?",
|
||||
a: "The full open-source edition is free forever. A paid enterprise tier adds SSO, audit logging, and similar controls, but every file tool is in the free edition.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "tinypng",
|
||||
competitor: "TinyPNG",
|
||||
category: "Image compression",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to TinyPNG",
|
||||
h1: "The open-source, self-hosted alternative to TinyPNG",
|
||||
metaDescription:
|
||||
"TinyPNG compresses images in the cloud with a monthly quota. SnapOtter compresses PNG, JPEG, WebP, and AVIF on your own server, with no quota and no upload. Open source, AGPLv3.",
|
||||
intro:
|
||||
"TinyPNG is a cloud image compressor with a monthly free quota and an upload step. SnapOtter compresses images on your own hardware, with no quota and nothing leaving your network.",
|
||||
breadth:
|
||||
"TinyPNG compresses images. SnapOtter compresses across PNG, JPEG, WebP, and AVIF, and then keeps going: resize, crop, convert between 14 output formats, watermark, plus 60+ other image tools and full video, audio, PDF, and file support.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("image compression", "Monthly free quota, then paid", "Image compression only"),
|
||||
faqs: [
|
||||
{
|
||||
q: "Is there a self-hosted TinyPNG alternative with no quota?",
|
||||
a: "Yes. SnapOtter runs image compression locally, so the only limit is your hardware. There's no per-month cap and no API key to a third party.",
|
||||
},
|
||||
{
|
||||
q: "Which formats can it compress?",
|
||||
a: "PNG, JPEG, WebP, and AVIF, with conversion between formats. SnapOtter reads 55+ input formats including 23 camera RAW formats.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "cloudconvert",
|
||||
competitor: "CloudConvert",
|
||||
category: "File conversion",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to CloudConvert",
|
||||
h1: "The open-source, self-hosted alternative to CloudConvert",
|
||||
metaDescription:
|
||||
"CloudConvert converts files in the cloud, billed per conversion. SnapOtter converts image, video, audio, PDF, and data formats on your own server. Open source, AGPLv3, no upload.",
|
||||
intro:
|
||||
"CloudConvert is a hosted converter, so files are uploaded and conversions are metered. SnapOtter converts across every modality on hardware you own, with no metering and no upload.",
|
||||
breadth:
|
||||
"CloudConvert converts. SnapOtter converts too, across image, video, audio, PDF, and data formats, and then adds compression, editing, OCR, transcription, and pipelines, so conversion is one of 240 things it does.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("file conversion", "Pay per conversion / minutes", "Conversion only"),
|
||||
faqs: [
|
||||
{
|
||||
q: "Can I self-host a CloudConvert alternative?",
|
||||
a: "Yes. SnapOtter is open source and runs on Docker. It converts image, video, audio, PDF, and data formats locally, with no per-conversion billing.",
|
||||
},
|
||||
{
|
||||
q: "Does it convert video and audio too?",
|
||||
a: "Yes. SnapOtter uses FFmpeg under the hood for video and audio, alongside Sharp for images and qpdf and LibreOffice for documents.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "otter-ai",
|
||||
competitor: "Otter.ai",
|
||||
category: "Transcription",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to Otter.ai",
|
||||
h1: "The open-source, self-hosted alternative to Otter.ai",
|
||||
metaDescription:
|
||||
"Otter.ai transcribes audio in the cloud. SnapOtter transcribes on your own GPU or CPU with local Whisper models. No upload, no per-minute fees. Open source, AGPLv3.",
|
||||
intro:
|
||||
"Otter.ai uploads your recordings to transcribe them in the cloud. SnapOtter runs speech-to-text on your own hardware with local models, so sensitive recordings never leave your network.",
|
||||
breadth:
|
||||
"Otter.ai transcribes. SnapOtter transcribes locally and also handles the rest of an audio workflow (trim, normalize, noise reduction, format conversion) plus video subtitles, image, PDF, and file tools.",
|
||||
competitorOpenSource: false,
|
||||
rows: [
|
||||
{
|
||||
feature: "Where your audio goes",
|
||||
snapotter: "Your own server / GPU",
|
||||
competitor: "Their cloud",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Pricing",
|
||||
snapotter: "Free, open source (AGPLv3)",
|
||||
competitor: "Subscription, per-minute limits",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Transcription engine",
|
||||
snapotter: "Local Whisper models, on your hardware",
|
||||
competitor: "Cloud service",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Works offline / air-gapped",
|
||||
snapotter: "Yes",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Beyond transcription",
|
||||
snapotter: "Audio editing, video, image, PDF, files",
|
||||
competitor: "Notes and transcription",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Open source",
|
||||
snapotter: "Yes, AGPLv3",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
q: "Is there a self-hosted Otter.ai alternative?",
|
||||
a: "Yes. SnapOtter runs transcription locally with Whisper models on your own GPU or CPU. No audio is uploaded to a third party.",
|
||||
},
|
||||
{
|
||||
q: "Do I need a GPU?",
|
||||
a: "A GPU is faster, but transcription also runs on CPU. The models install on demand the first time you use the feature.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "stirling-pdf",
|
||||
competitor: "Stirling-PDF",
|
||||
category: "Self-hosted PDF",
|
||||
pageTitle: "SnapOtter vs Stirling-PDF: Self-Hosted File Tools Compared",
|
||||
h1: "SnapOtter vs Stirling-PDF",
|
||||
metaDescription:
|
||||
"Stirling-PDF is a great self-hosted PDF toolkit. SnapOtter covers PDFs too, plus image, video, audio, and file tools in one stack. Both open source. Here's how they compare.",
|
||||
intro:
|
||||
"Stirling-PDF and SnapOtter are both self-hosted and open source, so files stay on your server either way. The difference is scope: Stirling-PDF focuses on PDFs, SnapOtter spans all five file types.",
|
||||
breadth:
|
||||
"If you only ever touch PDFs, Stirling-PDF is excellent and worth a look. If you also resize images, convert video, transcribe audio, or batch files, SnapOtter handles all of it in one deployment instead of running several tools side by side.",
|
||||
competitorOpenSource: true,
|
||||
rows: [
|
||||
{
|
||||
feature: "Self-hosted",
|
||||
snapotter: "Yes",
|
||||
competitor: "Yes",
|
||||
snapotterWins: false,
|
||||
},
|
||||
{
|
||||
feature: "Open source",
|
||||
snapotter: "Yes, AGPLv3",
|
||||
competitor: "Yes",
|
||||
snapotterWins: false,
|
||||
},
|
||||
{
|
||||
feature: "Modalities covered",
|
||||
snapotter: "Image, video, audio, PDF, files",
|
||||
competitor: "PDF only",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Tool count",
|
||||
snapotter: "240",
|
||||
competitor: "50+ PDF tools",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Local AI (OCR, transcription, upscaling)",
|
||||
snapotter: "Yes, on-demand bundles",
|
||||
competitor: "OCR",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Layer-based image editor",
|
||||
snapotter: "Yes",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Pipelines and batch processing",
|
||||
snapotter: "Yes",
|
||||
competitor: "Pipelines",
|
||||
snapotterWins: false,
|
||||
},
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
q: "Is SnapOtter a replacement for Stirling-PDF?",
|
||||
a: "It can be, if you want one stack for more than PDFs. SnapOtter's PDF coverage is broad, and it adds image, video, audio, and file tools. For a PDF-only setup, Stirling-PDF is a solid, focused choice.",
|
||||
},
|
||||
{
|
||||
q: "Are both free and open source?",
|
||||
a: "Yes. Both are open source and self-hostable. SnapOtter is AGPLv3 and adds an optional paid enterprise tier for SSO and audit features.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "convertx",
|
||||
competitor: "ConvertX",
|
||||
category: "Self-hosted conversion",
|
||||
pageTitle: "SnapOtter vs ConvertX: Self-Hosted File Tools Compared",
|
||||
h1: "SnapOtter vs ConvertX",
|
||||
metaDescription:
|
||||
"ConvertX is a self-hosted file converter for 1000+ formats. SnapOtter converts too, and adds editing, compression, OCR, and transcription across five modalities. Both open source.",
|
||||
intro:
|
||||
"ConvertX and SnapOtter are both self-hosted and open source. ConvertX is built around format conversion; SnapOtter does conversion plus the rest of a file workflow.",
|
||||
breadth:
|
||||
"ConvertX is a focused converter and handles a huge list of formats. SnapOtter converts as well, then adds compression, editing, redaction, OCR, transcription, and pipelines, so it's a fit when conversion is one step among many.",
|
||||
competitorOpenSource: true,
|
||||
rows: [
|
||||
{
|
||||
feature: "Self-hosted",
|
||||
snapotter: "Yes",
|
||||
competitor: "Yes",
|
||||
snapotterWins: false,
|
||||
},
|
||||
{
|
||||
feature: "Open source",
|
||||
snapotter: "Yes, AGPLv3",
|
||||
competitor: "Yes",
|
||||
snapotterWins: false,
|
||||
},
|
||||
{
|
||||
feature: "Primary focus",
|
||||
snapotter: "Full file workflow across five types",
|
||||
competitor: "Format conversion",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Beyond conversion",
|
||||
snapotter: "Compress, edit, redact, OCR, transcribe",
|
||||
competitor: "Conversion",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Tool count",
|
||||
snapotter: "240",
|
||||
competitor: "Conversion-focused",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Local AI tools",
|
||||
snapotter: "OCR, transcription, upscaling, more",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Layer-based image editor",
|
||||
snapotter: "Yes",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
q: "Should I use SnapOtter or ConvertX?",
|
||||
a: "If you mainly need format conversion across many formats, ConvertX is a clean, focused option. If you also edit, compress, redact, OCR, or transcribe, SnapOtter does all of that in one stack.",
|
||||
},
|
||||
{
|
||||
q: "Do both keep files on my server?",
|
||||
a: "Yes. Both are self-hosted, so files stay on your infrastructure. The difference is breadth, not where your data lives.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -12,8 +12,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const toolCount = TOOLS.length;
|
||||
const defaultTitle = `SnapOtter | Self-Hosted File Processing for Enterprise`;
|
||||
const defaultDescription = `The self-hosted file-processing suite for teams that keep sensitive data in-house. ${toolCount} tools with local AI, SSO, audit logging, and full data control.`;
|
||||
const defaultTitle = `SnapOtter | Self-Hosted File Processing (Image, Video, Audio, PDF, Files)`;
|
||||
const defaultDescription = `Self-hosted, open-source file toolkit. ${toolCount} tools across image, video, audio, PDF, and files. The open-source alternative to Smallpdf, iLovePDF, and CloudConvert, on infrastructure you own.`;
|
||||
|
||||
const {
|
||||
title = defaultTitle,
|
||||
@@ -56,7 +56,7 @@ const {
|
||||
<meta property="og:image" content={`https://snapotter.com${ogImage}`} />
|
||||
<meta property="og:image:width" content="1280" />
|
||||
<meta property="og:image:height" content="640" />
|
||||
<meta property="og:image:alt" content="SnapOtter - Self-Hosted File Processing for Enterprise" />
|
||||
<meta property="og:image:alt" content="SnapOtter - self-hosted file processing for image, video, audio, PDF, and files" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { normalizeSearchQuery } from "@snapotter/shared/search/format-aliases.js";
|
||||
|
||||
/** Bounded Levenshtein: returns edit distance, short-circuiting above `max`. */
|
||||
function editDistance(a: string, b: string, max: number): number {
|
||||
if (Math.abs(a.length - b.length) > max) return max + 1;
|
||||
const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let prev = dp[0];
|
||||
dp[0] = i;
|
||||
let rowMin = dp[0];
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const tmp = dp[j];
|
||||
dp[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]);
|
||||
prev = tmp;
|
||||
if (dp[j] < rowMin) rowMin = dp[j];
|
||||
}
|
||||
if (rowMin > max) return max + 1;
|
||||
}
|
||||
return dp[b.length];
|
||||
}
|
||||
|
||||
/** True if every normalized query token appears in the haystack (substring), or matches a haystack word within a small edit distance. */
|
||||
export function matchTool(rawQuery: string, haystack: string): boolean {
|
||||
const q = normalizeSearchQuery(rawQuery);
|
||||
if (!q) return true;
|
||||
const hay = haystack.toLowerCase();
|
||||
const hayWords = hay.split(/\s+/);
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
return tokens.every((tok) => {
|
||||
if (tok === "to") return true;
|
||||
if (hay.indexOf(tok) !== -1) return true;
|
||||
const max = tok.length <= 4 ? 1 : 2;
|
||||
return hayWords.some((w) => editDistance(tok, w, max) <= max);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import JsonLd from "@/components/JsonLd.astro";
|
||||
import Navbar from "@/components/Navbar.astro";
|
||||
import { ALTERNATIVES } from "@/data/alternatives";
|
||||
import Base from "@/layouts/Base.astro";
|
||||
|
||||
export function getStaticPaths() {
|
||||
return ALTERNATIVES.map((alt) => ({
|
||||
params: { slug: alt.slug },
|
||||
props: { alt },
|
||||
}));
|
||||
}
|
||||
|
||||
const { alt } = Astro.props;
|
||||
const canonical = `https://snapotter.com/alternatives/${alt.slug}`;
|
||||
const others = ALTERNATIVES.filter((a) => a.slug !== alt.slug);
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Alternatives",
|
||||
item: "https://snapotter.com/alternatives",
|
||||
},
|
||||
{ "@type": "ListItem", position: 3, name: alt.competitor, item: canonical },
|
||||
],
|
||||
};
|
||||
|
||||
const faqJsonLd =
|
||||
alt.faqs.length > 0
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: alt.faqs.map((faq) => ({
|
||||
"@type": "Question",
|
||||
name: faq.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: faq.a },
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
const allJsonLd = [breadcrumbJsonLd, ...(faqJsonLd ? [faqJsonLd] : [])];
|
||||
---
|
||||
|
||||
<Base title={`${alt.pageTitle} | SnapOtter`} description={alt.metaDescription} canonical={canonical}>
|
||||
<Fragment slot="head">
|
||||
<JsonLd data={allJsonLd} />
|
||||
</Fragment>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main id="main">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mx-auto max-w-4xl px-6 pt-28 md:pt-36" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-2 text-sm text-muted">
|
||||
<li><a href="/" class="transition-colors hover:text-foreground">Home</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<a href="/alternatives" class="transition-colors hover:text-foreground">Alternatives</a>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="font-medium text-foreground">{alt.competitor}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="mx-auto max-w-4xl px-6 pt-8 pb-12">
|
||||
<div class="reveal">
|
||||
<span class="rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-semibold text-primary-dark">
|
||||
{alt.category}
|
||||
</span>
|
||||
<h1 class="mt-5 font-display text-4xl font-bold tracking-tight md:text-5xl">
|
||||
{alt.h1}
|
||||
</h1>
|
||||
<p class="mt-4 text-lg leading-relaxed text-muted md:text-xl">
|
||||
{alt.intro}
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
<a
|
||||
href="https://docs.snapotter.com/guide/getting-started"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
|
||||
>
|
||||
Deploy with Docker
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://demo.snapotter.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
|
||||
>
|
||||
Try the live demo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Comparison table -->
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
|
||||
SnapOtter vs {alt.competitor}
|
||||
</h2>
|
||||
<div class="mt-8 overflow-x-auto rounded-xl border border-border bg-surface">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-border bg-background-alt">
|
||||
<tr>
|
||||
<th class="px-5 py-4 text-start font-semibold"> </th>
|
||||
<th class="px-5 py-4 text-start font-semibold text-primary-dark">SnapOtter</th>
|
||||
<th class="px-5 py-4 text-start font-semibold">{alt.competitor}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{alt.rows.map((row) => (
|
||||
<tr class="border-b border-border last:border-0">
|
||||
<td class="px-5 py-4 font-medium">{row.feature}</td>
|
||||
<td class="px-5 py-4">
|
||||
<span class="flex items-start gap-2">
|
||||
{row.snapotterWins && (
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{row.snapotter}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-muted">{row.competitor}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-muted">
|
||||
Comparison reflects the open-source editions and publicly documented plans. Vendor
|
||||
features change; check current docs before deciding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Breadth callout -->
|
||||
<section class="py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal flex items-start gap-4">
|
||||
<svg class="mt-1 h-6 w-6 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
<div>
|
||||
<h2 class="font-display text-xl font-bold">One stack, all five file types</h2>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">{alt.breadth}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQs -->
|
||||
{alt.faqs.length > 0 && (
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<dl class="mt-8 space-y-6">
|
||||
{alt.faqs.map((faq) => (
|
||||
<div class="rounded-xl border border-border bg-surface p-6">
|
||||
<dt class="text-sm font-semibold">{faq.q}</dt>
|
||||
<dd class="mt-3 text-sm leading-relaxed text-muted">{faq.a}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- Other comparisons -->
|
||||
<section class="py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight">More comparisons</h2>
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{others.map((o) => (
|
||||
<a
|
||||
href={`/alternatives/${o.slug}`}
|
||||
class="group flex flex-col rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
|
||||
>
|
||||
<span class="text-sm font-bold">SnapOtter vs {o.competitor}</span>
|
||||
<span class="mt-1 text-xs text-muted">{o.category}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bottom CTA -->
|
||||
<section class="border-t border-border py-20">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-3xl font-bold tracking-tight">
|
||||
Run it on your own server
|
||||
</h2>
|
||||
<p class="mx-auto mt-4 max-w-lg text-muted">
|
||||
240 tools across image, video, audio, PDF, and files. Open source, free forever, and your
|
||||
files never leave your network.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-4">
|
||||
<a
|
||||
href="https://docs.snapotter.com/guide/getting-started"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
|
||||
>
|
||||
Get started
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="/alternatives"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
|
||||
>
|
||||
All alternatives
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Base>
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import JsonLd from "@/components/JsonLd.astro";
|
||||
import Navbar from "@/components/Navbar.astro";
|
||||
import { ALTERNATIVES } from "@/data/alternatives";
|
||||
import Base from "@/layouts/Base.astro";
|
||||
|
||||
const canonical = "https://snapotter.com/alternatives";
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
|
||||
{ "@type": "ListItem", position: 2, name: "Alternatives", item: canonical },
|
||||
],
|
||||
};
|
||||
|
||||
const itemListJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
itemListElement: ALTERNATIVES.map((alt, i) => ({
|
||||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: alt.pageTitle,
|
||||
url: `https://snapotter.com/alternatives/${alt.slug}`,
|
||||
})),
|
||||
};
|
||||
|
||||
const allJsonLd = [breadcrumbJsonLd, itemListJsonLd];
|
||||
---
|
||||
|
||||
<Base
|
||||
title="Open-Source, Self-Hosted Alternatives to Cloud File Tools | SnapOtter"
|
||||
description="Open-source, self-hosted alternatives to Smallpdf, iLovePDF, TinyPNG, CloudConvert, and Otter.ai. One stack for image, video, audio, PDF, and files, on infrastructure you own."
|
||||
canonical={canonical}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<JsonLd data={allJsonLd} />
|
||||
</Fragment>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main id="main">
|
||||
<!-- Hero -->
|
||||
<section class="mx-auto max-w-4xl px-6 pt-28 pb-12 text-center md:pt-36">
|
||||
<div class="reveal">
|
||||
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">
|
||||
Open-source alternatives to cloud file tools
|
||||
</h1>
|
||||
<p class="mx-auto mt-5 max-w-2xl text-lg leading-relaxed text-muted md:text-xl">
|
||||
Most file tools upload your work to someone else's server. SnapOtter runs the same kinds of
|
||||
tools on yours: 240 of them across image, video, audio, PDF, and files, in one self-hosted
|
||||
stack.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cards -->
|
||||
<section class="mx-auto max-w-5xl px-6 pb-16">
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ALTERNATIVES.map((alt) => (
|
||||
<a
|
||||
href={`/alternatives/${alt.slug}`}
|
||||
class="group flex flex-col rounded-xl border border-border bg-surface p-6 transition-all hover:border-primary hover:shadow-md"
|
||||
>
|
||||
<span class="text-xs font-semibold text-primary-dark">{alt.category}</span>
|
||||
<span class="mt-2 font-display text-lg font-bold tracking-tight">
|
||||
{alt.competitorOpenSource
|
||||
? `SnapOtter vs ${alt.competitor}`
|
||||
: `The alternative to ${alt.competitor}`}
|
||||
</span>
|
||||
<span class="mt-2 text-sm leading-relaxed text-muted">{alt.intro}</span>
|
||||
<span class="mt-auto inline-flex items-center gap-1 pt-5 text-sm font-medium text-primary transition-all group-hover:gap-2">
|
||||
Compare
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why self-hosted -->
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
|
||||
Why teams pick the self-hosted route
|
||||
</h2>
|
||||
<div class="mt-8 grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">Your files stay yours</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
Processing happens on hardware you control. Nothing is uploaded to a third-party
|
||||
service, which matters when the file is a contract, a medical scan, or unreleased work.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">No quotas or per-file fees</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
The only limit is your machine. No monthly cap, no per-conversion billing, no
|
||||
watermarks on the free output.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">One stack, five file types</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
Image, video, audio, PDF, and files in a single deployment, instead of a separate
|
||||
account for each job.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">Open source, auditable</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
AGPLv3. Your team can read every line, run it air-gapped, and keep it as long as you
|
||||
like.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bottom CTA -->
|
||||
<section class="py-20">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-3xl font-bold tracking-tight">Run it on your own server</h2>
|
||||
<p class="mx-auto mt-4 max-w-lg text-muted">
|
||||
Open source, free forever, and your files never leave your network.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-4">
|
||||
<a
|
||||
href="https://docs.snapotter.com/guide/getting-started"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
|
||||
>
|
||||
Get started
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/snapotter-hq/snapotter"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Base>
|
||||
@@ -33,7 +33,7 @@ const faqs = [
|
||||
{
|
||||
question: "Does SnapOtter collect any analytics?",
|
||||
answer:
|
||||
"Analytics is completely optional and disabled by default. If you choose to enable it, SnapOtter collects anonymous usage data and error logs to help improve the software. No personal data or file content is ever collected. You have full control over this in the settings and can disable it at any time.",
|
||||
"Anonymous product analytics is on by default and never includes your files or their contents. Turn it off any time with a one-click admin toggle in Settings > System > Privacy.",
|
||||
},
|
||||
{
|
||||
question: "Can I use SnapOtter in my company?",
|
||||
|
||||
@@ -86,12 +86,18 @@ const breadcrumbJsonLd = {
|
||||
<h2 class="text-lg font-semibold text-foreground">Analytics</h2>
|
||||
<ul class="mt-3 list-disc space-y-2 pl-5">
|
||||
<li>
|
||||
Basic analytics (tool usage patterns, error reports) help improve the software.
|
||||
Your files and personal data are never included.
|
||||
Anonymous product analytics (tool usage patterns, error reports) help improve the
|
||||
software. Your files, file names, and personal data are never included.
|
||||
</li>
|
||||
<li>
|
||||
You can disable analytics at any time -- SnapOtter works normally without it.
|
||||
Rebuild with <code class="text-xs">--build-arg SNAPOTTER_ANALYTICS=off</code>.
|
||||
It is on by default. An administrator can turn it off any time with a one-click
|
||||
toggle under Settings > System > Privacy. No rebuild is required, and it stops
|
||||
immediately for the whole instance.
|
||||
</li>
|
||||
<li>
|
||||
To build an image that can never emit analytics, set the
|
||||
<code class="text-xs">SNAPOTTER_ANALYTICS=off</code> build arg as a compile-time
|
||||
hard-off.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -190,6 +190,7 @@ const collectionJsonLd = {
|
||||
data-name={tool.name.toLowerCase()}
|
||||
data-desc={tool.description.toLowerCase()}
|
||||
data-modality={tool.modality}
|
||||
data-keywords={(tool.keywords ?? []).join(" ")}
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
@@ -265,64 +266,63 @@ const collectionJsonLd = {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var search = document.getElementById("tools-search");
|
||||
var countEl = document.getElementById("tools-count");
|
||||
var emptyEl = document.getElementById("tools-empty");
|
||||
var items = Array.prototype.slice.call(document.querySelectorAll(".tool-item"));
|
||||
var sections = Array.prototype.slice.call(document.querySelectorAll("[data-cat-section]"));
|
||||
var modBtns = Array.prototype.slice.call(document.querySelectorAll(".tools-mod-btn"));
|
||||
var total = items.length;
|
||||
var activeMod = "all";
|
||||
<script>
|
||||
import { matchTool } from "../../lib/tool-search";
|
||||
|
||||
function setBtns() {
|
||||
modBtns.forEach(function (b) {
|
||||
var on = b.getAttribute("data-modality") === activeMod;
|
||||
const search = document.getElementById("tools-search") as HTMLInputElement | null;
|
||||
const countEl = document.getElementById("tools-count");
|
||||
const emptyEl = document.getElementById("tools-empty");
|
||||
const items = Array.from(document.querySelectorAll<HTMLElement>(".tool-item"));
|
||||
const sections = Array.from(document.querySelectorAll<HTMLElement>("[data-cat-section]"));
|
||||
const modBtns = Array.from(document.querySelectorAll<HTMLElement>(".tools-mod-btn"));
|
||||
const total = items.length;
|
||||
let activeMod = "all";
|
||||
|
||||
if (search && countEl && emptyEl) {
|
||||
const setBtns = () => {
|
||||
for (const b of modBtns) {
|
||||
const on = b.getAttribute("data-modality") === activeMod;
|
||||
b.className =
|
||||
"tools-mod-btn shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors " +
|
||||
(on ? "bg-primary text-white" : "border border-border bg-surface hover:bg-background-alt");
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function filter() {
|
||||
var q = search.value.toLowerCase().trim();
|
||||
var visible = 0;
|
||||
items.forEach(function (el) {
|
||||
var matchMod = activeMod === "all" || el.getAttribute("data-modality") === activeMod;
|
||||
var matchQ =
|
||||
!q ||
|
||||
el.getAttribute("data-name").indexOf(q) !== -1 ||
|
||||
el.getAttribute("data-desc").indexOf(q) !== -1;
|
||||
var show = matchMod && matchQ;
|
||||
const filter = () => {
|
||||
const q = search.value.trim();
|
||||
let visible = 0;
|
||||
for (const el of items) {
|
||||
const matchMod = activeMod === "all" || el.getAttribute("data-modality") === activeMod;
|
||||
const hay =
|
||||
`${el.getAttribute("data-name") || ""} ${el.getAttribute("data-desc") || ""} ` +
|
||||
`${el.getAttribute("data-keywords") || ""}`;
|
||||
const matchQ = !q || matchTool(q, hay);
|
||||
const show = matchMod && matchQ;
|
||||
el.style.display = show ? "" : "none";
|
||||
if (show) visible++;
|
||||
});
|
||||
sections.forEach(function (sec) {
|
||||
var anyVisible = Array.prototype.some.call(
|
||||
sec.querySelectorAll(".tool-item"),
|
||||
function (el) {
|
||||
return el.style.display !== "none";
|
||||
},
|
||||
}
|
||||
for (const sec of sections) {
|
||||
const anyVisible = Array.from(sec.querySelectorAll<HTMLElement>(".tool-item")).some(
|
||||
(el) => el.style.display !== "none",
|
||||
);
|
||||
sec.style.display = anyVisible ? "" : "none";
|
||||
});
|
||||
}
|
||||
emptyEl.style.display = visible === 0 ? "block" : "none";
|
||||
countEl.textContent =
|
||||
q || activeMod !== "all"
|
||||
? "Showing " + visible + " of " + total + " tools"
|
||||
: "Showing all " + total + " tools";
|
||||
}
|
||||
? `Showing ${visible} of ${total} tools`
|
||||
: `Showing all ${total} tools`;
|
||||
};
|
||||
|
||||
search.addEventListener("input", filter);
|
||||
modBtns.forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
activeMod = b.getAttribute("data-modality");
|
||||
for (const b of modBtns) {
|
||||
b.addEventListener("click", () => {
|
||||
activeMod = b.getAttribute("data-modality") ?? "all";
|
||||
setBtns();
|
||||
filter();
|
||||
});
|
||||
});
|
||||
})();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Footer />
|
||||
|
||||
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 319 KiB |
@@ -8,7 +8,7 @@ import { RouteAnnouncer } from "./components/common/route-announcer";
|
||||
import { I18nProvider } from "./contexts/i18n-context";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { useMobile } from "./hooks/use-mobile";
|
||||
import { initAnalytics, track } from "./lib/analytics";
|
||||
import { initAnalytics, isAnalyticsActive, optOut, track } from "./lib/analytics";
|
||||
import { useAnalyticsStore } from "./stores/analytics-store";
|
||||
|
||||
// Lazy-load all pages so each page's JS (and its icons/deps) is only
|
||||
@@ -48,13 +48,12 @@ class ErrorBoundary extends Component<
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Uncaught render error:", error, info.componentStack);
|
||||
// Mirror the crash to PostHog (error class only, no PII). track() is best-effort.
|
||||
if (!isAnalyticsActive()) return; // respect the runtime opt-out
|
||||
// Mirror the crash class only (no PII). track() and Sentry are best-effort.
|
||||
track(ANALYTICS_EVENTS.TOOL_CLIENT_ERROR, { error_name: error.name });
|
||||
import("@sentry/react")
|
||||
.then((Sentry) => {
|
||||
Sentry.captureException(error, {
|
||||
contexts: { react: { componentStack: info.componentStack ?? undefined } },
|
||||
});
|
||||
Sentry.captureException(error);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -160,10 +159,27 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!analyticsConfigLoaded || !analyticsConfig?.enabled) return;
|
||||
void initAnalytics(analyticsConfig);
|
||||
if (!analyticsConfigLoaded) return;
|
||||
if (analyticsConfig?.enabled) {
|
||||
void initAnalytics(analyticsConfig);
|
||||
} else if (isAnalyticsActive()) {
|
||||
// Instance-wide opt-out observed after this tab already initialized.
|
||||
optOut();
|
||||
}
|
||||
}, [analyticsConfigLoaded, analyticsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const refetch = () => {
|
||||
if (document.visibilityState === "visible") void fetchAnalyticsConfig();
|
||||
};
|
||||
document.addEventListener("visibilitychange", refetch);
|
||||
window.addEventListener("focus", refetch);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", refetch);
|
||||
window.removeEventListener("focus", refetch);
|
||||
};
|
||||
}, [fetchAnalyticsConfig]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<I18nProvider>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { FEATURE_BUNDLES, type PipelineTemplate, templateRequiredBundles } from "@snapotter/shared";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { getTemplateDescription, getTemplateName } from "@/lib/template-i18n";
|
||||
import { getToolName } from "@/lib/tool-i18n";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: PipelineTemplate;
|
||||
onUse: (template: PipelineTemplate) => void;
|
||||
}
|
||||
|
||||
export function TemplateCard({ template, onUse }: TemplateCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const bundles = useFeaturesStore((s) => s.bundles);
|
||||
const installing = useFeaturesStore((s) => s.installing);
|
||||
|
||||
const requiredBundles = useMemo(() => templateRequiredBundles(template), [template]);
|
||||
|
||||
const name = getTemplateName(t, template.id, template.id);
|
||||
const description = getTemplateDescription(t, template.id, "");
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`template-card-${template.id}`}
|
||||
className="flex flex-col gap-1.5 p-2 rounded border border-border hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUse(template)}
|
||||
className="text-start"
|
||||
data-testid={`template-use-${template.id}`}
|
||||
>
|
||||
<span className="text-xs font-medium text-foreground hover:text-primary">{name}</span>
|
||||
{description ? (
|
||||
<span className="block text-[11px] text-muted-foreground mt-0.5">{description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{template.steps.map((step, i) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: steps are an ordered, static list with no stable id and may repeat a toolId
|
||||
key={`${step.toolId}-${i}`}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
|
||||
>
|
||||
{getToolName(t, step.toolId, step.toolId)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{requiredBundles.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{requiredBundles.map((bundleId) => {
|
||||
const installed = bundles.find((b) => b.id === bundleId)?.status === "installed";
|
||||
const isInstalling = Boolean(installing[bundleId]);
|
||||
const label = FEATURE_BUNDLES[bundleId]?.name ?? bundleId;
|
||||
return (
|
||||
<span
|
||||
key={bundleId}
|
||||
data-testid={`template-bundle-${template.id}-${bundleId}`}
|
||||
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-primary/5 text-muted-foreground"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : installed ? null : (
|
||||
<Download className="h-3 w-3" />
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type Modality, PIPELINE_TEMPLATES, type PipelineTemplate } from "@snapotter/shared";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { getModalityName } from "@/lib/tool-i18n";
|
||||
import { TemplateCard } from "./template-card";
|
||||
|
||||
const MODALITY_ORDER: Modality[] = ["image", "document", "audio", "video", "file"];
|
||||
|
||||
interface TemplatesSectionProps {
|
||||
onUse: (template: PipelineTemplate) => void;
|
||||
emphasized: boolean;
|
||||
}
|
||||
|
||||
export function TemplatesSection({ onUse, emphasized }: TemplatesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const groups = MODALITY_ORDER.map((modality) => ({
|
||||
modality,
|
||||
templates: PIPELINE_TEMPLATES.filter((tpl) => tpl.modality === modality),
|
||||
})).filter((g) => g.templates.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="templates-section"
|
||||
className={`px-3 py-2 border-t border-border shrink-0 ${emphasized ? "bg-muted/30" : ""}`}
|
||||
>
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-1.5">
|
||||
{t.automate.templatesLabel}
|
||||
</h3>
|
||||
<div className="space-y-3 max-h-72 overflow-y-auto">
|
||||
{groups.map((group) => (
|
||||
<div key={group.modality}>
|
||||
<div className="text-[11px] font-medium text-muted-foreground mb-1">
|
||||
{getModalityName(t, group.modality, group.modality)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{group.templates.map((tpl) => (
|
||||
<TemplateCard key={tpl.id} template={tpl} onUse={onUse} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { HelpDialog } from "../help/help-dialog";
|
||||
import { SettingsDialog } from "../settings/settings-dialog";
|
||||
import { AiInstallIndicator } from "./ai-install-indicator";
|
||||
@@ -21,6 +22,16 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
|
||||
const connectionStatus = useConnectionStore((s) => s.status);
|
||||
const bannerVisible = connectionStatus !== "connected";
|
||||
|
||||
// Load global settings (disabled tools, experimental flag, default theme) on
|
||||
// every authenticated page, not just the home grid. Without this, navigating
|
||||
// directly to a tool URL leaves disabledTools empty, so an admin-disabled tool
|
||||
// renders normally instead of showing the disabled message. fetch() is a
|
||||
// no-op once loaded, so this is cheap on subsequent navigations.
|
||||
const fetchSettings = useSettingsStore((s) => s.fetch);
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -52,8 +52,9 @@ export function AvatarDropdown({ onSettingsClick, variant = "light" }: AvatarDro
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-semibold hover:opacity-90 transition-opacity"
|
||||
className="w-7 h-7 rounded-full bg-primary text-[#1a1814] flex items-center justify-center text-xs font-semibold hover:opacity-90 transition-opacity"
|
||||
aria-label={username}
|
||||
data-testid="user-menu"
|
||||
>
|
||||
{initial}
|
||||
</button>
|
||||
|
||||
@@ -36,6 +36,7 @@ import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@
|
||||
import { format, plural } from "@/lib/format";
|
||||
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
|
||||
import { cn, copyToClipboard } from "@/lib/utils";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { OtterLogo } from "../common/otter-logo";
|
||||
@@ -362,10 +363,10 @@ function GeneralSection() {
|
||||
role: "unknown",
|
||||
});
|
||||
}),
|
||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||
apiGet<{ preferences: Record<string, unknown> }>("/v1/preferences")
|
||||
.then((data) => {
|
||||
if (data.settings.defaultToolView) {
|
||||
setDefaultToolView(data.settings.defaultToolView);
|
||||
if (typeof data.preferences.defaultToolView === "string") {
|
||||
setDefaultToolView(data.preferences.defaultToolView);
|
||||
}
|
||||
})
|
||||
.catch(() => {}),
|
||||
@@ -397,7 +398,10 @@ function GeneralSection() {
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
await apiPut("/v1/settings", { defaultToolView });
|
||||
// The default home view is a per-user preference, not instance config, so
|
||||
// it saves to /v1/preferences (writable by any authenticated user) rather
|
||||
// than the admin-only /v1/settings.
|
||||
await apiPut("/v1/preferences", { defaultToolView });
|
||||
setSaveMsg(t.settings.general.saveSuccess);
|
||||
useSettingsStore.setState({
|
||||
defaultToolView: defaultToolView as "sidebar" | "fullscreen",
|
||||
@@ -549,7 +553,24 @@ function SystemSection() {
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
await apiPut("/v1/settings", settings);
|
||||
// GET returns read-only keys (instance_id, cookie_secret) and shows redacted
|
||||
// secrets as the literal "********". Echoing those back fails with 400
|
||||
// READONLY_SETTING or would overwrite a real secret with the mask, so send
|
||||
// only the keys this section can actually change.
|
||||
const READONLY_KEYS = new Set(["instance_id", "cookie_secret"]);
|
||||
const writable = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key, value]) => !READONLY_KEYS.has(key) && value !== "********",
|
||||
),
|
||||
);
|
||||
await apiPut("/v1/settings", writable);
|
||||
if (settings.analyticsEnabled === "false") {
|
||||
const { optOut } = await import("@/lib/analytics");
|
||||
optOut();
|
||||
} else {
|
||||
// Re-enabling takes effect on the next config refetch / reload.
|
||||
useAnalyticsStore.getState().fetchConfig();
|
||||
}
|
||||
if (settings.defaultTheme) {
|
||||
const theme = settings.defaultTheme as "light" | "dark" | "system";
|
||||
useThemeStore.getState().setTheme(theme);
|
||||
@@ -685,6 +706,39 @@ function SystemSection() {
|
||||
</button>
|
||||
</SettingRow>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">{t.settings.privacy.title}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">{t.settings.privacy.description}</p>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t.settings.privacy.analyticsLabel}
|
||||
description={t.settings.privacy.analyticsDescription}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={settings.analyticsEnabled !== "false"}
|
||||
aria-label={t.settings.privacy.analyticsLabel}
|
||||
onClick={() =>
|
||||
updateSetting(
|
||||
"analyticsEnabled",
|
||||
settings.analyticsEnabled === "false" ? "true" : "false",
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"w-11 h-6 rounded-full transition-colors relative",
|
||||
settings.analyticsEnabled !== "false" ? "bg-primary" : "bg-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block w-4 h-4 rounded-full bg-white absolute top-1 transition-transform",
|
||||
settings.analyticsEnabled !== "false" ? "translate-x-6" : "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</SettingRow>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t.settings.dataRetention.title}
|
||||
@@ -1631,6 +1685,10 @@ function PeopleSection() {
|
||||
setShowAddForm(false);
|
||||
setShowGeneratedPw(false);
|
||||
setPwCopied(false);
|
||||
// Reset the field values too, so re-opening the form is clean.
|
||||
setNewUsername("");
|
||||
setNewPassword("");
|
||||
setAddError(null);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
@@ -2415,8 +2473,17 @@ function TeamsSection() {
|
||||
className="px-2 py-1 rounded border border-border bg-background text-sm text-foreground w-40"
|
||||
ref={(el) => el?.focus()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename(tm.id);
|
||||
if (e.key === "Escape") setEditingTeamId(null);
|
||||
// Keep Enter/Escape scoped to the rename input. Without
|
||||
// stopping propagation, Escape also reaches the dialog's
|
||||
// global Escape handler and closes the whole dialog.
|
||||
if (e.key === "Enter") {
|
||||
e.stopPropagation();
|
||||
handleRename(tm.id);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setEditingTeamId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -14,6 +14,15 @@ export interface CompressControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/** Stable serialization (sorted keys) for comparing two settings payloads. */
|
||||
function canonicalSettings(settings: Record<string, unknown>): string {
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(settings).sort()) {
|
||||
sorted[key] = settings[key];
|
||||
}
|
||||
return JSON.stringify(sorted);
|
||||
}
|
||||
|
||||
export function CompressControls({ settings: initialSettings, onChange }: CompressControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<CompressMode>("targetSize");
|
||||
@@ -21,12 +30,15 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
|
||||
const [targetSizeValue, setTargetSizeValue] = useState("");
|
||||
const [sizeUnit, setSizeUnit] = useState<SizeUnit>("KB");
|
||||
|
||||
const prevSettingsKeyRef = useRef<string | null>(null);
|
||||
// Seed local state from preloaded settings exactly once (a pipeline step can
|
||||
// mount this control with non-default settings). One-time, not a re-sync: a
|
||||
// re-sync would keep pulling the store's value back into local state and fight
|
||||
// the emit effect below, ping-ponging into an infinite render loop (React
|
||||
// error #185). Mirrors resize-settings / convert-settings.
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialSettings) return;
|
||||
const key = JSON.stringify(initialSettings);
|
||||
if (prevSettingsKeyRef.current === key) return;
|
||||
prevSettingsKeyRef.current = key;
|
||||
if (!initialSettings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initialSettings.mode != null) setMode(initialSettings.mode as CompressMode);
|
||||
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
|
||||
if (initialSettings.targetSizeKb != null)
|
||||
@@ -38,15 +50,28 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
// Emit settings on change. Once local state has converged on initialSettings
|
||||
// (a preloaded pipeline step), the computed payload equals initialSettings, so
|
||||
// we skip the echo. A genuine user edit produces a payload that differs, so it
|
||||
// still emits. This idempotent guard replaces an earlier one-shot flag that
|
||||
// could dangle and silently swallow a real edit when the sync effect's
|
||||
// setState calls were no-ops; it has no such failure mode.
|
||||
useEffect(() => {
|
||||
if (mode === "quality") {
|
||||
onChangeRef.current?.({ mode, quality });
|
||||
} else {
|
||||
const valueNum = Number(targetSizeValue);
|
||||
const targetSizeKb = sizeUnit === "MB" ? valueNum * 1024 : valueNum;
|
||||
onChangeRef.current?.({ mode, targetSizeKb });
|
||||
const next: Record<string, unknown> =
|
||||
mode === "quality"
|
||||
? { mode, quality }
|
||||
: {
|
||||
mode,
|
||||
targetSizeKb:
|
||||
sizeUnit === "MB" ? Number(targetSizeValue) * 1024 : Number(targetSizeValue),
|
||||
};
|
||||
|
||||
if (initialSettings && canonicalSettings(next) === canonicalSettings(initialSettings)) {
|
||||
return;
|
||||
}
|
||||
}, [mode, quality, targetSizeValue, sizeUnit]);
|
||||
|
||||
onChangeRef.current?.(next);
|
||||
}, [mode, quality, targetSizeValue, sizeUnit, initialSettings]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { BASE_CONFIG, CONVERSION_PRESET_BY_ID } from "@snapotter/shared";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
/** Target formats that honor a quality knob (lossy raster encodings). */
|
||||
const LOSSY = new Set(["jpg", "jpeg", "webp", "avif"]);
|
||||
|
||||
/**
|
||||
* One settings panel shared by every conversion preset. Reads the active tool
|
||||
* id from the route (the /:section/:toolId param), looks up the preset and its
|
||||
* base settingsKind from shared metadata, and renders the matching controls:
|
||||
* a quality slider for lossy raster/svg targets, a quality select for
|
||||
* convert-video, page size + orientation for image-to-pdf, nothing otherwise.
|
||||
*/
|
||||
export function ConversionPresetSettings() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ toolId: string }>();
|
||||
const toolId = params.toolId ?? "";
|
||||
const preset = CONVERSION_PRESET_BY_ID[toolId];
|
||||
const kind = preset ? BASE_CONFIG[preset.base].settingsKind : "none";
|
||||
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor(toolId);
|
||||
|
||||
const [quality, setQuality] = useState(85);
|
||||
const [videoQuality, setVideoQuality] = useState<"high" | "balanced" | "small">("balanced");
|
||||
const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4");
|
||||
const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const targetIsLossy = preset ? LOSSY.has(String(preset.locked.format ?? "")) : false;
|
||||
|
||||
const buildSettings = (): Record<string, unknown> => {
|
||||
if (kind === "quality" && targetIsLossy) return { quality };
|
||||
if (kind === "video") return { quality: videoQuality };
|
||||
if (kind === "pdf") return { pageSize, orientation };
|
||||
return {};
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = buildSettings();
|
||||
if (files.length > 1) processAllFiles(files, settings);
|
||||
else processFiles(files, settings);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasFile && !processing) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{kind === "quality" && targetIsLossy && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="preset-quality" className="text-xs text-muted-foreground">
|
||||
Quality
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{quality}</span>
|
||||
</div>
|
||||
<input
|
||||
id="preset-quality"
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === "video" && (
|
||||
<div>
|
||||
<label htmlFor="preset-vq" className="text-xs text-muted-foreground">
|
||||
Quality
|
||||
</label>
|
||||
<select
|
||||
id="preset-vq"
|
||||
value={videoQuality}
|
||||
onChange={(e) => setVideoQuality(e.target.value as "high" | "balanced" | "small")}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="high">High</option>
|
||||
<option value="balanced">Balanced</option>
|
||||
<option value="small">Small</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === "pdf" && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="preset-page" className="text-xs text-muted-foreground">
|
||||
Page size
|
||||
</label>
|
||||
<select
|
||||
id="preset-page"
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(e.target.value as "A4" | "Letter" | "A3" | "A5")}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="A4">A4</option>
|
||||
<option value="Letter">Letter</option>
|
||||
<option value="A3">A3</option>
|
||||
<option value="A5">A5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="preset-orient" className="text-xs text-muted-foreground">
|
||||
Orientation
|
||||
</label>
|
||||
<select
|
||||
id="preset-orient"
|
||||
value={orientation}
|
||||
onChange={(e) => setOrientation(e.target.value as "portrait" | "landscape")}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="portrait">Portrait</option>
|
||||
<option value="landscape">Landscape</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings.convert.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="preset-submit"
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{files.length > 1
|
||||
? format(t.toolSettings.convert.submitBatch, { count: files.length })
|
||||
: t.toolSettings.convert.submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="preset-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,14 @@ export function CropCanvas({
|
||||
crop={crop}
|
||||
onChange={(_pixelCrop, percentCrop) => onCropChange(percentCrop)}
|
||||
aspect={aspect}
|
||||
className="max-h-full"
|
||||
// ReactCrop's own CSS forces `.ReactCrop__child-wrapper>img` to
|
||||
// max-height:inherit, which overrides the img's own max-h utility
|
||||
// (higher specificity). So the viewport cap has to live on the
|
||||
// ReactCrop element itself; the child-wrapper and img then inherit
|
||||
// it. Without this a tall portrait renders at full natural height
|
||||
// and overflows the viewport. max-h-full doesn't help because the
|
||||
// ancestor chain isn't reliably height-bounded.
|
||||
className="max-h-[calc(100dvh-12rem)]"
|
||||
ruleOfThirds={showGrid}
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -86,7 +86,7 @@ export interface RemoveBgControlsProps {
|
||||
onChange: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgControlsProps) {
|
||||
export function RemoveBgControls({ settings, onChange }: RemoveBgControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [subject, setSubject] = useState<SubjectType>("people");
|
||||
const [quality, setQuality] = useState<Quality>("balanced");
|
||||
@@ -116,6 +116,39 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
||||
// Expandable sections
|
||||
const [effectsOpen, setEffectsOpen] = useState(false);
|
||||
|
||||
// Seed local state from preloaded settings exactly once. Pipeline steps (e.g.
|
||||
// a template) mount this control with non-default settings; without seeding,
|
||||
// the emit effect below would overwrite them with the hardcoded defaults.
|
||||
// Mirrors the initializedRef pattern in resize-settings / convert-settings.
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!settings || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (settings.backgroundType != null) setBgType(settings.backgroundType as BackgroundType);
|
||||
if (settings.backgroundColor != null) setBgColor(String(settings.backgroundColor));
|
||||
if (settings.gradientColor1 != null) setGradColor1(String(settings.gradientColor1));
|
||||
if (settings.gradientColor2 != null) setGradColor2(String(settings.gradientColor2));
|
||||
if (settings.gradientAngle != null) setGradAngle(Number(settings.gradientAngle));
|
||||
if (settings.blurEnabled != null) setBlurEnabled(Boolean(settings.blurEnabled));
|
||||
if (settings.blurIntensity != null) setBlurIntensity(Number(settings.blurIntensity));
|
||||
if (settings.shadowEnabled != null) setShadowEnabled(Boolean(settings.shadowEnabled));
|
||||
if (settings.shadowOpacity != null) setShadowOpacity(Number(settings.shadowOpacity));
|
||||
if (settings.edgeRefine != null) setEdgeRefine(Number(settings.edgeRefine));
|
||||
if (settings.decontaminate != null) setDecontaminate(Boolean(settings.decontaminate));
|
||||
if (settings.outputFormat != null)
|
||||
setOutputFormat(settings.outputFormat as "png" | "webp" | "avif");
|
||||
// Reveal the effects section when any seeded effect is active so the
|
||||
// preloaded values are immediately visible (and adjustable).
|
||||
if (
|
||||
settings.blurEnabled ||
|
||||
settings.shadowEnabled ||
|
||||
settings.edgeRefine ||
|
||||
settings.decontaminate
|
||||
) {
|
||||
setEffectsOpen(true);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Filter quality options based on subject (Ultra only for People)
|
||||
const qualityOptions = ALL_QUALITY_OPTIONS.filter(
|
||||
(opt) => !opt.peopleOnly || subject === "people",
|
||||
@@ -409,6 +442,8 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
||||
<button
|
||||
key={fmt}
|
||||
type="button"
|
||||
data-testid={`remove-background-format-${fmt}`}
|
||||
aria-pressed={outputFormat === fmt}
|
||||
onClick={() => setOutputFormat(fmt)}
|
||||
className={`py-2 px-2 rounded-lg border text-xs font-medium uppercase transition-colors ${
|
||||
outputFormat === fmt
|
||||
@@ -520,6 +555,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
data-testid="remove-background-edge-refine"
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
@@ -534,6 +570,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="remove-background-decontaminate"
|
||||
checked={decontaminate}
|
||||
onChange={(e) => setDecontaminate(e.target.checked)}
|
||||
className="rounded border-border accent-primary"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FileImage, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { SearchBar } from "@/components/common/search-bar";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useFuseSearch } from "@/hooks/use-fuse-search";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { ICON_MAP } from "@/lib/icon-map";
|
||||
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
|
||||
@@ -37,19 +38,17 @@ export function ToolPalette({ onAddStep, className }: ToolPaletteProps) {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const availableTools = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
const preFiltered = useMemo(() => {
|
||||
return TOOLS.filter((t) => {
|
||||
if (EXCLUDED_TOOLS.has(t.id)) return false;
|
||||
if (disabledTools.includes(t.id)) return false;
|
||||
if (t.experimental && !experimentalEnabled) return false;
|
||||
if (pipelineToolIds && !pipelineToolIds.includes(t.id)) return false;
|
||||
if (q && !t.name.toLowerCase().includes(q) && !t.description.toLowerCase().includes(q)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [disabledTools, experimentalEnabled, pipelineToolIds, search]);
|
||||
}, [disabledTools, experimentalEnabled, pipelineToolIds]);
|
||||
|
||||
const availableTools = useFuseSearch(preFiltered, search);
|
||||
|
||||
const groupedTools = useMemo(() => {
|
||||
const groups: Record<string, typeof availableTools> = {};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Tool } from "@snapotter/shared";
|
||||
import { normalizeSearchQuery } from "@snapotter/shared";
|
||||
import type { IFuseOptions } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
import { useMemo } from "react";
|
||||
@@ -6,6 +7,7 @@ import { useMemo } from "react";
|
||||
const FUSE_OPTIONS: IFuseOptions<Tool> = {
|
||||
keys: [
|
||||
{ name: "name", weight: 0.35 },
|
||||
{ name: "keywords", weight: 0.3 },
|
||||
{ name: "description", weight: 0.25 },
|
||||
{ name: "modality", weight: 0.15 },
|
||||
{ name: "id", weight: 0.15 },
|
||||
@@ -19,12 +21,16 @@ const FUSE_OPTIONS: IFuseOptions<Tool> = {
|
||||
/**
|
||||
* Wraps a tool list with Fuse.js fuzzy search.
|
||||
* Returns all tools when query is empty; ranked fuzzy results otherwise.
|
||||
* Queries are normalized (synonyms, compact forms like "jpg2png", filler
|
||||
* stripping) before searching, falling back to the raw query when
|
||||
* normalization yields an empty string.
|
||||
*/
|
||||
export function useFuseSearch(tools: Tool[], query: string): Tool[] {
|
||||
const fuse = useMemo(() => new Fuse(tools, FUSE_OPTIONS), [tools]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!query) return tools;
|
||||
return fuse.search(query).map((r) => r.item);
|
||||
const normalized = normalizeSearchQuery(query);
|
||||
return fuse.search(normalized || query).map((r) => r.item);
|
||||
}, [fuse, query, tools]);
|
||||
}
|
||||
|
||||
@@ -4,15 +4,35 @@ type PostHogInstance = import("posthog-js").PostHog;
|
||||
|
||||
let posthog: PostHogInstance | null = null;
|
||||
let initialized = false;
|
||||
let enabled = false; // live runtime flag; gates track() and ErrorBoundary capture
|
||||
|
||||
const FILE_EXT_PATTERN =
|
||||
/\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi;
|
||||
const FILE_EXT_TEST =
|
||||
/\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/i;
|
||||
const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai|Users|home)\//g;
|
||||
function basename(p: string): string {
|
||||
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
function scrubString(str: string): string {
|
||||
return str.replace(FILE_EXT_PATTERN, ".[REDACTED]").replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
// Only these keys may leave the browser per event, and only as primitives.
|
||||
const ALLOWED: Record<string, ReadonlySet<string>> = {
|
||||
tool_opened: new Set(["tool_id", "category", "modality"]),
|
||||
file_added: new Set(["tool_id", "count", "file_count"]),
|
||||
tool_started: new Set(["tool_id", "is_batch", "file_count"]),
|
||||
tool_client_error: new Set(["error_name"]),
|
||||
result_downloaded: new Set(["tool_id"]),
|
||||
result_saved: new Set(["tool_id"]),
|
||||
search: new Set(["results_count", "clicked_tool_id"]),
|
||||
ai_bundle_prompted: new Set(["bundle_id"]),
|
||||
batch_processed: new Set(["tool_id", "file_count", "status"]),
|
||||
};
|
||||
|
||||
function sanitize(event: string, properties?: Record<string, unknown>): Record<string, unknown> {
|
||||
const allow = ALLOWED[event];
|
||||
if (!allow || !properties) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(properties)) {
|
||||
const t = typeof v;
|
||||
if (allow.has(k) && (t === "string" || t === "number" || t === "boolean")) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
@@ -28,18 +48,17 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
disable_session_recording: true,
|
||||
ip: false,
|
||||
persistence: "localStorage",
|
||||
person_profiles: "always",
|
||||
person_profiles: "identified_only",
|
||||
}) ?? null;
|
||||
initialized = true;
|
||||
enabled = true;
|
||||
} catch (err) {
|
||||
console.warn("[analytics] PostHog init failed:", err);
|
||||
}
|
||||
|
||||
if (posthog) {
|
||||
posthog.register({
|
||||
instance_id: config.instanceId,
|
||||
app_version: (await import("@snapotter/shared")).APP_VERSION,
|
||||
});
|
||||
// app_version only; no instance_id, so plain events stay person-less.
|
||||
posthog.register({ app_version: (await import("@snapotter/shared")).APP_VERSION });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -53,32 +72,30 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
sendDefaultPii: false,
|
||||
integrations: [Sentry.browserTracingIntegration()],
|
||||
beforeSend(event) {
|
||||
if (event.user) {
|
||||
delete event.user.email;
|
||||
delete event.user.username;
|
||||
}
|
||||
if (!enabled) return null;
|
||||
event.message = undefined;
|
||||
event.logentry = undefined;
|
||||
event.request = undefined;
|
||||
event.extra = undefined;
|
||||
event.contexts = undefined;
|
||||
event.breadcrumbs = undefined;
|
||||
event.user = undefined;
|
||||
if (event.exception?.values) {
|
||||
for (const ex of event.exception.values) {
|
||||
if (ex.value) ex.value = scrubString(ex.value);
|
||||
ex.value = ex.type;
|
||||
if (ex.stacktrace?.frames) {
|
||||
for (const frame of ex.stacktrace.frames) {
|
||||
if (frame.filename) frame.filename = scrubString(frame.filename);
|
||||
if (frame.abs_path) frame.abs_path = scrubString(frame.abs_path);
|
||||
if (frame.filename) frame.filename = basename(frame.filename);
|
||||
frame.abs_path = undefined;
|
||||
frame.vars = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
},
|
||||
beforeBreadcrumb(breadcrumb) {
|
||||
if (breadcrumb.category === "ui.click") return null;
|
||||
if (breadcrumb.category === "fetch" && breadcrumb.data?.url) {
|
||||
if (FILE_EXT_TEST.test(breadcrumb.data.url as string)) return null;
|
||||
}
|
||||
if (breadcrumb.message) {
|
||||
breadcrumb.message = scrubString(breadcrumb.message);
|
||||
}
|
||||
return breadcrumb;
|
||||
beforeBreadcrumb() {
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -88,19 +105,38 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
}
|
||||
|
||||
export function track(event: string, properties?: Record<string, unknown>): void {
|
||||
if (!posthog) return;
|
||||
if (!enabled || !posthog) return;
|
||||
try {
|
||||
posthog.capture(event, properties);
|
||||
posthog.capture(event, sanitize(event, properties));
|
||||
} catch {
|
||||
// never throw
|
||||
}
|
||||
}
|
||||
|
||||
export function getDistinctId(): string | null {
|
||||
if (!posthog) return null;
|
||||
if (!enabled || !posthog) return null;
|
||||
try {
|
||||
return posthog.get_distinct_id();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAnalyticsActive(): boolean {
|
||||
return enabled && !!posthog;
|
||||
}
|
||||
|
||||
/** Hard runtime opt-out: stop PostHog and Sentry in this tab without a reload. */
|
||||
export function optOut(): void {
|
||||
enabled = false;
|
||||
try {
|
||||
posthog?.opt_out_capturing();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
void import("@sentry/react")
|
||||
.then((Sentry) => {
|
||||
Sentry.getClient()?.close();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { TranslationKeys } from "@snapotter/shared";
|
||||
|
||||
export function getTemplateName(t: TranslationKeys, id: string, fallback: string): string {
|
||||
const entry = (t.pipelineTemplates as Record<string, { name?: string }>)[id];
|
||||
return entry?.name ?? fallback;
|
||||
}
|
||||
|
||||
export function getTemplateDescription(t: TranslationKeys, id: string, fallback: string): string {
|
||||
const entry = (t.pipelineTemplates as Record<string, { description?: string }>)[id];
|
||||
return entry?.description ?? fallback;
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
* truth for display modes; tool-registry.tsx merges it into registry entries.
|
||||
*/
|
||||
|
||||
import { BASE_CONFIG, CONVERSION_PRESETS } from "@snapotter/shared";
|
||||
|
||||
export type DisplayMode =
|
||||
| "side-by-side"
|
||||
| "before-after"
|
||||
@@ -205,6 +207,20 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
"extract-zip": "no-comparison",
|
||||
};
|
||||
|
||||
// Each conversion preset mirrors the display mode of its base tool (e.g.
|
||||
// jpg-to-png follows "convert"). Generated here so the 83 presets never drift
|
||||
// from the static map above. Every displayBase is one of the keys defined
|
||||
// statically, so the lookup always resolves.
|
||||
for (const preset of CONVERSION_PRESETS) {
|
||||
const baseMode = TOOL_DISPLAY_MODES[BASE_CONFIG[preset.base].displayBase];
|
||||
// The pdf-to-image base renders "custom-results" with a dedicated page-picker
|
||||
// UI backed by usePdfToImageStore. Presets use the shared
|
||||
// ConversionPresetSettings + generic download flow instead, which has no such
|
||||
// ResultsPanel, so they render as a plain converter (no-comparison) like the
|
||||
// sibling image-to-pdf / convert-spreadsheet presets.
|
||||
TOOL_DISPLAY_MODES[preset.id] = baseMode === "custom-results" ? "no-comparison" : baseMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools whose selected files all post in ONE request as repeated "file" parts.
|
||||
* Consumed by use-tool-processor; backend routes declare maxInputs.
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
* Adding a new tool means adding one entry here instead of editing a 750-line file.
|
||||
*/
|
||||
|
||||
import { AUDIO_INPUTS, IMAGE_INPUTS, SUBTITLE_INPUTS, VIDEO_INPUTS } from "@snapotter/shared";
|
||||
import {
|
||||
AUDIO_INPUTS,
|
||||
CONVERSION_PRESETS,
|
||||
IMAGE_INPUTS,
|
||||
SUBTITLE_INPUTS,
|
||||
VIDEO_INPUTS,
|
||||
} from "@snapotter/shared";
|
||||
import type React from "react";
|
||||
import { lazy } from "react";
|
||||
import type { Crop } from "react-image-crop";
|
||||
@@ -868,6 +874,13 @@ const VignetteSettings = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
// One settings component shared by every conversion preset (jpg-to-png, etc.).
|
||||
const ConversionPresetSettings = lazy(() =>
|
||||
import("@/components/tools/conversion-preset-settings").then((m) => ({
|
||||
default: m.ConversionPresetSettings,
|
||||
})),
|
||||
);
|
||||
|
||||
// ── Color tool wrapper ─────────────────────────────────────────────
|
||||
// Color tools share a single component but differ by toolId.
|
||||
|
||||
@@ -1119,8 +1132,20 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
|
||||
["extract-zip", { accept: ".zip", Settings: ExtractZipSettings }],
|
||||
];
|
||||
|
||||
// Conversion presets all share ConversionPresetSettings; each narrows the
|
||||
// file picker to its own source inputs. Generated from shared metadata so the
|
||||
// list stays in lockstep with the catalog.
|
||||
const PRESET_ENTRIES: ReadonlyArray<[string, RegistryEntryConfig]> = CONVERSION_PRESETS.map(
|
||||
(p) => [p.id, { accept: p.sourceInputs.join(","), Settings: ConversionPresetSettings }] as const,
|
||||
);
|
||||
|
||||
const ALL_ENTRIES: ReadonlyArray<[string, RegistryEntryConfig]> = [
|
||||
...ENTRY_CONFIG,
|
||||
...PRESET_ENTRIES,
|
||||
];
|
||||
|
||||
export const toolRegistry = new Map<string, ToolRegistryEntry>(
|
||||
ENTRY_CONFIG.map(([toolId, entry]) => {
|
||||
ALL_ENTRIES.map(([toolId, entry]) => {
|
||||
const displayMode = TOOL_DISPLAY_MODES[toolId];
|
||||
if (!displayMode) {
|
||||
throw new Error(`Tool "${toolId}" has no display mode in tool-display-modes.ts`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { modalityForExtension } from "@snapotter/shared";
|
||||
import { modalityForExtension, type PipelineTemplate } from "@snapotter/shared";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { TemplatesSection } from "@/components/automate/templates-section";
|
||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { Dropzone } from "@/components/common/dropzone";
|
||||
import { FileLibraryModal } from "@/components/common/file-library-modal";
|
||||
@@ -277,6 +278,13 @@ export function AutomatePage() {
|
||||
[loadSteps],
|
||||
);
|
||||
|
||||
const handleUseTemplate = useCallback(
|
||||
(template: PipelineTemplate) => {
|
||||
loadSteps(template.steps);
|
||||
},
|
||||
[loadSteps],
|
||||
);
|
||||
|
||||
const handleExportPipeline = useCallback((pipeline: SavedPipeline) => {
|
||||
const exportData = {
|
||||
format: "snapotter-pipeline" as const,
|
||||
@@ -739,6 +747,9 @@ export function AutomatePage() {
|
||||
{/* Tool catalog */}
|
||||
<ToolPalette onAddStep={handleAddStep} className="flex-1 min-h-0" />
|
||||
|
||||
{/* Pipeline templates */}
|
||||
<TemplatesSection onUse={handleUseTemplate} emphasized={steps.length === 0} />
|
||||
|
||||
{/* Saved pipelines */}
|
||||
<div className="px-3 py-2 border-t border-border shrink-0">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
|
||||
@@ -89,7 +89,7 @@ export function HomePage() {
|
||||
if (!search || search.length < 2) return;
|
||||
const timer = setTimeout(() => {
|
||||
import("@/lib/analytics").then(({ track }) => {
|
||||
track(ANALYTICS_EVENTS.SEARCH, { query: search, results_count: searchResults.length });
|
||||
track(ANALYTICS_EVENTS.SEARCH, { results_count: searchResults.length });
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
@@ -195,6 +195,14 @@ function HomeSearchBar({
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Escape steps out of the search field: clear a query if present,
|
||||
// otherwise drop focus back to the page.
|
||||
if (e.key === "Escape") {
|
||||
if (value) onChange("");
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
className="w-full ps-11 pe-20 py-2.5 rounded-lg border border-border bg-card text-sm text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/40 transition-shadow"
|
||||
@@ -244,7 +252,10 @@ function ModalityTabs({
|
||||
className={cn(
|
||||
"px-3.5 py-1.5 rounded-full text-sm font-medium transition-colors whitespace-nowrap",
|
||||
activeTab === tab.key
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
? // Fixed dark text on the orange pill (the orange is the same
|
||||
// in both themes). White on Otter Orange is only ~3:1, below
|
||||
// WCAG AA for this 14px label; near-black reaches ~5.8:1.
|
||||
"bg-primary text-[#1a1814] shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -43,9 +43,11 @@ export function PrivacyPolicyPage() {
|
||||
<p>
|
||||
SnapOtter includes basic analytics (tool usage, error reports) to help improve the
|
||||
software. Your files, file names, and personal data are never part of this. Analytics
|
||||
can be disabled by rebuilding with{" "}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">SNAPOTTER_ANALYTICS=off</code>{" "}
|
||||
-- everything works normally without it.
|
||||
is on by default and can be disabled at runtime. An administrator can turn anonymous
|
||||
product analytics off under Settings {">"} System {">"} Privacy, with no restart or
|
||||
rebuild needed. For a compile-time hard-off, build with{" "}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">SNAPOTTER_ANALYTICS=off</code>,
|
||||
which strips it from the bundle entirely. Everything works normally without it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ interface AnalyticsState {
|
||||
fetchConfig: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAnalyticsStore = create<AnalyticsState>((set, get) => ({
|
||||
export const useAnalyticsStore = create<AnalyticsState>((set) => ({
|
||||
config: null,
|
||||
configLoaded: false,
|
||||
// No one-shot guard: callers may refetch (e.g. on tab focus) so an
|
||||
// instance-wide opt-out converges in already-open tabs.
|
||||
fetchConfig: async () => {
|
||||
if (get().configLoaded) return;
|
||||
try {
|
||||
const res = await fetch("/api/v1/config/analytics");
|
||||
const config: AnalyticsConfig = await res.json();
|
||||
|
||||
@@ -16,6 +16,12 @@ interface SettingsState {
|
||||
|
||||
const VALID_THEMES = new Set(["light", "dark", "system"]);
|
||||
|
||||
// De-duplicate concurrent fetches. Several components (AppLayout, the home grid,
|
||||
// the connection monitor) call fetch() on mount; without this guard they would
|
||||
// each fire a /v1/settings request before `loaded` flips, inflating the initial
|
||||
// network load.
|
||||
let inFlight: Promise<void> | null = null;
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
disabledTools: [],
|
||||
experimentalEnabled: false,
|
||||
@@ -26,27 +32,38 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
|
||||
fetch: async () => {
|
||||
if (get().loaded && !get().loadError) return;
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const data = await apiGet<{
|
||||
settings: Record<string, string>;
|
||||
}>("/v1/settings");
|
||||
|
||||
const defaultTheme = VALID_THEMES.has(data.settings.defaultTheme)
|
||||
? (data.settings.defaultTheme as Theme)
|
||||
: "light";
|
||||
|
||||
set({
|
||||
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
||||
experimentalEnabled: data.settings.enableExperimentalTools === "true",
|
||||
defaultToolView:
|
||||
data.settings.defaultToolView === "fullscreen" ? "fullscreen" : "sidebar",
|
||||
defaultTheme,
|
||||
loaded: true,
|
||||
loadError: false,
|
||||
});
|
||||
|
||||
useThemeStore.getState().applyServerDefault(defaultTheme);
|
||||
} catch {
|
||||
set({ loaded: true, loadError: true });
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
const data = await apiGet<{
|
||||
settings: Record<string, string>;
|
||||
}>("/v1/settings");
|
||||
|
||||
const defaultTheme = VALID_THEMES.has(data.settings.defaultTheme)
|
||||
? (data.settings.defaultTheme as Theme)
|
||||
: "light";
|
||||
|
||||
set({
|
||||
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
||||
experimentalEnabled: data.settings.enableExperimentalTools === "true",
|
||||
defaultToolView: data.settings.defaultToolView === "fullscreen" ? "fullscreen" : "sidebar",
|
||||
defaultTheme,
|
||||
loaded: true,
|
||||
loadError: false,
|
||||
});
|
||||
|
||||
useThemeStore.getState().applyServerDefault(defaultTheme);
|
||||
} catch {
|
||||
set({ loaded: true, loadError: true });
|
||||
await inFlight;
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Press kit for use in blog posts, articles, reviews, and other publications about SnapOtter. Free to use when referencing SnapOtter.
|
||||
|
||||
SnapOtter is an open-source, self-hosted file processing suite with 157 tools across image, video, audio, PDF, and file workflows.
|
||||
SnapOtter is an open-source, self-hosted file processing suite with 240 tools across image, video, audio, PDF, and file workflows.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -20,7 +20,7 @@ SnapOtter is an open-source, self-hosted file processing suite with 157 tools ac
|
||||
| `wordmark.svg` | Scalable | "SnapOtter" text mark (dark text) |
|
||||
| `wordmark-white.svg` | Scalable | "SnapOtter" text mark (white text, for dark backgrounds) |
|
||||
| `banner.svg` | Scalable | Wordmark + tagline banner on dark background |
|
||||
| `dashboard.gif` | Animated | Scrolling tour of all 157 tools across the five modalities (README hero) |
|
||||
| `dashboard.gif` | Animated | Scrolling tour of all 240 tools across the five modalities (README hero) |
|
||||
|
||||
## Brand Colors
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 319 KiB |
@@ -2,7 +2,7 @@
|
||||
"name": "snapotter",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted file processing suite with 157 tools across image, video, audio, document, and data, plus local AI. Runs as a Docker Compose stack with Postgres and Redis.",
|
||||
"description": "Self-hosted file processing suite with 240 tools across image, video, audio, document, and data, plus local AI. Runs as a Docker Compose stack with Postgres and Redis.",
|
||||
"homepage": "https://snapotter.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./i18n/*": "./src/i18n/*"
|
||||
"./i18n/*": "./src/i18n/*",
|
||||
"./search/*": "./src/search/*"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check src/",
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface ToolUsedProperties {
|
||||
}
|
||||
|
||||
export interface SearchProperties {
|
||||
query: string;
|
||||
results_count: number;
|
||||
clicked_tool_id?: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expandConversionPresets } from "./conversion-presets.js";
|
||||
import { AUDIO_INPUTS, IMAGE_INPUTS, SUBTITLE_INPUTS, VIDEO_INPUTS } from "./modality.js";
|
||||
import { toolSection } from "./section.js";
|
||||
import type { CategoryInfo, SocialMediaPreset, Tool } from "./types.js";
|
||||
@@ -34,7 +35,7 @@ export const CATEGORIES: CategoryInfo[] = [
|
||||
{ id: "archives", name: "Archives", icon: "FolderArchive", color: "#6366F1" },
|
||||
];
|
||||
|
||||
export const TOOLS: Tool[] = [
|
||||
const BASE_TOOLS: Tool[] = [
|
||||
// Essentials
|
||||
{
|
||||
id: "resize",
|
||||
@@ -78,6 +79,14 @@ export const TOOLS: Tool[] = [
|
||||
route: "/convert",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
keywords: [
|
||||
"image converter",
|
||||
"jpg to png",
|
||||
"png to jpg",
|
||||
"heic to jpg",
|
||||
"webp to png",
|
||||
"convert image format",
|
||||
],
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
@@ -241,7 +250,7 @@ export const TOOLS: Tool[] = [
|
||||
},
|
||||
{
|
||||
id: "ocr",
|
||||
name: "OCR / Text Extraction",
|
||||
name: "Extract Text from Image (OCR)",
|
||||
description: "Extract text from images",
|
||||
category: "doc-convert",
|
||||
icon: "ScanText",
|
||||
@@ -809,6 +818,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/convert-video",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
keywords: ["video converter", "mov to mp4", "mkv to mp4", "avi to mp4", "webm to mp4"],
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
@@ -1111,6 +1121,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/convert-audio",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
keywords: ["audio converter", "m4a to mp3", "aac to mp3", "wav to mp3", "ogg to mp3"],
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
@@ -1368,6 +1379,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/convert-spreadsheet",
|
||||
modality: "document",
|
||||
acceptedInputs: [".xlsx", ".xls", ".ods", ".csv"],
|
||||
keywords: ["excel to csv", "xlsx to csv", "spreadsheet converter"],
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
@@ -1798,6 +1810,8 @@ export const TOOLS: Tool[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const TOOLS: Tool[] = [...BASE_TOOLS, ...expandConversionPresets(BASE_TOOLS)];
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
const slug = `/${toolSection(tool)}`;
|
||||
if (!tool.route.startsWith(`${slug}/`)) {
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
import type { Modality } from "./modality.js";
|
||||
import { generateConversionKeywords } from "./search/format-aliases.js";
|
||||
import type { Tool, ToolCategory } from "./types.js";
|
||||
|
||||
/** Which factory path a preset uses on the API side. */
|
||||
export type PresetGroup = "registry" | "image-to-pdf" | "pdf-to-image" | "svg-to-raster";
|
||||
|
||||
/** Settings UI family for the shared web settings component. */
|
||||
export type PresetSettingsKind = "none" | "quality" | "pdf" | "video";
|
||||
|
||||
export interface ConversionPreset {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
base: string;
|
||||
/** Settings forced into the base process call (e.g. { format: "png" }). */
|
||||
locked: Record<string, unknown>;
|
||||
/** Input extensions this preset accepts (narrows the base). */
|
||||
sourceInputs: string[];
|
||||
}
|
||||
|
||||
interface BaseConfig {
|
||||
group: PresetGroup;
|
||||
modality: Modality;
|
||||
outputModality?: Modality;
|
||||
category: ToolCategory;
|
||||
settingsKind: PresetSettingsKind;
|
||||
/** The base tool id whose displayMode the web side mirrors. */
|
||||
displayBase: string;
|
||||
}
|
||||
|
||||
/** Per-base routing/config. category may override the base tool's own category for grouping. */
|
||||
export const BASE_CONFIG: Record<string, BaseConfig> = {
|
||||
convert: {
|
||||
group: "registry",
|
||||
modality: "image",
|
||||
category: "format",
|
||||
settingsKind: "quality",
|
||||
displayBase: "convert",
|
||||
},
|
||||
vectorize: {
|
||||
group: "registry",
|
||||
modality: "image",
|
||||
category: "format",
|
||||
settingsKind: "none",
|
||||
displayBase: "vectorize",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
group: "svg-to-raster",
|
||||
modality: "image",
|
||||
category: "format",
|
||||
settingsKind: "quality",
|
||||
displayBase: "svg-to-raster",
|
||||
},
|
||||
"image-to-pdf": {
|
||||
group: "image-to-pdf",
|
||||
modality: "image",
|
||||
outputModality: "document",
|
||||
category: "format",
|
||||
settingsKind: "pdf",
|
||||
displayBase: "image-to-pdf",
|
||||
},
|
||||
"pdf-to-image": {
|
||||
group: "pdf-to-image",
|
||||
modality: "document",
|
||||
outputModality: "image",
|
||||
category: "pdf-organize",
|
||||
settingsKind: "none",
|
||||
displayBase: "pdf-to-image",
|
||||
},
|
||||
"convert-video": {
|
||||
group: "registry",
|
||||
modality: "video",
|
||||
category: "video-convert",
|
||||
settingsKind: "video",
|
||||
displayBase: "convert-video",
|
||||
},
|
||||
"video-to-gif": {
|
||||
group: "registry",
|
||||
modality: "video",
|
||||
outputModality: "image",
|
||||
category: "video-convert",
|
||||
settingsKind: "none",
|
||||
displayBase: "video-to-gif",
|
||||
},
|
||||
"gif-to-video": {
|
||||
group: "registry",
|
||||
modality: "video",
|
||||
category: "video-convert",
|
||||
settingsKind: "none",
|
||||
displayBase: "convert-video",
|
||||
},
|
||||
"extract-audio": {
|
||||
group: "registry",
|
||||
modality: "video",
|
||||
outputModality: "audio",
|
||||
category: "video-convert",
|
||||
settingsKind: "none",
|
||||
displayBase: "convert-video",
|
||||
},
|
||||
"convert-audio": {
|
||||
group: "registry",
|
||||
modality: "audio",
|
||||
category: "audio-convert",
|
||||
settingsKind: "none",
|
||||
displayBase: "convert-audio",
|
||||
},
|
||||
"convert-spreadsheet": {
|
||||
group: "registry",
|
||||
modality: "document",
|
||||
category: "doc-convert",
|
||||
settingsKind: "none",
|
||||
displayBase: "convert-spreadsheet",
|
||||
},
|
||||
};
|
||||
|
||||
const JPG = [".jpg", ".jpeg"];
|
||||
const PNG = [".png"];
|
||||
const WEBP = [".webp"];
|
||||
const GIF = [".gif"];
|
||||
const TIFF = [".tiff", ".tif"];
|
||||
const HEIC = [".heic", ".heif"];
|
||||
const PSD = [".psd"];
|
||||
const EPS = [".eps"];
|
||||
const SVG = [".svg", ".svgz"];
|
||||
const PDF = [".pdf"];
|
||||
|
||||
/** image raster pairs on `convert` */
|
||||
function img(
|
||||
id: string,
|
||||
from: string,
|
||||
to: string,
|
||||
format: string,
|
||||
sourceInputs: string[],
|
||||
): ConversionPreset {
|
||||
return { id, from, to, base: "convert", locked: { format }, sourceInputs };
|
||||
}
|
||||
|
||||
export const CONVERSION_PRESETS: ConversionPreset[] = [
|
||||
// Image raster (base: convert)
|
||||
img("jpg-to-png", "JPG", "PNG", "png", JPG),
|
||||
img("png-to-jpg", "PNG", "JPG", "jpg", PNG),
|
||||
img("jpg-to-webp", "JPG", "WebP", "webp", JPG),
|
||||
img("png-to-webp", "PNG", "WebP", "webp", PNG),
|
||||
img("webp-to-jpg", "WebP", "JPG", "jpg", WEBP),
|
||||
img("webp-to-png", "WebP", "PNG", "png", WEBP),
|
||||
img("jpg-to-avif", "JPG", "AVIF", "avif", JPG),
|
||||
img("png-to-avif", "PNG", "AVIF", "avif", PNG),
|
||||
img("webp-to-avif", "WebP", "AVIF", "avif", WEBP),
|
||||
img("heic-to-jpg", "HEIC", "JPG", "jpg", HEIC),
|
||||
img("heic-to-png", "HEIC", "PNG", "png", HEIC),
|
||||
img("heic-to-avif", "HEIC", "AVIF", "avif", HEIC),
|
||||
img("jpg-to-gif", "JPG", "GIF", "gif", JPG),
|
||||
img("png-to-gif", "PNG", "GIF", "gif", PNG),
|
||||
img("gif-to-jpg", "GIF", "JPG", "jpg", GIF),
|
||||
img("gif-to-png", "GIF", "PNG", "png", GIF),
|
||||
img("webp-to-gif", "WebP", "GIF", "gif", WEBP),
|
||||
img("jpg-to-tiff", "JPG", "TIFF", "tiff", JPG),
|
||||
img("png-to-tiff", "PNG", "TIFF", "tiff", PNG),
|
||||
img("tiff-to-jpg", "TIFF", "JPG", "jpg", TIFF),
|
||||
img("tiff-to-png", "TIFF", "PNG", "png", TIFF),
|
||||
img("psd-to-jpg", "PSD", "JPG", "jpg", PSD),
|
||||
img("psd-to-png", "PSD", "PNG", "png", PSD),
|
||||
img("png-to-eps", "PNG", "EPS", "eps", PNG),
|
||||
img("jpg-to-eps", "JPG", "EPS", "eps", JPG),
|
||||
img("eps-to-png", "EPS", "PNG", "png", EPS),
|
||||
img("eps-to-jpg", "EPS", "JPG", "jpg", EPS),
|
||||
|
||||
// Image vector
|
||||
{ id: "png-to-svg", from: "PNG", to: "SVG", base: "vectorize", locked: {}, sourceInputs: PNG },
|
||||
{ id: "jpg-to-svg", from: "JPG", to: "SVG", base: "vectorize", locked: {}, sourceInputs: JPG },
|
||||
{ id: "tiff-to-svg", from: "TIFF", to: "SVG", base: "vectorize", locked: {}, sourceInputs: TIFF },
|
||||
{ id: "psd-to-svg", from: "PSD", to: "SVG", base: "vectorize", locked: {}, sourceInputs: PSD },
|
||||
{ id: "eps-to-svg", from: "EPS", to: "SVG", base: "vectorize", locked: {}, sourceInputs: EPS },
|
||||
{
|
||||
id: "svg-to-png",
|
||||
from: "SVG",
|
||||
to: "PNG",
|
||||
base: "svg-to-raster",
|
||||
locked: { outputFormat: "png" },
|
||||
sourceInputs: SVG,
|
||||
},
|
||||
{
|
||||
id: "svg-to-jpg",
|
||||
from: "SVG",
|
||||
to: "JPG",
|
||||
base: "svg-to-raster",
|
||||
locked: { outputFormat: "jpg" },
|
||||
sourceInputs: SVG,
|
||||
},
|
||||
|
||||
// Image -> PDF (base: image-to-pdf)
|
||||
{ id: "jpg-to-pdf", from: "JPG", to: "PDF", base: "image-to-pdf", locked: {}, sourceInputs: JPG },
|
||||
{ id: "png-to-pdf", from: "PNG", to: "PDF", base: "image-to-pdf", locked: {}, sourceInputs: PNG },
|
||||
{
|
||||
id: "heic-to-pdf",
|
||||
from: "HEIC",
|
||||
to: "PDF",
|
||||
base: "image-to-pdf",
|
||||
locked: {},
|
||||
sourceInputs: HEIC,
|
||||
},
|
||||
{
|
||||
id: "tiff-to-pdf",
|
||||
from: "TIFF",
|
||||
to: "PDF",
|
||||
base: "image-to-pdf",
|
||||
locked: {},
|
||||
sourceInputs: TIFF,
|
||||
},
|
||||
{
|
||||
id: "webp-to-pdf",
|
||||
from: "WebP",
|
||||
to: "PDF",
|
||||
base: "image-to-pdf",
|
||||
locked: {},
|
||||
sourceInputs: WEBP,
|
||||
},
|
||||
{ id: "gif-to-pdf", from: "GIF", to: "PDF", base: "image-to-pdf", locked: {}, sourceInputs: GIF },
|
||||
{ id: "eps-to-pdf", from: "EPS", to: "PDF", base: "image-to-pdf", locked: {}, sourceInputs: EPS },
|
||||
|
||||
// PDF -> image (base: pdf-to-image)
|
||||
{
|
||||
id: "pdf-to-jpg",
|
||||
from: "PDF",
|
||||
to: "JPG",
|
||||
base: "pdf-to-image",
|
||||
locked: { format: "jpg" },
|
||||
sourceInputs: PDF,
|
||||
},
|
||||
{
|
||||
id: "pdf-to-png",
|
||||
from: "PDF",
|
||||
to: "PNG",
|
||||
base: "pdf-to-image",
|
||||
locked: { format: "png" },
|
||||
sourceInputs: PDF,
|
||||
},
|
||||
{
|
||||
id: "pdf-to-tiff",
|
||||
from: "PDF",
|
||||
to: "TIFF",
|
||||
base: "pdf-to-image",
|
||||
locked: { format: "tiff" },
|
||||
sourceInputs: PDF,
|
||||
},
|
||||
|
||||
// Video container (base: convert-video)
|
||||
{
|
||||
id: "mov-to-mp4",
|
||||
from: "MOV",
|
||||
to: "MP4",
|
||||
base: "convert-video",
|
||||
locked: { format: "mp4" },
|
||||
sourceInputs: [".mov"],
|
||||
},
|
||||
{
|
||||
id: "webm-to-mp4",
|
||||
from: "WEBM",
|
||||
to: "MP4",
|
||||
base: "convert-video",
|
||||
locked: { format: "mp4" },
|
||||
sourceInputs: [".webm"],
|
||||
},
|
||||
{
|
||||
id: "mkv-to-mp4",
|
||||
from: "MKV",
|
||||
to: "MP4",
|
||||
base: "convert-video",
|
||||
locked: { format: "mp4" },
|
||||
sourceInputs: [".mkv"],
|
||||
},
|
||||
{
|
||||
id: "avi-to-mp4",
|
||||
from: "AVI",
|
||||
to: "MP4",
|
||||
base: "convert-video",
|
||||
locked: { format: "mp4" },
|
||||
sourceInputs: [".avi"],
|
||||
},
|
||||
{
|
||||
id: "mp4-to-mov",
|
||||
from: "MP4",
|
||||
to: "MOV",
|
||||
base: "convert-video",
|
||||
locked: { format: "mov" },
|
||||
sourceInputs: [".mp4"],
|
||||
},
|
||||
{
|
||||
id: "mp4-to-webm",
|
||||
from: "MP4",
|
||||
to: "WEBM",
|
||||
base: "convert-video",
|
||||
locked: { format: "webm" },
|
||||
sourceInputs: [".mp4"],
|
||||
},
|
||||
{
|
||||
id: "webm-to-mov",
|
||||
from: "WEBM",
|
||||
to: "MOV",
|
||||
base: "convert-video",
|
||||
locked: { format: "mov" },
|
||||
sourceInputs: [".webm"],
|
||||
},
|
||||
{
|
||||
id: "mkv-to-mov",
|
||||
from: "MKV",
|
||||
to: "MOV",
|
||||
base: "convert-video",
|
||||
locked: { format: "mov" },
|
||||
sourceInputs: [".mkv"],
|
||||
},
|
||||
{
|
||||
id: "avi-to-mov",
|
||||
from: "AVI",
|
||||
to: "MOV",
|
||||
base: "convert-video",
|
||||
locked: { format: "mov" },
|
||||
sourceInputs: [".avi"],
|
||||
},
|
||||
// Video container (Tweak: convert-video needs avi/mkv output, Task 5)
|
||||
{
|
||||
id: "mp4-to-avi",
|
||||
from: "MP4",
|
||||
to: "AVI",
|
||||
base: "convert-video",
|
||||
locked: { format: "avi" },
|
||||
sourceInputs: [".mp4"],
|
||||
},
|
||||
{
|
||||
id: "mov-to-avi",
|
||||
from: "MOV",
|
||||
to: "AVI",
|
||||
base: "convert-video",
|
||||
locked: { format: "avi" },
|
||||
sourceInputs: [".mov"],
|
||||
},
|
||||
{
|
||||
id: "mkv-to-avi",
|
||||
from: "MKV",
|
||||
to: "AVI",
|
||||
base: "convert-video",
|
||||
locked: { format: "avi" },
|
||||
sourceInputs: [".mkv"],
|
||||
},
|
||||
{
|
||||
id: "avi-to-mkv",
|
||||
from: "AVI",
|
||||
to: "MKV",
|
||||
base: "convert-video",
|
||||
locked: { format: "mkv" },
|
||||
sourceInputs: [".avi"],
|
||||
},
|
||||
|
||||
// Video -> GIF (base: video-to-gif), registered via group "registry" delegating to video-to-gif
|
||||
{
|
||||
id: "mp4-to-gif",
|
||||
from: "MP4",
|
||||
to: "GIF",
|
||||
base: "video-to-gif",
|
||||
locked: {},
|
||||
sourceInputs: [".mp4"],
|
||||
},
|
||||
{
|
||||
id: "mov-to-gif",
|
||||
from: "MOV",
|
||||
to: "GIF",
|
||||
base: "video-to-gif",
|
||||
locked: {},
|
||||
sourceInputs: [".mov"],
|
||||
},
|
||||
{
|
||||
id: "mkv-to-gif",
|
||||
from: "MKV",
|
||||
to: "GIF",
|
||||
base: "video-to-gif",
|
||||
locked: {},
|
||||
sourceInputs: [".mkv"],
|
||||
},
|
||||
{
|
||||
id: "avi-to-gif",
|
||||
from: "AVI",
|
||||
to: "GIF",
|
||||
base: "video-to-gif",
|
||||
locked: {},
|
||||
sourceInputs: [".avi"],
|
||||
},
|
||||
|
||||
// GIF -> video (base: gif-to-video)
|
||||
{
|
||||
id: "gif-to-mp4",
|
||||
from: "GIF",
|
||||
to: "MP4",
|
||||
base: "gif-to-video",
|
||||
locked: { format: "mp4" },
|
||||
sourceInputs: GIF,
|
||||
},
|
||||
{
|
||||
id: "gif-to-webm",
|
||||
from: "GIF",
|
||||
to: "WEBM",
|
||||
base: "gif-to-video",
|
||||
locked: { format: "webm" },
|
||||
sourceInputs: GIF,
|
||||
},
|
||||
{
|
||||
id: "gif-to-mov",
|
||||
from: "GIF",
|
||||
to: "MOV",
|
||||
base: "gif-to-video",
|
||||
locked: { format: "mov" },
|
||||
sourceInputs: GIF,
|
||||
}, // Tweak
|
||||
|
||||
// Video -> audio (base: extract-audio)
|
||||
{
|
||||
id: "mp4-to-mp3",
|
||||
from: "MP4",
|
||||
to: "MP3",
|
||||
base: "extract-audio",
|
||||
locked: { format: "mp3" },
|
||||
sourceInputs: [".mp4"],
|
||||
},
|
||||
{
|
||||
id: "mov-to-mp3",
|
||||
from: "MOV",
|
||||
to: "MP3",
|
||||
base: "extract-audio",
|
||||
locked: { format: "mp3" },
|
||||
sourceInputs: [".mov"],
|
||||
},
|
||||
{
|
||||
id: "mkv-to-mp3",
|
||||
from: "MKV",
|
||||
to: "MP3",
|
||||
base: "extract-audio",
|
||||
locked: { format: "mp3" },
|
||||
sourceInputs: [".mkv"],
|
||||
},
|
||||
{
|
||||
id: "webm-to-mp3",
|
||||
from: "WEBM",
|
||||
to: "MP3",
|
||||
base: "extract-audio",
|
||||
locked: { format: "mp3" },
|
||||
sourceInputs: [".webm"],
|
||||
},
|
||||
{
|
||||
id: "avi-to-mp3",
|
||||
from: "AVI",
|
||||
to: "MP3",
|
||||
base: "extract-audio",
|
||||
locked: { format: "mp3" },
|
||||
sourceInputs: [".avi"],
|
||||
},
|
||||
{
|
||||
id: "mp4-to-wav",
|
||||
from: "MP4",
|
||||
to: "WAV",
|
||||
base: "extract-audio",
|
||||
locked: { format: "wav" },
|
||||
sourceInputs: [".mp4"],
|
||||
},
|
||||
{
|
||||
id: "mov-to-wav",
|
||||
from: "MOV",
|
||||
to: "WAV",
|
||||
base: "extract-audio",
|
||||
locked: { format: "wav" },
|
||||
sourceInputs: [".mov"],
|
||||
},
|
||||
{
|
||||
id: "mp4-to-ogg",
|
||||
from: "MP4",
|
||||
to: "OGG",
|
||||
base: "extract-audio",
|
||||
locked: { format: "ogg" },
|
||||
sourceInputs: [".mp4"],
|
||||
}, // Tweak
|
||||
|
||||
// Audio (base: convert-audio)
|
||||
{
|
||||
id: "m4a-to-mp3",
|
||||
from: "M4A",
|
||||
to: "MP3",
|
||||
base: "convert-audio",
|
||||
locked: { format: "mp3" },
|
||||
sourceInputs: [".m4a"],
|
||||
},
|
||||
{
|
||||
id: "m4a-to-wav",
|
||||
from: "M4A",
|
||||
to: "WAV",
|
||||
base: "convert-audio",
|
||||
locked: { format: "wav" },
|
||||
sourceInputs: [".m4a"],
|
||||
},
|
||||
{
|
||||
id: "aac-to-mp3",
|
||||
from: "AAC",
|
||||
to: "MP3",
|
||||
base: "convert-audio",
|
||||
locked: { format: "mp3" },
|
||||
sourceInputs: [".aac"],
|
||||
},
|
||||
{
|
||||
id: "aac-to-wav",
|
||||
from: "AAC",
|
||||
to: "WAV",
|
||||
base: "convert-audio",
|
||||
locked: { format: "wav" },
|
||||
sourceInputs: [".aac"],
|
||||
},
|
||||
{
|
||||
id: "aac-to-flac",
|
||||
from: "AAC",
|
||||
to: "FLAC",
|
||||
base: "convert-audio",
|
||||
locked: { format: "flac" },
|
||||
sourceInputs: [".aac"],
|
||||
},
|
||||
{
|
||||
id: "ogg-to-mp3",
|
||||
from: "OGG",
|
||||
to: "MP3",
|
||||
base: "convert-audio",
|
||||
locked: { format: "mp3" },
|
||||
sourceInputs: [".ogg"],
|
||||
},
|
||||
{
|
||||
id: "ogg-to-wav",
|
||||
from: "OGG",
|
||||
to: "WAV",
|
||||
base: "convert-audio",
|
||||
locked: { format: "wav" },
|
||||
sourceInputs: [".ogg"],
|
||||
},
|
||||
{
|
||||
id: "wav-to-mp3",
|
||||
from: "WAV",
|
||||
to: "MP3",
|
||||
base: "convert-audio",
|
||||
locked: { format: "mp3" },
|
||||
sourceInputs: [".wav"],
|
||||
},
|
||||
{
|
||||
id: "mp3-to-wav",
|
||||
from: "MP3",
|
||||
to: "WAV",
|
||||
base: "convert-audio",
|
||||
locked: { format: "wav" },
|
||||
sourceInputs: [".mp3"],
|
||||
},
|
||||
{
|
||||
id: "flac-to-mp3",
|
||||
from: "FLAC",
|
||||
to: "MP3",
|
||||
base: "convert-audio",
|
||||
locked: { format: "mp3" },
|
||||
sourceInputs: [".flac"],
|
||||
},
|
||||
|
||||
// Data (base: convert-spreadsheet)
|
||||
{
|
||||
id: "excel-to-csv",
|
||||
from: "Excel",
|
||||
to: "CSV",
|
||||
base: "convert-spreadsheet",
|
||||
locked: { format: "csv" },
|
||||
sourceInputs: [".xlsx", ".xls"],
|
||||
},
|
||||
];
|
||||
|
||||
/** Icon per base modality (lucide names already used elsewhere in the catalog). */
|
||||
const ICON_BY_GROUP: Record<string, string> = {
|
||||
image: "FileType",
|
||||
video: "FileVideo",
|
||||
audio: "FileAudio",
|
||||
document: "FileType",
|
||||
};
|
||||
|
||||
/**
|
||||
* Expand presets into full Tool objects. Reads executionHint/modality from the
|
||||
* base tool in TOOLS (mirrors it), applies the BASE_CONFIG category override,
|
||||
* sets route = `/<id>`, sourceInputs as acceptedInputs, and auto-generates keywords.
|
||||
* `baseTools` is injected to avoid a circular import with constants.ts.
|
||||
*/
|
||||
export function expandConversionPresets(baseTools?: Pick<Tool, "id" | "executionHint">[]): Tool[] {
|
||||
return CONVERSION_PRESETS.map((p) => {
|
||||
const cfg = BASE_CONFIG[p.base];
|
||||
if (!cfg) throw new Error(`No BASE_CONFIG for base "${p.base}" (preset ${p.id})`);
|
||||
const base = baseTools?.find((t) => t.id === p.base);
|
||||
const executionHint = base?.executionHint ?? (cfg.modality === "image" ? "fast" : "long");
|
||||
return {
|
||||
id: p.id,
|
||||
name: `${p.from} to ${p.to}`,
|
||||
description: `Convert ${p.from} to ${p.to}`,
|
||||
category: cfg.category,
|
||||
icon: ICON_BY_GROUP[cfg.modality] ?? "FileType",
|
||||
route: `/${p.id}`,
|
||||
modality: cfg.modality,
|
||||
acceptedInputs: p.sourceInputs,
|
||||
outputModality: cfg.outputModality,
|
||||
executionHint,
|
||||
keywords: generateConversionKeywords({ from: p.from, to: p.to }),
|
||||
} satisfies Tool;
|
||||
});
|
||||
}
|
||||
|
||||
export const CONVERSION_PRESET_BY_ID: Record<string, ConversionPreset> = Object.fromEntries(
|
||||
CONVERSION_PRESETS.map((p) => [p.id, p]),
|
||||
);
|
||||
@@ -73,6 +73,340 @@ export const ar: TranslationKeys = {
|
||||
documentsAndFiles: "PDF وملفات",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG إلى PNG",
|
||||
description: "حوّل JPG إلى PNG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG إلى JPG",
|
||||
description: "حوّل PNG إلى JPG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG إلى WebP",
|
||||
description: "حوّل JPG إلى WebP عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG إلى WebP",
|
||||
description: "حوّل PNG إلى WebP عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP إلى JPG",
|
||||
description: "حوّل WebP إلى JPG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP إلى PNG",
|
||||
description: "حوّل WebP إلى PNG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG إلى AVIF",
|
||||
description: "حوّل JPG إلى AVIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG إلى AVIF",
|
||||
description: "حوّل PNG إلى AVIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP إلى AVIF",
|
||||
description: "حوّل WebP إلى AVIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC إلى JPG",
|
||||
description: "حوّل HEIC إلى JPG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC إلى PNG",
|
||||
description: "حوّل HEIC إلى PNG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC إلى AVIF",
|
||||
description: "حوّل HEIC إلى AVIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG إلى GIF",
|
||||
description: "حوّل JPG إلى GIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG إلى GIF",
|
||||
description: "حوّل PNG إلى GIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF إلى JPG",
|
||||
description: "حوّل GIF إلى JPG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF إلى PNG",
|
||||
description: "حوّل GIF إلى PNG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP إلى GIF",
|
||||
description: "حوّل WebP إلى GIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG إلى TIFF",
|
||||
description: "حوّل JPG إلى TIFF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG إلى TIFF",
|
||||
description: "حوّل PNG إلى TIFF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF إلى JPG",
|
||||
description: "حوّل TIFF إلى JPG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF إلى PNG",
|
||||
description: "حوّل TIFF إلى PNG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD إلى JPG",
|
||||
description: "حوّل PSD إلى JPG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD إلى PNG",
|
||||
description: "حوّل PSD إلى PNG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG إلى EPS",
|
||||
description: "حوّل PNG إلى EPS عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG إلى EPS",
|
||||
description: "حوّل JPG إلى EPS عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS إلى PNG",
|
||||
description: "حوّل EPS إلى PNG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS إلى JPG",
|
||||
description: "حوّل EPS إلى JPG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG إلى SVG",
|
||||
description: "حوّل PNG إلى SVG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG إلى SVG",
|
||||
description: "حوّل JPG إلى SVG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF إلى SVG",
|
||||
description: "حوّل TIFF إلى SVG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD إلى SVG",
|
||||
description: "حوّل PSD إلى SVG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS إلى SVG",
|
||||
description: "حوّل EPS إلى SVG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG إلى PNG",
|
||||
description: "حوّل SVG إلى PNG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG إلى JPG",
|
||||
description: "حوّل SVG إلى JPG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG إلى PDF",
|
||||
description: "حوّل JPG إلى PDF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG إلى PDF",
|
||||
description: "حوّل PNG إلى PDF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC إلى PDF",
|
||||
description: "حوّل HEIC إلى PDF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF إلى PDF",
|
||||
description: "حوّل TIFF إلى PDF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP إلى PDF",
|
||||
description: "حوّل WebP إلى PDF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF إلى PDF",
|
||||
description: "حوّل GIF إلى PDF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS إلى PDF",
|
||||
description: "حوّل EPS إلى PDF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF إلى JPG",
|
||||
description: "حوّل PDF إلى JPG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF إلى PNG",
|
||||
description: "حوّل PDF إلى PNG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF إلى TIFF",
|
||||
description: "حوّل PDF إلى TIFF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV إلى MP4",
|
||||
description: "حوّل MOV إلى MP4 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM إلى MP4",
|
||||
description: "حوّل WEBM إلى MP4 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV إلى MP4",
|
||||
description: "حوّل MKV إلى MP4 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI إلى MP4",
|
||||
description: "حوّل AVI إلى MP4 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 إلى MOV",
|
||||
description: "حوّل MP4 إلى MOV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 إلى WEBM",
|
||||
description: "حوّل MP4 إلى WEBM عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM إلى MOV",
|
||||
description: "حوّل WEBM إلى MOV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV إلى MOV",
|
||||
description: "حوّل MKV إلى MOV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI إلى MOV",
|
||||
description: "حوّل AVI إلى MOV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 إلى AVI",
|
||||
description: "حوّل MP4 إلى AVI عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV إلى AVI",
|
||||
description: "حوّل MOV إلى AVI عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV إلى AVI",
|
||||
description: "حوّل MKV إلى AVI عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI إلى MKV",
|
||||
description: "حوّل AVI إلى MKV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 إلى GIF",
|
||||
description: "حوّل MP4 إلى GIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV إلى GIF",
|
||||
description: "حوّل MOV إلى GIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV إلى GIF",
|
||||
description: "حوّل MKV إلى GIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI إلى GIF",
|
||||
description: "حوّل AVI إلى GIF عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF إلى MP4",
|
||||
description: "حوّل GIF إلى MP4 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF إلى WEBM",
|
||||
description: "حوّل GIF إلى WEBM عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF إلى MOV",
|
||||
description: "حوّل GIF إلى MOV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 إلى MP3",
|
||||
description: "حوّل MP4 إلى MP3 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV إلى MP3",
|
||||
description: "حوّل MOV إلى MP3 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV إلى MP3",
|
||||
description: "حوّل MKV إلى MP3 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM إلى MP3",
|
||||
description: "حوّل WEBM إلى MP3 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI إلى MP3",
|
||||
description: "حوّل AVI إلى MP3 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 إلى WAV",
|
||||
description: "حوّل MP4 إلى WAV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV إلى WAV",
|
||||
description: "حوّل MOV إلى WAV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 إلى OGG",
|
||||
description: "حوّل MP4 إلى OGG عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A إلى MP3",
|
||||
description: "حوّل M4A إلى MP3 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A إلى WAV",
|
||||
description: "حوّل M4A إلى WAV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC إلى MP3",
|
||||
description: "حوّل AAC إلى MP3 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC إلى WAV",
|
||||
description: "حوّل AAC إلى WAV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC إلى FLAC",
|
||||
description: "حوّل AAC إلى FLAC عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG إلى MP3",
|
||||
description: "حوّل OGG إلى MP3 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG إلى WAV",
|
||||
description: "حوّل OGG إلى WAV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV إلى MP3",
|
||||
description: "حوّل WAV إلى MP3 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 إلى WAV",
|
||||
description: "حوّل MP3 إلى WAV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC إلى MP3",
|
||||
description: "حوّل FLAC إلى MP3 عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel إلى CSV",
|
||||
description: "حوّل Excel إلى CSV عبر الإنترنت. سريع ومجاني وخاص.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "تغيير الحجم",
|
||||
description: "تغيير الحجم بالبكسل أو النسبة المئوية أو قوالب وسائل التواصل",
|
||||
@@ -150,7 +484,7 @@ export const ar: TranslationKeys = {
|
||||
description: "إزالة الكائنات غير المرغوبة بتقنية AI",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / استخراج النصوص",
|
||||
name: "استخراج النص من صورة (OCR)",
|
||||
description: "استخراج النصوص من الصور بدقة مدعومة بـ AI",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2740,6 +3074,14 @@ export const ar: TranslationKeys = {
|
||||
"تنزيل ملف تشخيصي مضغوط يحتوي على إعدادات محجوبة وسجلات وبيانات المهام لاستكشاف الأخطاء.",
|
||||
supportBundleFailed: "فشل تنزيل حزمة الدعم.",
|
||||
},
|
||||
privacy: {
|
||||
title: "الخصوصية",
|
||||
description:
|
||||
"تساعد تحليلات المنتج المجهولة في تحسين SnapOtter. وهي مفعّلة افتراضيًا ويضبطها المسؤول لكامل النسخة.",
|
||||
analyticsLabel: "تحليلات المنتج المجهولة",
|
||||
analyticsDescription:
|
||||
"شارك بيانات استخدام مجهولة (الأدوات التي تعمل، ومدد التشغيل، ورموز الأخطاء) وتقارير الأعطال. بلا عنوان IP وبلا هوية حساب. لا نجمع أبدًا أسماء ملفاتك أو محتوى الملفات أو مخرجات OCR أو البيانات الوصفية للصور أو نص المستندات. أوقف هذا الخيار لإيقاف كل ذلك.",
|
||||
},
|
||||
security: {
|
||||
heading: "الأمان",
|
||||
description: "إعدادات كلمة المرور والمصادقة.",
|
||||
@@ -3026,7 +3368,7 @@ export const ar: TranslationKeys = {
|
||||
heading: "حول",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"مجموعة أدوات معالجة ملفات ذاتية الاستضافة تركز على الخصوصية مع 157 أداة. تحويل وضغط وتحرير ونسخ صوتي وتعرّف ضوئي وأتمتة سير عمل الصور والفيديو والصوت والمستندات والبيانات دون إرسال البيانات إلى السحابة.",
|
||||
"مجموعة أدوات معالجة ملفات ذاتية الاستضافة تركز على الخصوصية مع 240 أداة. تحويل وضغط وتحرير ونسخ صوتي وتعرّف ضوئي وأتمتة سير عمل الصور والفيديو والصوت والمستندات والبيانات دون إرسال البيانات إلى السحابة.",
|
||||
versionLabel: "الإصدار:",
|
||||
linksHeading: "روابط",
|
||||
githubLink: "مستودع GitHub",
|
||||
@@ -3082,7 +3424,7 @@ export const ar: TranslationKeys = {
|
||||
"بلا حدود. بلا قيود خفية.",
|
||||
"يعمل بدون إنترنت.",
|
||||
"معالجة جماعية بلا حدود.",
|
||||
"157 أداة.",
|
||||
"240 أداة.",
|
||||
"15 نموذج AI. على جهازك.",
|
||||
"سريع كالبرق. مبني على Sharp.",
|
||||
"يعمل في بيئات معزولة.",
|
||||
@@ -3146,6 +3488,7 @@ export const ar: TranslationKeys = {
|
||||
toolPalette: "لوحة الأدوات",
|
||||
clickToAdd: "اضغط للإضافة إلى Pipeline",
|
||||
savedLabel: "محفوظ",
|
||||
templatesLabel: "القوالب",
|
||||
importButton: "استيراد",
|
||||
noSavedPipelines: "لا توجد Pipeline محفوظة",
|
||||
stepCount: "({count} خطوة)",
|
||||
@@ -3430,4 +3773,42 @@ export const ar: TranslationKeys = {
|
||||
navigationMenu: "التنقل",
|
||||
homeLink: "الصفحة الرئيسية لـ SnapOtter",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "تحسين للويب",
|
||||
description: "تغيير حجم الصورة وتحويلها إلى WebP وضغطها لتحميل سريع على الويب.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "مناسب للبريد الإلكتروني",
|
||||
description: "تصغير الصورة بما يكفي لإرفاقها برسالة بريد إلكتروني.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "تنظيف الخصوصية",
|
||||
description: "إزالة بيانات EXIF وGPS وبيانات التعريف الأخرى، ثم الضغط.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "مربع للتواصل الاجتماعي",
|
||||
description: "اقتصاص الصورة إلى مربع 1:1 وضغطها لمنشورات التواصل الاجتماعي.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "علامة مائية بالدُّفعة",
|
||||
description: "إضافة علامة مائية نصية إلى الصور، ثم ضغطها.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "تصغير PDF للبريد الإلكتروني",
|
||||
description: "تسطيح ملف PDF وضغطه بقوة ليتوافق مع حدود البريد الإلكتروني.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "تحسين البودكاست",
|
||||
description: "إزالة الصمت وتطبيع مستوى الصوت وإضافة تلاشٍ إلى التسجيل.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "قص العنصر",
|
||||
description: "إزالة الخلفية باستخدام الذكاء الاصطناعي وضغط الصورة الشفافة المقصوصة.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "ترميم صورة قديمة",
|
||||
description: "إصلاح صورة قديمة أو تالفة وتلوينها وتكبيرها باستخدام الذكاء الاصطناعي.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const de: TranslationKeys = {
|
||||
documentsAndFiles: "PDF & Dateien",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG in PNG umwandeln",
|
||||
description: "JPG online in PNG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG in JPG umwandeln",
|
||||
description: "PNG online in JPG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG in WebP umwandeln",
|
||||
description: "JPG online in WebP umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG in WebP umwandeln",
|
||||
description: "PNG online in WebP umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP in JPG umwandeln",
|
||||
description: "WebP online in JPG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP in PNG umwandeln",
|
||||
description: "WebP online in PNG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG in AVIF umwandeln",
|
||||
description: "JPG online in AVIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG in AVIF umwandeln",
|
||||
description: "PNG online in AVIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP in AVIF umwandeln",
|
||||
description: "WebP online in AVIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC in JPG umwandeln",
|
||||
description: "HEIC online in JPG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC in PNG umwandeln",
|
||||
description: "HEIC online in PNG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC in AVIF umwandeln",
|
||||
description: "HEIC online in AVIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG in GIF umwandeln",
|
||||
description: "JPG online in GIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG in GIF umwandeln",
|
||||
description: "PNG online in GIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF in JPG umwandeln",
|
||||
description: "GIF online in JPG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF in PNG umwandeln",
|
||||
description: "GIF online in PNG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP in GIF umwandeln",
|
||||
description: "WebP online in GIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG in TIFF umwandeln",
|
||||
description: "JPG online in TIFF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG in TIFF umwandeln",
|
||||
description: "PNG online in TIFF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF in JPG umwandeln",
|
||||
description: "TIFF online in JPG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF in PNG umwandeln",
|
||||
description: "TIFF online in PNG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD in JPG umwandeln",
|
||||
description: "PSD online in JPG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD in PNG umwandeln",
|
||||
description: "PSD online in PNG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG in EPS umwandeln",
|
||||
description: "PNG online in EPS umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG in EPS umwandeln",
|
||||
description: "JPG online in EPS umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS in PNG umwandeln",
|
||||
description: "EPS online in PNG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS in JPG umwandeln",
|
||||
description: "EPS online in JPG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG in SVG umwandeln",
|
||||
description: "PNG online in SVG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG in SVG umwandeln",
|
||||
description: "JPG online in SVG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF in SVG umwandeln",
|
||||
description: "TIFF online in SVG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD in SVG umwandeln",
|
||||
description: "PSD online in SVG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS in SVG umwandeln",
|
||||
description: "EPS online in SVG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG in PNG umwandeln",
|
||||
description: "SVG online in PNG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG in JPG umwandeln",
|
||||
description: "SVG online in JPG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG in PDF umwandeln",
|
||||
description: "JPG online in PDF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG in PDF umwandeln",
|
||||
description: "PNG online in PDF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC in PDF umwandeln",
|
||||
description: "HEIC online in PDF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF in PDF umwandeln",
|
||||
description: "TIFF online in PDF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP in PDF umwandeln",
|
||||
description: "WebP online in PDF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF in PDF umwandeln",
|
||||
description: "GIF online in PDF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS in PDF umwandeln",
|
||||
description: "EPS online in PDF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF in JPG umwandeln",
|
||||
description: "PDF online in JPG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF in PNG umwandeln",
|
||||
description: "PDF online in PNG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF in TIFF umwandeln",
|
||||
description: "PDF online in TIFF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV in MP4 umwandeln",
|
||||
description: "MOV online in MP4 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM in MP4 umwandeln",
|
||||
description: "WEBM online in MP4 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV in MP4 umwandeln",
|
||||
description: "MKV online in MP4 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI in MP4 umwandeln",
|
||||
description: "AVI online in MP4 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 in MOV umwandeln",
|
||||
description: "MP4 online in MOV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 in WEBM umwandeln",
|
||||
description: "MP4 online in WEBM umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM in MOV umwandeln",
|
||||
description: "WEBM online in MOV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV in MOV umwandeln",
|
||||
description: "MKV online in MOV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI in MOV umwandeln",
|
||||
description: "AVI online in MOV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 in AVI umwandeln",
|
||||
description: "MP4 online in AVI umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV in AVI umwandeln",
|
||||
description: "MOV online in AVI umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV in AVI umwandeln",
|
||||
description: "MKV online in AVI umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI in MKV umwandeln",
|
||||
description: "AVI online in MKV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 in GIF umwandeln",
|
||||
description: "MP4 online in GIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV in GIF umwandeln",
|
||||
description: "MOV online in GIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV in GIF umwandeln",
|
||||
description: "MKV online in GIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI in GIF umwandeln",
|
||||
description: "AVI online in GIF umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF in MP4 umwandeln",
|
||||
description: "GIF online in MP4 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF in WEBM umwandeln",
|
||||
description: "GIF online in WEBM umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF in MOV umwandeln",
|
||||
description: "GIF online in MOV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 in MP3 umwandeln",
|
||||
description: "MP4 online in MP3 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV in MP3 umwandeln",
|
||||
description: "MOV online in MP3 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV in MP3 umwandeln",
|
||||
description: "MKV online in MP3 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM in MP3 umwandeln",
|
||||
description: "WEBM online in MP3 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI in MP3 umwandeln",
|
||||
description: "AVI online in MP3 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 in WAV umwandeln",
|
||||
description: "MP4 online in WAV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV in WAV umwandeln",
|
||||
description: "MOV online in WAV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 in OGG umwandeln",
|
||||
description: "MP4 online in OGG umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A in MP3 umwandeln",
|
||||
description: "M4A online in MP3 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A in WAV umwandeln",
|
||||
description: "M4A online in WAV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC in MP3 umwandeln",
|
||||
description: "AAC online in MP3 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC in WAV umwandeln",
|
||||
description: "AAC online in WAV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC in FLAC umwandeln",
|
||||
description: "AAC online in FLAC umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG in MP3 umwandeln",
|
||||
description: "OGG online in MP3 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG in WAV umwandeln",
|
||||
description: "OGG online in WAV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV in MP3 umwandeln",
|
||||
description: "WAV online in MP3 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 in WAV umwandeln",
|
||||
description: "MP3 online in WAV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC in MP3 umwandeln",
|
||||
description: "FLAC online in MP3 umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel in CSV umwandeln",
|
||||
description: "Excel online in CSV umwandeln. Schnell, kostenlos und privat.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Größe ändern",
|
||||
description: "Größe nach Pixeln, Prozent oder Social-Media-Vorlagen ändern",
|
||||
@@ -151,7 +485,7 @@ export const de: TranslationKeys = {
|
||||
description: "Unerwünschte Objekte mit AI entfernen",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / Texterkennung",
|
||||
name: "Text aus Bild extrahieren (OCR)",
|
||||
description: "Text aus Bildern mit AI-gestützter Genauigkeit extrahieren",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2760,6 +3094,14 @@ export const de: TranslationKeys = {
|
||||
"Diagnosearchiv mit bereinigter Konfiguration, Protokollen und Auftragsdaten zur Fehlerbehebung herunterladen.",
|
||||
supportBundleFailed: "Support-Paket konnte nicht heruntergeladen werden.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Datenschutz",
|
||||
description:
|
||||
"Anonyme Produktanalysen helfen, SnapOtter zu verbessern. Sie sind standardmäßig aktiviert und werden von einem Administrator für die gesamte Instanz festgelegt.",
|
||||
analyticsLabel: "Anonyme Produktanalysen",
|
||||
analyticsDescription:
|
||||
"Teilen Sie anonyme Nutzungsdaten (welche Tools ausgeführt werden, Dauer, Fehlercodes) und Absturzberichte. Keine IP-Adresse, keine Kontoidentität. Wir erfassen niemals Ihre Dateinamen, Dateiinhalte, OCR-Ausgaben, Bildmetadaten oder Dokumenttexte. Schalten Sie dies aus, um alles davon zu beenden.",
|
||||
},
|
||||
security: {
|
||||
heading: "Sicherheit",
|
||||
description: "Passwort- und Authentifizierungseinstellungen.",
|
||||
@@ -3060,7 +3402,7 @@ export const de: TranslationKeys = {
|
||||
heading: "Info",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"Eine selbst gehostete, datenschutzorientierte Dateiverarbeitungssuite mit 157 Werkzeugen. Konvertieren, komprimieren, bearbeiten, transkribieren, OCR und Bild-, Video-, Audio-, Dokument- und Datenabläufe automatisieren, ohne Daten in die Cloud zu senden.",
|
||||
"Eine selbst gehostete, datenschutzorientierte Dateiverarbeitungssuite mit 240 Werkzeugen. Konvertieren, komprimieren, bearbeiten, transkribieren, OCR und Bild-, Video-, Audio-, Dokument- und Datenabläufe automatisieren, ohne Daten in die Cloud zu senden.",
|
||||
versionLabel: "Version:",
|
||||
linksHeading: "Links",
|
||||
githubLink: "GitHub-Repository",
|
||||
@@ -3120,7 +3462,7 @@ export const de: TranslationKeys = {
|
||||
"Keine Limits. Keine versteckten Grenzen.",
|
||||
"Funktioniert komplett offline.",
|
||||
"Unbegrenzte Stapelverarbeitung.",
|
||||
"157 Werkzeuge.",
|
||||
"240 Werkzeuge.",
|
||||
"16 AI-Modelle. Ihre Hardware.",
|
||||
"Blitzschnell. Basiert auf Sharp.",
|
||||
"Geeignet für abgeschottete Netzwerke.",
|
||||
@@ -3185,6 +3527,7 @@ export const de: TranslationKeys = {
|
||||
toolPalette: "Werkzeugpalette",
|
||||
clickToAdd: "Zum Hinzufügen zur Pipeline klicken",
|
||||
savedLabel: "Gespeichert",
|
||||
templatesLabel: "Vorlagen",
|
||||
importButton: "Importieren",
|
||||
noSavedPipelines: "Keine gespeicherten Pipelines",
|
||||
stepCount: "({count} Schritt)",
|
||||
@@ -3472,4 +3815,44 @@ export const de: TranslationKeys = {
|
||||
navigationMenu: "Navigation",
|
||||
homeLink: "SnapOtter Startseite",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Web-optimieren",
|
||||
description:
|
||||
"Bild verkleinern, in WebP umwandeln und komprimieren für schnelles Laden im Web.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "E-Mail-tauglich",
|
||||
description: "Bild so verkleinern, dass es als E-Mail-Anhang klein genug ist.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Metadaten entfernen",
|
||||
description: "EXIF, GPS und andere Metadaten entfernen, dann komprimieren.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Social-Quadrat",
|
||||
description: "Auf 1:1 zuschneiden und für Social-Media-Beiträge komprimieren.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Wasserzeichen-Stapel",
|
||||
description: "Text-Wasserzeichen auf Bilder setzen, dann komprimieren.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "PDF für E-Mail verkleinern",
|
||||
description: "PDF reduzieren und stark komprimieren, damit es E-Mail-Grenzen einhält.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Podcast-Feinschliff",
|
||||
description: "Stille entfernen, Lautstärke normalisieren und Ein-/Ausblendungen hinzufügen.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Motiv freistellen",
|
||||
description:
|
||||
"Hintergrund per KI entfernen und das transparente Freisteller-Bild komprimieren.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Altes Foto restaurieren",
|
||||
description: "Altes oder beschädigtes Foto per KI reparieren, kolorieren und hochskalieren.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -76,6 +76,340 @@ export const en = {
|
||||
documentsAndFiles: "PDF & Files",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG to PNG",
|
||||
description: "Convert JPG to PNG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG to JPG",
|
||||
description: "Convert PNG to JPG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG to WebP",
|
||||
description: "Convert JPG to WebP online. Fast, free, private, no signup.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG to WebP",
|
||||
description: "Convert PNG to WebP online. Fast, free, private, no signup.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP to JPG",
|
||||
description: "Convert WebP to JPG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP to PNG",
|
||||
description: "Convert WebP to PNG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG to AVIF",
|
||||
description: "Convert JPG to AVIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG to AVIF",
|
||||
description: "Convert PNG to AVIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP to AVIF",
|
||||
description: "Convert WebP to AVIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC to JPG",
|
||||
description: "Convert HEIC to JPG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC to PNG",
|
||||
description: "Convert HEIC to PNG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC to AVIF",
|
||||
description: "Convert HEIC to AVIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG to GIF",
|
||||
description: "Convert JPG to GIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG to GIF",
|
||||
description: "Convert PNG to GIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF to JPG",
|
||||
description: "Convert GIF to JPG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF to PNG",
|
||||
description: "Convert GIF to PNG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP to GIF",
|
||||
description: "Convert WebP to GIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG to TIFF",
|
||||
description: "Convert JPG to TIFF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG to TIFF",
|
||||
description: "Convert PNG to TIFF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF to JPG",
|
||||
description: "Convert TIFF to JPG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF to PNG",
|
||||
description: "Convert TIFF to PNG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD to JPG",
|
||||
description: "Convert PSD to JPG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD to PNG",
|
||||
description: "Convert PSD to PNG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG to EPS",
|
||||
description: "Convert PNG to EPS online. Fast, free, private, no signup.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG to EPS",
|
||||
description: "Convert JPG to EPS online. Fast, free, private, no signup.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS to PNG",
|
||||
description: "Convert EPS to PNG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS to JPG",
|
||||
description: "Convert EPS to JPG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG to SVG",
|
||||
description: "Convert PNG to SVG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG to SVG",
|
||||
description: "Convert JPG to SVG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF to SVG",
|
||||
description: "Convert TIFF to SVG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD to SVG",
|
||||
description: "Convert PSD to SVG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS to SVG",
|
||||
description: "Convert EPS to SVG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG to PNG",
|
||||
description: "Convert SVG to PNG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG to JPG",
|
||||
description: "Convert SVG to JPG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG to PDF",
|
||||
description: "Convert JPG to PDF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG to PDF",
|
||||
description: "Convert PNG to PDF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC to PDF",
|
||||
description: "Convert HEIC to PDF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF to PDF",
|
||||
description: "Convert TIFF to PDF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP to PDF",
|
||||
description: "Convert WebP to PDF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF to PDF",
|
||||
description: "Convert GIF to PDF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS to PDF",
|
||||
description: "Convert EPS to PDF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF to JPG",
|
||||
description: "Convert PDF to JPG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF to PNG",
|
||||
description: "Convert PDF to PNG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF to TIFF",
|
||||
description: "Convert PDF to TIFF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV to MP4",
|
||||
description: "Convert MOV to MP4 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM to MP4",
|
||||
description: "Convert WEBM to MP4 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV to MP4",
|
||||
description: "Convert MKV to MP4 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI to MP4",
|
||||
description: "Convert AVI to MP4 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 to MOV",
|
||||
description: "Convert MP4 to MOV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 to WEBM",
|
||||
description: "Convert MP4 to WEBM online. Fast, free, private, no signup.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM to MOV",
|
||||
description: "Convert WEBM to MOV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV to MOV",
|
||||
description: "Convert MKV to MOV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI to MOV",
|
||||
description: "Convert AVI to MOV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 to AVI",
|
||||
description: "Convert MP4 to AVI online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV to AVI",
|
||||
description: "Convert MOV to AVI online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV to AVI",
|
||||
description: "Convert MKV to AVI online. Fast, free, private, no signup.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI to MKV",
|
||||
description: "Convert AVI to MKV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 to GIF",
|
||||
description: "Convert MP4 to GIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV to GIF",
|
||||
description: "Convert MOV to GIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV to GIF",
|
||||
description: "Convert MKV to GIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI to GIF",
|
||||
description: "Convert AVI to GIF online. Fast, free, private, no signup.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF to MP4",
|
||||
description: "Convert GIF to MP4 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF to WEBM",
|
||||
description: "Convert GIF to WEBM online. Fast, free, private, no signup.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF to MOV",
|
||||
description: "Convert GIF to MOV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 to MP3",
|
||||
description: "Convert MP4 to MP3 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV to MP3",
|
||||
description: "Convert MOV to MP3 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV to MP3",
|
||||
description: "Convert MKV to MP3 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM to MP3",
|
||||
description: "Convert WEBM to MP3 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI to MP3",
|
||||
description: "Convert AVI to MP3 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 to WAV",
|
||||
description: "Convert MP4 to WAV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV to WAV",
|
||||
description: "Convert MOV to WAV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 to OGG",
|
||||
description: "Convert MP4 to OGG online. Fast, free, private, no signup.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A to MP3",
|
||||
description: "Convert M4A to MP3 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A to WAV",
|
||||
description: "Convert M4A to WAV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC to MP3",
|
||||
description: "Convert AAC to MP3 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC to WAV",
|
||||
description: "Convert AAC to WAV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC to FLAC",
|
||||
description: "Convert AAC to FLAC online. Fast, free, private, no signup.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG to MP3",
|
||||
description: "Convert OGG to MP3 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG to WAV",
|
||||
description: "Convert OGG to WAV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV to MP3",
|
||||
description: "Convert WAV to MP3 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 to WAV",
|
||||
description: "Convert MP3 to WAV online. Fast, free, private, no signup.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC to MP3",
|
||||
description: "Convert FLAC to MP3 online. Fast, free, private, no signup.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel to CSV",
|
||||
description: "Convert Excel to CSV online. Fast, free, private, no signup.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Resize",
|
||||
description: "Resize by pixels, percentage, or social media presets",
|
||||
@@ -131,7 +465,7 @@ export const en = {
|
||||
upscale: { name: "Image Upscaling", description: "AI super-resolution enhancement" },
|
||||
"erase-object": { name: "Object Eraser", description: "Remove unwanted objects with AI" },
|
||||
ocr: {
|
||||
name: "OCR / Text Extraction",
|
||||
name: "Extract Text from Image (OCR)",
|
||||
description: "Extract text from images with AI-powered accuracy",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2704,6 +3038,14 @@ export const en = {
|
||||
"Download a diagnostic zip with redacted config, logs, and job data for troubleshooting.",
|
||||
supportBundleFailed: "Failed to download support bundle.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Privacy",
|
||||
description:
|
||||
"Anonymous Product Analytics helps improve SnapOtter. It is on by default and set for the whole instance by an administrator.",
|
||||
analyticsLabel: "Anonymous Product Analytics",
|
||||
analyticsDescription:
|
||||
"Share anonymous usage data (which tools run, durations, error codes) and crash reports. No IP address, no account identity. We never collect your file names, file contents, OCR output, image metadata, or document text. Turn this off to stop all of it.",
|
||||
},
|
||||
security: {
|
||||
heading: "Security",
|
||||
description: "Password and authentication settings.",
|
||||
@@ -2992,7 +3334,7 @@ export const en = {
|
||||
heading: "About",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"A self-hosted, privacy-first file processing suite with 157 tools. Convert, compress, edit, transcribe, OCR, and automate image, video, audio, document, and data workflows without sending data to the cloud.",
|
||||
"A self-hosted, privacy-first file processing suite with 240 tools. Convert, compress, edit, transcribe, OCR, and automate image, video, audio, document, and data workflows without sending data to the cloud.",
|
||||
versionLabel: "Version:",
|
||||
linksHeading: "Links",
|
||||
githubLink: "GitHub Repository",
|
||||
@@ -3051,7 +3393,7 @@ export const en = {
|
||||
"No limits. No hidden caps.",
|
||||
"Works fully offline.",
|
||||
"Unlimited batch processing.",
|
||||
"157 tools.",
|
||||
"240 tools.",
|
||||
"16 AI models. Your hardware.",
|
||||
"Lightning fast. Built on Sharp.",
|
||||
"Air-gapped ready.",
|
||||
@@ -3115,6 +3457,7 @@ export const en = {
|
||||
toolPalette: "Tool Palette",
|
||||
clickToAdd: "Click to add to pipeline",
|
||||
savedLabel: "Saved",
|
||||
templatesLabel: "Templates",
|
||||
importButton: "Import",
|
||||
noSavedPipelines: "No saved pipelines",
|
||||
stepCount: "({count} step)",
|
||||
@@ -3400,6 +3743,44 @@ export const en = {
|
||||
navigationMenu: "Navigation",
|
||||
homeLink: "SnapOtter home",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Web-optimize",
|
||||
description: "Resize, convert to WebP, and compress an image for fast web loading.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "Email-friendly",
|
||||
description: "Shrink an image so it's small enough to attach to an email.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Privacy scrub",
|
||||
description: "Strip EXIF, GPS, and other metadata, then compress.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Social square",
|
||||
description: "Crop to a 1:1 square and compress for social posts.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Watermark batch",
|
||||
description: "Stamp a text watermark on images, then compress.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "Shrink PDF for email",
|
||||
description: "Flatten and aggressively compress a PDF to fit email limits.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Podcast polish",
|
||||
description: "Remove dead air, normalize loudness, and add fades to a recording.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Cut out subject",
|
||||
description: "Remove the background with AI and compress the transparent cutout.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Restore old photo",
|
||||
description: "Repair, colorize, and upscale an old or damaged photo with AI.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
type DeepStringRecord<T> = {
|
||||
|
||||
@@ -73,6 +73,340 @@ export const es: TranslationKeys = {
|
||||
documentsAndFiles: "PDF y archivos",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG a PNG",
|
||||
description: "Convierte JPG a PNG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG a JPG",
|
||||
description: "Convierte PNG a JPG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG a WebP",
|
||||
description: "Convierte JPG a WebP en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG a WebP",
|
||||
description: "Convierte PNG a WebP en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP a JPG",
|
||||
description: "Convierte WebP a JPG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP a PNG",
|
||||
description: "Convierte WebP a PNG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG a AVIF",
|
||||
description: "Convierte JPG a AVIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG a AVIF",
|
||||
description: "Convierte PNG a AVIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP a AVIF",
|
||||
description: "Convierte WebP a AVIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC a JPG",
|
||||
description: "Convierte HEIC a JPG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC a PNG",
|
||||
description: "Convierte HEIC a PNG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC a AVIF",
|
||||
description: "Convierte HEIC a AVIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG a GIF",
|
||||
description: "Convierte JPG a GIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG a GIF",
|
||||
description: "Convierte PNG a GIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF a JPG",
|
||||
description: "Convierte GIF a JPG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF a PNG",
|
||||
description: "Convierte GIF a PNG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP a GIF",
|
||||
description: "Convierte WebP a GIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG a TIFF",
|
||||
description: "Convierte JPG a TIFF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG a TIFF",
|
||||
description: "Convierte PNG a TIFF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF a JPG",
|
||||
description: "Convierte TIFF a JPG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF a PNG",
|
||||
description: "Convierte TIFF a PNG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD a JPG",
|
||||
description: "Convierte PSD a JPG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD a PNG",
|
||||
description: "Convierte PSD a PNG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG a EPS",
|
||||
description: "Convierte PNG a EPS en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG a EPS",
|
||||
description: "Convierte JPG a EPS en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS a PNG",
|
||||
description: "Convierte EPS a PNG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS a JPG",
|
||||
description: "Convierte EPS a JPG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG a SVG",
|
||||
description: "Convierte PNG a SVG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG a SVG",
|
||||
description: "Convierte JPG a SVG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF a SVG",
|
||||
description: "Convierte TIFF a SVG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD a SVG",
|
||||
description: "Convierte PSD a SVG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS a SVG",
|
||||
description: "Convierte EPS a SVG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG a PNG",
|
||||
description: "Convierte SVG a PNG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG a JPG",
|
||||
description: "Convierte SVG a JPG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG a PDF",
|
||||
description: "Convierte JPG a PDF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG a PDF",
|
||||
description: "Convierte PNG a PDF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC a PDF",
|
||||
description: "Convierte HEIC a PDF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF a PDF",
|
||||
description: "Convierte TIFF a PDF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP a PDF",
|
||||
description: "Convierte WebP a PDF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF a PDF",
|
||||
description: "Convierte GIF a PDF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS a PDF",
|
||||
description: "Convierte EPS a PDF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF a JPG",
|
||||
description: "Convierte PDF a JPG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF a PNG",
|
||||
description: "Convierte PDF a PNG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF a TIFF",
|
||||
description: "Convierte PDF a TIFF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV a MP4",
|
||||
description: "Convierte MOV a MP4 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM a MP4",
|
||||
description: "Convierte WEBM a MP4 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV a MP4",
|
||||
description: "Convierte MKV a MP4 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI a MP4",
|
||||
description: "Convierte AVI a MP4 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 a MOV",
|
||||
description: "Convierte MP4 a MOV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 a WEBM",
|
||||
description: "Convierte MP4 a WEBM en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM a MOV",
|
||||
description: "Convierte WEBM a MOV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV a MOV",
|
||||
description: "Convierte MKV a MOV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI a MOV",
|
||||
description: "Convierte AVI a MOV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 a AVI",
|
||||
description: "Convierte MP4 a AVI en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV a AVI",
|
||||
description: "Convierte MOV a AVI en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV a AVI",
|
||||
description: "Convierte MKV a AVI en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI a MKV",
|
||||
description: "Convierte AVI a MKV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 a GIF",
|
||||
description: "Convierte MP4 a GIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV a GIF",
|
||||
description: "Convierte MOV a GIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV a GIF",
|
||||
description: "Convierte MKV a GIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI a GIF",
|
||||
description: "Convierte AVI a GIF en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF a MP4",
|
||||
description: "Convierte GIF a MP4 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF a WEBM",
|
||||
description: "Convierte GIF a WEBM en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF a MOV",
|
||||
description: "Convierte GIF a MOV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 a MP3",
|
||||
description: "Convierte MP4 a MP3 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV a MP3",
|
||||
description: "Convierte MOV a MP3 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV a MP3",
|
||||
description: "Convierte MKV a MP3 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM a MP3",
|
||||
description: "Convierte WEBM a MP3 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI a MP3",
|
||||
description: "Convierte AVI a MP3 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 a WAV",
|
||||
description: "Convierte MP4 a WAV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV a WAV",
|
||||
description: "Convierte MOV a WAV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 a OGG",
|
||||
description: "Convierte MP4 a OGG en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A a MP3",
|
||||
description: "Convierte M4A a MP3 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A a WAV",
|
||||
description: "Convierte M4A a WAV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC a MP3",
|
||||
description: "Convierte AAC a MP3 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC a WAV",
|
||||
description: "Convierte AAC a WAV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC a FLAC",
|
||||
description: "Convierte AAC a FLAC en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG a MP3",
|
||||
description: "Convierte OGG a MP3 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG a WAV",
|
||||
description: "Convierte OGG a WAV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV a MP3",
|
||||
description: "Convierte WAV a MP3 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 a WAV",
|
||||
description: "Convierte MP3 a WAV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC a MP3",
|
||||
description: "Convierte FLAC a MP3 en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel a CSV",
|
||||
description: "Convierte Excel a CSV en línea. Rápido, gratis y privado.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Redimensionar",
|
||||
description: "Redimensiona por píxeles, porcentaje o presets de redes sociales",
|
||||
@@ -140,7 +474,7 @@ export const es: TranslationKeys = {
|
||||
description: "Elimina objetos no deseados con AI",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / Extracción de texto",
|
||||
name: "Extraer texto de imagen (OCR)",
|
||||
description: "Extrae texto de imágenes con precisión potenciada por AI",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2743,6 +3077,14 @@ export const es: TranslationKeys = {
|
||||
"Descarga un ZIP de diagnóstico con configuración anonimizada, registros y datos de trabajos para solución de problemas.",
|
||||
supportBundleFailed: "Error al descargar el paquete de soporte.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Privacidad",
|
||||
description:
|
||||
"Las analíticas de producto anónimas ayudan a mejorar SnapOtter. Están activadas de forma predeterminada y un administrador las configura para toda la instancia.",
|
||||
analyticsLabel: "Analíticas de producto anónimas",
|
||||
analyticsDescription:
|
||||
"Comparte datos de uso anónimos (qué herramientas se ejecutan, duraciones, códigos de error) e informes de fallos. Sin dirección IP ni identidad de cuenta. Nunca recopilamos los nombres de tus archivos, su contenido, los resultados de OCR, los metadatos de imágenes ni el texto de los documentos. Desactiva esto para detenerlo todo.",
|
||||
},
|
||||
security: {
|
||||
heading: "Seguridad",
|
||||
description: "Configuración de contraseña y autenticación.",
|
||||
@@ -3041,7 +3383,7 @@ export const es: TranslationKeys = {
|
||||
heading: "Acerca de",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"Una suite de procesamiento de archivos autoalojada y con privacidad como prioridad, con 157 herramientas. Convierte, comprime, edita, transcribe, OCR y automatiza flujos de trabajo de imágenes, video, audio, documentos y datos sin enviar datos a la nube.",
|
||||
"Una suite de procesamiento de archivos autoalojada y con privacidad como prioridad, con 240 herramientas. Convierte, comprime, edita, transcribe, OCR y automatiza flujos de trabajo de imágenes, video, audio, documentos y datos sin enviar datos a la nube.",
|
||||
versionLabel: "Versión:",
|
||||
linksHeading: "Enlaces",
|
||||
githubLink: "Repositorio en GitHub",
|
||||
@@ -3102,7 +3444,7 @@ export const es: TranslationKeys = {
|
||||
"Sin límites. Sin topes ocultos.",
|
||||
"Funciona totalmente sin conexión.",
|
||||
"Procesamiento por lotes ilimitado.",
|
||||
"157 herramientas.",
|
||||
"240 herramientas.",
|
||||
"15 modelos AI. Tu hardware.",
|
||||
"Ultra rápido. Construido sobre Sharp.",
|
||||
"Listo para redes aisladas.",
|
||||
@@ -3166,6 +3508,7 @@ export const es: TranslationKeys = {
|
||||
toolPalette: "Paleta de herramientas",
|
||||
clickToAdd: "Clic para agregar al Pipeline",
|
||||
savedLabel: "Guardado",
|
||||
templatesLabel: "Plantillas",
|
||||
importButton: "Importar",
|
||||
noSavedPipelines: "No hay Pipelines guardados",
|
||||
stepCount: "({count} paso)",
|
||||
@@ -3453,4 +3796,45 @@ export const es: TranslationKeys = {
|
||||
navigationMenu: "Navegación",
|
||||
homeLink: "Inicio de SnapOtter",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Optimizar para web",
|
||||
description:
|
||||
"Redimensiona, convierte a WebP y comprime una imagen para una carga web rápida.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "Apto para correo",
|
||||
description:
|
||||
"Reduce una imagen para que sea lo bastante pequeña para adjuntarla a un correo.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Limpieza de privacidad",
|
||||
description: "Elimina los datos EXIF, GPS y otros metadatos, y luego comprime.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Cuadrado social",
|
||||
description: "Recorta a un cuadrado 1:1 y comprime para publicaciones en redes sociales.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Marca de agua por lotes",
|
||||
description: "Estampa una marca de agua de texto en las imágenes y luego comprime.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "Reducir PDF para correo",
|
||||
description:
|
||||
"Aplana y comprime de forma agresiva un PDF para que cumpla los límites del correo.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Pulido de pódcast",
|
||||
description: "Elimina los silencios, normaliza el volumen y añade fundidos a una grabación.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Recortar el sujeto",
|
||||
description: "Quita el fondo con IA y comprime el recorte transparente.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Restaurar foto antigua",
|
||||
description: "Repara, colorea y amplía una foto antigua o dañada con IA.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const fr: TranslationKeys = {
|
||||
documentsAndFiles: "PDF & fichiers",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG en PNG",
|
||||
description: "Convertir JPG en PNG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG en JPG",
|
||||
description: "Convertir PNG en JPG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG en WebP",
|
||||
description: "Convertir JPG en WebP en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG en WebP",
|
||||
description: "Convertir PNG en WebP en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP en JPG",
|
||||
description: "Convertir WebP en JPG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP en PNG",
|
||||
description: "Convertir WebP en PNG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG en AVIF",
|
||||
description: "Convertir JPG en AVIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG en AVIF",
|
||||
description: "Convertir PNG en AVIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP en AVIF",
|
||||
description: "Convertir WebP en AVIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC en JPG",
|
||||
description: "Convertir HEIC en JPG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC en PNG",
|
||||
description: "Convertir HEIC en PNG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC en AVIF",
|
||||
description: "Convertir HEIC en AVIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG en GIF",
|
||||
description: "Convertir JPG en GIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG en GIF",
|
||||
description: "Convertir PNG en GIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF en JPG",
|
||||
description: "Convertir GIF en JPG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF en PNG",
|
||||
description: "Convertir GIF en PNG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP en GIF",
|
||||
description: "Convertir WebP en GIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG en TIFF",
|
||||
description: "Convertir JPG en TIFF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG en TIFF",
|
||||
description: "Convertir PNG en TIFF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF en JPG",
|
||||
description: "Convertir TIFF en JPG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF en PNG",
|
||||
description: "Convertir TIFF en PNG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD en JPG",
|
||||
description: "Convertir PSD en JPG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD en PNG",
|
||||
description: "Convertir PSD en PNG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG en EPS",
|
||||
description: "Convertir PNG en EPS en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG en EPS",
|
||||
description: "Convertir JPG en EPS en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS en PNG",
|
||||
description: "Convertir EPS en PNG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS en JPG",
|
||||
description: "Convertir EPS en JPG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG en SVG",
|
||||
description: "Convertir PNG en SVG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG en SVG",
|
||||
description: "Convertir JPG en SVG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF en SVG",
|
||||
description: "Convertir TIFF en SVG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD en SVG",
|
||||
description: "Convertir PSD en SVG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS en SVG",
|
||||
description: "Convertir EPS en SVG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG en PNG",
|
||||
description: "Convertir SVG en PNG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG en JPG",
|
||||
description: "Convertir SVG en JPG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG en PDF",
|
||||
description: "Convertir JPG en PDF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG en PDF",
|
||||
description: "Convertir PNG en PDF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC en PDF",
|
||||
description: "Convertir HEIC en PDF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF en PDF",
|
||||
description: "Convertir TIFF en PDF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP en PDF",
|
||||
description: "Convertir WebP en PDF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF en PDF",
|
||||
description: "Convertir GIF en PDF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS en PDF",
|
||||
description: "Convertir EPS en PDF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF en JPG",
|
||||
description: "Convertir PDF en JPG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF en PNG",
|
||||
description: "Convertir PDF en PNG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF en TIFF",
|
||||
description: "Convertir PDF en TIFF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV en MP4",
|
||||
description: "Convertir MOV en MP4 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM en MP4",
|
||||
description: "Convertir WEBM en MP4 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV en MP4",
|
||||
description: "Convertir MKV en MP4 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI en MP4",
|
||||
description: "Convertir AVI en MP4 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 en MOV",
|
||||
description: "Convertir MP4 en MOV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 en WEBM",
|
||||
description: "Convertir MP4 en WEBM en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM en MOV",
|
||||
description: "Convertir WEBM en MOV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV en MOV",
|
||||
description: "Convertir MKV en MOV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI en MOV",
|
||||
description: "Convertir AVI en MOV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 en AVI",
|
||||
description: "Convertir MP4 en AVI en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV en AVI",
|
||||
description: "Convertir MOV en AVI en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV en AVI",
|
||||
description: "Convertir MKV en AVI en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI en MKV",
|
||||
description: "Convertir AVI en MKV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 en GIF",
|
||||
description: "Convertir MP4 en GIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV en GIF",
|
||||
description: "Convertir MOV en GIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV en GIF",
|
||||
description: "Convertir MKV en GIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI en GIF",
|
||||
description: "Convertir AVI en GIF en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF en MP4",
|
||||
description: "Convertir GIF en MP4 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF en WEBM",
|
||||
description: "Convertir GIF en WEBM en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF en MOV",
|
||||
description: "Convertir GIF en MOV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 en MP3",
|
||||
description: "Convertir MP4 en MP3 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV en MP3",
|
||||
description: "Convertir MOV en MP3 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV en MP3",
|
||||
description: "Convertir MKV en MP3 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM en MP3",
|
||||
description: "Convertir WEBM en MP3 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI en MP3",
|
||||
description: "Convertir AVI en MP3 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 en WAV",
|
||||
description: "Convertir MP4 en WAV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV en WAV",
|
||||
description: "Convertir MOV en WAV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 en OGG",
|
||||
description: "Convertir MP4 en OGG en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A en MP3",
|
||||
description: "Convertir M4A en MP3 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A en WAV",
|
||||
description: "Convertir M4A en WAV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC en MP3",
|
||||
description: "Convertir AAC en MP3 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC en WAV",
|
||||
description: "Convertir AAC en WAV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC en FLAC",
|
||||
description: "Convertir AAC en FLAC en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG en MP3",
|
||||
description: "Convertir OGG en MP3 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG en WAV",
|
||||
description: "Convertir OGG en WAV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV en MP3",
|
||||
description: "Convertir WAV en MP3 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 en WAV",
|
||||
description: "Convertir MP3 en WAV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC en MP3",
|
||||
description: "Convertir FLAC en MP3 en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel en CSV",
|
||||
description: "Convertir Excel en CSV en ligne. Rapide, gratuit et privé.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Redimensionner",
|
||||
description: "Redimensionnez par pixels, pourcentage ou presets pour réseaux sociaux",
|
||||
@@ -149,7 +483,7 @@ export const fr: TranslationKeys = {
|
||||
description: "Supprimez les objets indésirables avec l'AI",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / Extraction de texte",
|
||||
name: "Extraire le texte d'une image (OCR)",
|
||||
description: "Extrayez du texte à partir d'images avec une précision alimentée par l'AI",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2766,6 +3100,14 @@ export const fr: TranslationKeys = {
|
||||
"Téléchargez une archive de diagnostic avec la configuration masquée, les journaux et les données de tâches.",
|
||||
supportBundleFailed: "Échec du téléchargement du diagnostic.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Confidentialité",
|
||||
description:
|
||||
"Les statistiques produit anonymes aident à améliorer SnapOtter. Elles sont activées par défaut et configurées pour toute l'instance par un administrateur.",
|
||||
analyticsLabel: "Statistiques produit anonymes",
|
||||
analyticsDescription:
|
||||
"Partagez des données d'usage anonymes (quels outils sont exécutés, durées, codes d'erreur) et des rapports de plantage. Aucune adresse IP, aucune identité de compte. Nous ne collectons jamais vos noms de fichiers, le contenu des fichiers, les résultats d'OCR, les métadonnées d'images ni le texte des documents. Désactivez cette option pour tout arrêter.",
|
||||
},
|
||||
security: {
|
||||
heading: "Sécurité",
|
||||
description: "Paramètres de mot de passe et d'authentification.",
|
||||
@@ -3064,7 +3406,7 @@ export const fr: TranslationKeys = {
|
||||
heading: "À propos",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"Une suite de traitement de fichiers auto-hébergée, axée sur la confidentialité, avec 157 outils. Convertissez, compressez, éditez, transcrivez, OCR et automatisez vos flux de travail image, vidéo, audio, document et données sans envoyer de données vers le cloud.",
|
||||
"Une suite de traitement de fichiers auto-hébergée, axée sur la confidentialité, avec 240 outils. Convertissez, compressez, éditez, transcrivez, OCR et automatisez vos flux de travail image, vidéo, audio, document et données sans envoyer de données vers le cloud.",
|
||||
versionLabel: "Version :",
|
||||
linksHeading: "Liens",
|
||||
githubLink: "Dépôt GitHub",
|
||||
@@ -3125,7 +3467,7 @@ export const fr: TranslationKeys = {
|
||||
"Sans limites. Sans plafonds cachés.",
|
||||
"Fonctionne entièrement hors ligne.",
|
||||
"Traitement par lot illimité.",
|
||||
"157 outils.",
|
||||
"240 outils.",
|
||||
"15 modèles AI. Votre matériel.",
|
||||
"Ultra rapide. Propulsé par Sharp.",
|
||||
"Prêt pour les réseaux isolés.",
|
||||
@@ -3190,6 +3532,7 @@ export const fr: TranslationKeys = {
|
||||
toolPalette: "Palette d'outils",
|
||||
clickToAdd: "Cliquez pour ajouter au Pipeline",
|
||||
savedLabel: "Enregistré",
|
||||
templatesLabel: "Modèles",
|
||||
importButton: "Importer",
|
||||
noSavedPipelines: "Aucun Pipeline enregistré",
|
||||
stepCount: "({count} étape)",
|
||||
@@ -3476,4 +3819,45 @@ export const fr: TranslationKeys = {
|
||||
navigationMenu: "Navigation",
|
||||
homeLink: "Accueil SnapOtter",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Optimiser pour le web",
|
||||
description:
|
||||
"Redimensionne, convertit en WebP et compresse une image pour un chargement web rapide.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "Adapté aux e-mails",
|
||||
description: "Réduit une image pour qu'elle soit assez petite pour être jointe à un e-mail.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Nettoyage de confidentialité",
|
||||
description: "Supprime les métadonnées EXIF, GPS et autres, puis compresse.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Carré pour réseaux sociaux",
|
||||
description:
|
||||
"Recadre au format carré 1:1 et compresse pour les publications sur les réseaux sociaux.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Filigrane par lot",
|
||||
description: "Appose un filigrane texte sur les images, puis compresse.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "Réduire un PDF pour e-mail",
|
||||
description: "Aplatit et compresse fortement un PDF pour respecter les limites des e-mails.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Peaufiner un podcast",
|
||||
description:
|
||||
"Supprime les silences, normalise le volume et ajoute des fondus à un enregistrement.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Détourer le sujet",
|
||||
description: "Supprime l'arrière-plan avec l'IA et compresse le détourage transparent.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Restaurer une vieille photo",
|
||||
description: "Répare, colorise et agrandit une photo ancienne ou abîmée avec l'IA.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -73,6 +73,175 @@ export const hi: TranslationKeys = {
|
||||
documentsAndFiles: "PDF और फ़ाइलें",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": { name: "JPG से PNG", description: "JPG को PNG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"png-to-jpg": { name: "PNG से JPG", description: "PNG को JPG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"jpg-to-webp": {
|
||||
name: "JPG से WebP",
|
||||
description: "JPG को WebP में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG से WebP",
|
||||
description: "PNG को WebP में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP से JPG",
|
||||
description: "WebP को JPG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP से PNG",
|
||||
description: "WebP को PNG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG से AVIF",
|
||||
description: "JPG को AVIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG से AVIF",
|
||||
description: "PNG को AVIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP से AVIF",
|
||||
description: "WebP को AVIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC से JPG",
|
||||
description: "HEIC को JPG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC से PNG",
|
||||
description: "HEIC को PNG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC से AVIF",
|
||||
description: "HEIC को AVIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"jpg-to-gif": { name: "JPG से GIF", description: "JPG को GIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"png-to-gif": { name: "PNG से GIF", description: "PNG को GIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"gif-to-jpg": { name: "GIF से JPG", description: "GIF को JPG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"gif-to-png": { name: "GIF से PNG", description: "GIF को PNG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"webp-to-gif": {
|
||||
name: "WebP से GIF",
|
||||
description: "WebP को GIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG से TIFF",
|
||||
description: "JPG को TIFF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG से TIFF",
|
||||
description: "PNG को TIFF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF से JPG",
|
||||
description: "TIFF को JPG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF से PNG",
|
||||
description: "TIFF को PNG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"psd-to-jpg": { name: "PSD से JPG", description: "PSD को JPG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"psd-to-png": { name: "PSD से PNG", description: "PSD को PNG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"png-to-eps": { name: "PNG से EPS", description: "PNG को EPS में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"jpg-to-eps": { name: "JPG से EPS", description: "JPG को EPS में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"eps-to-png": { name: "EPS से PNG", description: "EPS को PNG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"eps-to-jpg": { name: "EPS से JPG", description: "EPS को JPG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"png-to-svg": { name: "PNG से SVG", description: "PNG को SVG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"jpg-to-svg": { name: "JPG से SVG", description: "JPG को SVG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF से SVG",
|
||||
description: "TIFF को SVG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"psd-to-svg": { name: "PSD से SVG", description: "PSD को SVG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"eps-to-svg": { name: "EPS से SVG", description: "EPS को SVG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"svg-to-png": { name: "SVG से PNG", description: "SVG को PNG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"svg-to-jpg": { name: "SVG से JPG", description: "SVG को JPG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"jpg-to-pdf": { name: "JPG से PDF", description: "JPG को PDF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"png-to-pdf": { name: "PNG से PDF", description: "PNG को PDF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC से PDF",
|
||||
description: "HEIC को PDF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF से PDF",
|
||||
description: "TIFF को PDF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP से PDF",
|
||||
description: "WebP को PDF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"gif-to-pdf": { name: "GIF से PDF", description: "GIF को PDF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"eps-to-pdf": { name: "EPS से PDF", description: "EPS को PDF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"pdf-to-jpg": { name: "PDF से JPG", description: "PDF को JPG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"pdf-to-png": { name: "PDF से PNG", description: "PDF को PNG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF से TIFF",
|
||||
description: "PDF को TIFF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"mov-to-mp4": { name: "MOV से MP4", description: "MOV को MP4 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM से MP4",
|
||||
description: "WEBM को MP4 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"mkv-to-mp4": { name: "MKV से MP4", description: "MKV को MP4 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"avi-to-mp4": { name: "AVI से MP4", description: "AVI को MP4 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mp4-to-mov": { name: "MP4 से MOV", description: "MP4 को MOV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 से WEBM",
|
||||
description: "MP4 को WEBM में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM से MOV",
|
||||
description: "WEBM को MOV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"mkv-to-mov": { name: "MKV से MOV", description: "MKV को MOV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"avi-to-mov": { name: "AVI से MOV", description: "AVI को MOV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mp4-to-avi": { name: "MP4 से AVI", description: "MP4 को AVI में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mov-to-avi": { name: "MOV से AVI", description: "MOV को AVI में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mkv-to-avi": { name: "MKV से AVI", description: "MKV को AVI में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"avi-to-mkv": { name: "AVI से MKV", description: "AVI को MKV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mp4-to-gif": { name: "MP4 से GIF", description: "MP4 को GIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mov-to-gif": { name: "MOV से GIF", description: "MOV को GIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mkv-to-gif": { name: "MKV से GIF", description: "MKV को GIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"avi-to-gif": { name: "AVI से GIF", description: "AVI को GIF में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"gif-to-mp4": { name: "GIF से MP4", description: "GIF को MP4 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"gif-to-webm": {
|
||||
name: "GIF से WEBM",
|
||||
description: "GIF को WEBM में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"gif-to-mov": { name: "GIF से MOV", description: "GIF को MOV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mp4-to-mp3": { name: "MP4 से MP3", description: "MP4 को MP3 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mov-to-mp3": { name: "MOV से MP3", description: "MOV को MP3 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mkv-to-mp3": { name: "MKV से MP3", description: "MKV को MP3 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM से MP3",
|
||||
description: "WEBM को MP3 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"avi-to-mp3": { name: "AVI से MP3", description: "AVI को MP3 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mp4-to-wav": { name: "MP4 से WAV", description: "MP4 को WAV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mov-to-wav": { name: "MOV से WAV", description: "MOV को WAV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mp4-to-ogg": { name: "MP4 से OGG", description: "MP4 को OGG में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"m4a-to-mp3": { name: "M4A से MP3", description: "M4A को MP3 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"m4a-to-wav": { name: "M4A से WAV", description: "M4A को WAV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"aac-to-mp3": { name: "AAC से MP3", description: "AAC को MP3 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"aac-to-wav": { name: "AAC से WAV", description: "AAC को WAV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"aac-to-flac": {
|
||||
name: "AAC से FLAC",
|
||||
description: "AAC को FLAC में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"ogg-to-mp3": { name: "OGG से MP3", description: "OGG को MP3 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"ogg-to-wav": { name: "OGG से WAV", description: "OGG को WAV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"wav-to-mp3": { name: "WAV से MP3", description: "WAV को MP3 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"mp3-to-wav": { name: "MP3 से WAV", description: "MP3 को WAV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।" },
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC से MP3",
|
||||
description: "FLAC को MP3 में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel से CSV",
|
||||
description: "Excel को CSV में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "रीसाइज़",
|
||||
description: "पिक्सेल, प्रतिशत, या सोशल मीडिया प्रीसेट से रीसाइज़ करें",
|
||||
@@ -150,7 +319,7 @@ export const hi: TranslationKeys = {
|
||||
description: "AI से अनचाही वस्तुएं हटाएं",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / टेक्स्ट एक्सट्रैक्शन",
|
||||
name: "छवि से टेक्स्ट निकालें (OCR)",
|
||||
description: "AI-संचालित सटीकता से इमेज से टेक्स्ट निकालें",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2736,6 +2905,14 @@ export const hi: TranslationKeys = {
|
||||
"समस्या निवारण के लिए संशोधित कॉन्फ़िग, लॉग और जॉब डेटा वाली डायग्नोस्टिक ज़िप डाउनलोड करें।",
|
||||
supportBundleFailed: "सपोर्ट बंडल डाउनलोड विफल।",
|
||||
},
|
||||
privacy: {
|
||||
title: "गोपनीयता",
|
||||
description:
|
||||
"अनाम उत्पाद एनालिटिक्स SnapOtter को बेहतर बनाने में मदद करता है। यह डिफ़ॉल्ट रूप से चालू रहता है और पूरे इंस्टेंस के लिए इसे एक प्रशासक द्वारा सेट किया जाता है।",
|
||||
analyticsLabel: "अनाम उत्पाद एनालिटिक्स",
|
||||
analyticsDescription:
|
||||
"अनाम उपयोग डेटा (कौन-से टूल चलते हैं, अवधि, त्रुटि कोड) और क्रैश रिपोर्ट साझा करें। कोई IP पता नहीं, कोई खाता पहचान नहीं। हम आपकी फ़ाइलों के नाम, फ़ाइल सामग्री, OCR आउटपुट, छवि मेटाडेटा या दस्तावेज़ टेक्स्ट कभी एकत्र नहीं करते। यह सब रोकने के लिए इसे बंद कर दें।",
|
||||
},
|
||||
security: {
|
||||
heading: "सुरक्षा",
|
||||
description: "पासवर्ड और ऑथेंटिकेशन सेटिंग्स।",
|
||||
@@ -3019,7 +3196,7 @@ export const hi: TranslationKeys = {
|
||||
heading: "जानकारी",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"157 टूल्स के साथ गोपनीयता-पहले, सेल्फ-होस्टेड फ़ाइल प्रोसेसिंग सूट। क्लाउड को डेटा भेजे बिना कन्वर्ट, कंप्रेस, एडिट, ट्रांसक्राइब, OCR और इमेज, वीडियो, ऑडियो, डॉक्यूमेंट और डेटा वर्कफ्लो ऑटोमेट करें।",
|
||||
"240 टूल्स के साथ गोपनीयता-पहले, सेल्फ-होस्टेड फ़ाइल प्रोसेसिंग सूट। क्लाउड को डेटा भेजे बिना कन्वर्ट, कंप्रेस, एडिट, ट्रांसक्राइब, OCR और इमेज, वीडियो, ऑडियो, डॉक्यूमेंट और डेटा वर्कफ्लो ऑटोमेट करें।",
|
||||
versionLabel: "वर्शन:",
|
||||
linksHeading: "लिंक्स",
|
||||
githubLink: "GitHub रिपॉज़िटरी",
|
||||
@@ -3077,7 +3254,7 @@ export const hi: TranslationKeys = {
|
||||
"कोई सीमा नहीं। कोई छुपी हुई कैप नहीं।",
|
||||
"पूरी तरह ऑफलाइन काम करता है।",
|
||||
"असीमित बैच प्रोसेसिंग।",
|
||||
"157 टूल्स।",
|
||||
"240 टूल्स।",
|
||||
"16 AI मॉडल। आपका हार्डवेयर।",
|
||||
"बिजली की तरह तेज़। Sharp पर बनाया गया।",
|
||||
"एयर-गैप्ड नेटवर्क के लिए तैयार।",
|
||||
@@ -3141,6 +3318,7 @@ export const hi: TranslationKeys = {
|
||||
toolPalette: "टूल पैलेट",
|
||||
clickToAdd: "Pipeline में जोड़ने के लिए क्लिक करें",
|
||||
savedLabel: "सहेजा गया",
|
||||
templatesLabel: "टेम्पलेट",
|
||||
importButton: "इम्पोर्ट",
|
||||
noSavedPipelines: "कोई सहेजा गया Pipeline नहीं",
|
||||
stepCount: "({count} स्टेप)",
|
||||
@@ -3425,4 +3603,42 @@ export const hi: TranslationKeys = {
|
||||
navigationMenu: "नेविगेशन",
|
||||
homeLink: "SnapOtter होम",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "वेब के लिए अनुकूलित करें",
|
||||
description: "तेज़ वेब लोडिंग के लिए छवि का आकार बदलें, उसे WebP में बदलें और संपीड़ित करें।",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "ईमेल के अनुकूल",
|
||||
description: "छवि को इतना छोटा करें कि उसे ईमेल में अटैच किया जा सके।",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "गोपनीयता सफ़ाई",
|
||||
description: "EXIF, GPS और अन्य मेटाडेटा हटाएँ, फिर संपीड़ित करें।",
|
||||
},
|
||||
"social-square": {
|
||||
name: "सोशल वर्ग",
|
||||
description: "1:1 वर्ग में क्रॉप करें और सोशल पोस्ट के लिए संपीड़ित करें।",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "वॉटरमार्क बैच",
|
||||
description: "छवियों पर टेक्स्ट वॉटरमार्क लगाएँ, फिर संपीड़ित करें।",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "ईमेल के लिए PDF छोटा करें",
|
||||
description: "PDF को समतल करें और ईमेल सीमा में फ़िट करने के लिए ज़ोरदार ढंग से संपीड़ित करें।",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "पॉडकास्ट निखार",
|
||||
description: "रिकॉर्डिंग से ख़ामोशी हटाएँ, ध्वनि स्तर सामान्य करें और फ़ेड जोड़ें।",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "विषय काटें",
|
||||
description: "एआई से पृष्ठभूमि हटाएँ और पारदर्शी कटआउट को संपीड़ित करें।",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "पुरानी फ़ोटो पुनर्स्थापित करें",
|
||||
description: "एआई से पुरानी या क्षतिग्रस्त फ़ोटो की मरम्मत करें, रंग भरें और बड़ा करें।",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const id: TranslationKeys = {
|
||||
documentsAndFiles: "PDF & File",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG ke PNG",
|
||||
description: "Konversi JPG ke PNG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG ke JPG",
|
||||
description: "Konversi PNG ke JPG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG ke WebP",
|
||||
description: "Konversi JPG ke WebP online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG ke WebP",
|
||||
description: "Konversi PNG ke WebP online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP ke JPG",
|
||||
description: "Konversi WebP ke JPG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP ke PNG",
|
||||
description: "Konversi WebP ke PNG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG ke AVIF",
|
||||
description: "Konversi JPG ke AVIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG ke AVIF",
|
||||
description: "Konversi PNG ke AVIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP ke AVIF",
|
||||
description: "Konversi WebP ke AVIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC ke JPG",
|
||||
description: "Konversi HEIC ke JPG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC ke PNG",
|
||||
description: "Konversi HEIC ke PNG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC ke AVIF",
|
||||
description: "Konversi HEIC ke AVIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG ke GIF",
|
||||
description: "Konversi JPG ke GIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG ke GIF",
|
||||
description: "Konversi PNG ke GIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF ke JPG",
|
||||
description: "Konversi GIF ke JPG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF ke PNG",
|
||||
description: "Konversi GIF ke PNG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP ke GIF",
|
||||
description: "Konversi WebP ke GIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG ke TIFF",
|
||||
description: "Konversi JPG ke TIFF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG ke TIFF",
|
||||
description: "Konversi PNG ke TIFF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF ke JPG",
|
||||
description: "Konversi TIFF ke JPG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF ke PNG",
|
||||
description: "Konversi TIFF ke PNG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD ke JPG",
|
||||
description: "Konversi PSD ke JPG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD ke PNG",
|
||||
description: "Konversi PSD ke PNG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG ke EPS",
|
||||
description: "Konversi PNG ke EPS online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG ke EPS",
|
||||
description: "Konversi JPG ke EPS online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS ke PNG",
|
||||
description: "Konversi EPS ke PNG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS ke JPG",
|
||||
description: "Konversi EPS ke JPG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG ke SVG",
|
||||
description: "Konversi PNG ke SVG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG ke SVG",
|
||||
description: "Konversi JPG ke SVG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF ke SVG",
|
||||
description: "Konversi TIFF ke SVG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD ke SVG",
|
||||
description: "Konversi PSD ke SVG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS ke SVG",
|
||||
description: "Konversi EPS ke SVG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG ke PNG",
|
||||
description: "Konversi SVG ke PNG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG ke JPG",
|
||||
description: "Konversi SVG ke JPG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG ke PDF",
|
||||
description: "Konversi JPG ke PDF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG ke PDF",
|
||||
description: "Konversi PNG ke PDF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC ke PDF",
|
||||
description: "Konversi HEIC ke PDF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF ke PDF",
|
||||
description: "Konversi TIFF ke PDF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP ke PDF",
|
||||
description: "Konversi WebP ke PDF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF ke PDF",
|
||||
description: "Konversi GIF ke PDF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS ke PDF",
|
||||
description: "Konversi EPS ke PDF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF ke JPG",
|
||||
description: "Konversi PDF ke JPG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF ke PNG",
|
||||
description: "Konversi PDF ke PNG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF ke TIFF",
|
||||
description: "Konversi PDF ke TIFF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV ke MP4",
|
||||
description: "Konversi MOV ke MP4 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM ke MP4",
|
||||
description: "Konversi WEBM ke MP4 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV ke MP4",
|
||||
description: "Konversi MKV ke MP4 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI ke MP4",
|
||||
description: "Konversi AVI ke MP4 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 ke MOV",
|
||||
description: "Konversi MP4 ke MOV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 ke WEBM",
|
||||
description: "Konversi MP4 ke WEBM online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM ke MOV",
|
||||
description: "Konversi WEBM ke MOV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV ke MOV",
|
||||
description: "Konversi MKV ke MOV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI ke MOV",
|
||||
description: "Konversi AVI ke MOV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 ke AVI",
|
||||
description: "Konversi MP4 ke AVI online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV ke AVI",
|
||||
description: "Konversi MOV ke AVI online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV ke AVI",
|
||||
description: "Konversi MKV ke AVI online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI ke MKV",
|
||||
description: "Konversi AVI ke MKV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 ke GIF",
|
||||
description: "Konversi MP4 ke GIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV ke GIF",
|
||||
description: "Konversi MOV ke GIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV ke GIF",
|
||||
description: "Konversi MKV ke GIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI ke GIF",
|
||||
description: "Konversi AVI ke GIF online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF ke MP4",
|
||||
description: "Konversi GIF ke MP4 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF ke WEBM",
|
||||
description: "Konversi GIF ke WEBM online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF ke MOV",
|
||||
description: "Konversi GIF ke MOV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 ke MP3",
|
||||
description: "Konversi MP4 ke MP3 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV ke MP3",
|
||||
description: "Konversi MOV ke MP3 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV ke MP3",
|
||||
description: "Konversi MKV ke MP3 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM ke MP3",
|
||||
description: "Konversi WEBM ke MP3 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI ke MP3",
|
||||
description: "Konversi AVI ke MP3 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 ke WAV",
|
||||
description: "Konversi MP4 ke WAV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV ke WAV",
|
||||
description: "Konversi MOV ke WAV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 ke OGG",
|
||||
description: "Konversi MP4 ke OGG online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A ke MP3",
|
||||
description: "Konversi M4A ke MP3 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A ke WAV",
|
||||
description: "Konversi M4A ke WAV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC ke MP3",
|
||||
description: "Konversi AAC ke MP3 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC ke WAV",
|
||||
description: "Konversi AAC ke WAV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC ke FLAC",
|
||||
description: "Konversi AAC ke FLAC online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG ke MP3",
|
||||
description: "Konversi OGG ke MP3 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG ke WAV",
|
||||
description: "Konversi OGG ke WAV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV ke MP3",
|
||||
description: "Konversi WAV ke MP3 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 ke WAV",
|
||||
description: "Konversi MP3 ke WAV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC ke MP3",
|
||||
description: "Konversi FLAC ke MP3 online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel ke CSV",
|
||||
description: "Konversi Excel ke CSV online. Cepat, gratis, dan privat.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Ubah Ukuran",
|
||||
description: "Ubah ukuran berdasarkan piksel, persentase, atau preset media sosial",
|
||||
@@ -151,7 +485,7 @@ export const id: TranslationKeys = {
|
||||
description: "Hapus objek yang tidak diinginkan dengan AI",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / Ekstraksi Teks",
|
||||
name: "Ekstrak Teks dari Gambar (OCR)",
|
||||
description: "Ekstrak teks dari gambar dengan akurasi AI",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2750,6 +3084,14 @@ export const id: TranslationKeys = {
|
||||
"Unduh ZIP diagnostik berisi konfigurasi yang disunting, log, dan data pekerjaan untuk pemecahan masalah.",
|
||||
supportBundleFailed: "Gagal mengunduh paket dukungan.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Privasi",
|
||||
description:
|
||||
"Analitik Produk Anonim membantu meningkatkan SnapOtter. Fitur ini aktif secara default dan diatur untuk seluruh instance oleh administrator.",
|
||||
analyticsLabel: "Analitik Produk Anonim",
|
||||
analyticsDescription:
|
||||
"Bagikan data penggunaan anonim (alat mana yang dijalankan, durasi, kode kesalahan) dan laporan kerusakan. Tanpa alamat IP, tanpa identitas akun. Kami tidak pernah mengumpulkan nama file, isi file, keluaran OCR, metadata gambar, atau teks dokumen Anda. Matikan ini untuk menghentikan semuanya.",
|
||||
},
|
||||
security: {
|
||||
heading: "Keamanan",
|
||||
description: "Pengaturan kata sandi dan autentikasi.",
|
||||
@@ -3043,7 +3385,7 @@ export const id: TranslationKeys = {
|
||||
heading: "Tentang",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"Suite pemrosesan file self-hosted yang mengutamakan privasi dengan 157 alat. Konversi, kompres, edit, transkrip, OCR, dan otomasi alur kerja gambar, video, audio, dokumen, dan data tanpa mengirim data ke cloud.",
|
||||
"Suite pemrosesan file self-hosted yang mengutamakan privasi dengan 240 alat. Konversi, kompres, edit, transkrip, OCR, dan otomasi alur kerja gambar, video, audio, dokumen, dan data tanpa mengirim data ke cloud.",
|
||||
versionLabel: "Versi:",
|
||||
linksHeading: "Tautan",
|
||||
githubLink: "Repositori GitHub",
|
||||
@@ -3102,7 +3444,7 @@ export const id: TranslationKeys = {
|
||||
"Tanpa batas. Tanpa batasan tersembunyi.",
|
||||
"Berfungsi sepenuhnya offline.",
|
||||
"Pemrosesan batch tanpa batas.",
|
||||
"157 alat.",
|
||||
"240 alat.",
|
||||
"15 model AI. Di perangkat Anda.",
|
||||
"Secepat kilat. Dibangun di atas Sharp.",
|
||||
"Siap untuk jaringan terisolasi.",
|
||||
@@ -3167,6 +3509,7 @@ export const id: TranslationKeys = {
|
||||
toolPalette: "Palet Alat",
|
||||
clickToAdd: "Klik untuk menambahkan ke Pipeline",
|
||||
savedLabel: "Tersimpan",
|
||||
templatesLabel: "Templat",
|
||||
importButton: "Impor",
|
||||
noSavedPipelines: "Tidak ada Pipeline tersimpan",
|
||||
stepCount: "({count} langkah)",
|
||||
@@ -3453,4 +3796,43 @@ export const id: TranslationKeys = {
|
||||
navigationMenu: "Navigasi",
|
||||
homeLink: "Beranda SnapOtter",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Optimalkan untuk web",
|
||||
description: "Ubah ukuran, konversi ke WebP, dan kompres gambar agar cepat dimuat di web.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "Ramah email",
|
||||
description: "Perkecil gambar agar cukup kecil untuk dilampirkan ke email.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Pembersihan privasi",
|
||||
description: "Hapus EXIF, GPS, dan metadata lainnya, lalu kompres.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Persegi media sosial",
|
||||
description: "Pangkas menjadi persegi 1:1 dan kompres untuk unggahan media sosial.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Tanda air massal",
|
||||
description: "Bubuhkan tanda air teks pada gambar, lalu kompres.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "Perkecil PDF untuk email",
|
||||
description: "Ratakan dan kompres PDF secara agresif agar sesuai batas email.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Pemolesan podcast",
|
||||
description:
|
||||
"Hapus jeda hening, normalkan kenyaringan, dan tambahkan transisi pudar ke rekaman.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Potong subjek",
|
||||
description: "Hapus latar belakang dengan AI dan kompres potongan transparan.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Pulihkan foto lama",
|
||||
description: "Perbaiki, beri warna, dan perbesar foto lama atau rusak dengan AI.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const it: TranslationKeys = {
|
||||
documentsAndFiles: "PDF e File",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "Da JPG a PNG",
|
||||
description: "Converti JPG in PNG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "Da PNG a JPG",
|
||||
description: "Converti PNG in JPG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "Da JPG a WebP",
|
||||
description: "Converti JPG in WebP online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "Da PNG a WebP",
|
||||
description: "Converti PNG in WebP online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "Da WebP a JPG",
|
||||
description: "Converti WebP in JPG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "Da WebP a PNG",
|
||||
description: "Converti WebP in PNG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "Da JPG a AVIF",
|
||||
description: "Converti JPG in AVIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "Da PNG a AVIF",
|
||||
description: "Converti PNG in AVIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "Da WebP a AVIF",
|
||||
description: "Converti WebP in AVIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "Da HEIC a JPG",
|
||||
description: "Converti HEIC in JPG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "Da HEIC a PNG",
|
||||
description: "Converti HEIC in PNG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "Da HEIC a AVIF",
|
||||
description: "Converti HEIC in AVIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "Da JPG a GIF",
|
||||
description: "Converti JPG in GIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "Da PNG a GIF",
|
||||
description: "Converti PNG in GIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "Da GIF a JPG",
|
||||
description: "Converti GIF in JPG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "Da GIF a PNG",
|
||||
description: "Converti GIF in PNG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "Da WebP a GIF",
|
||||
description: "Converti WebP in GIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "Da JPG a TIFF",
|
||||
description: "Converti JPG in TIFF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "Da PNG a TIFF",
|
||||
description: "Converti PNG in TIFF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "Da TIFF a JPG",
|
||||
description: "Converti TIFF in JPG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "Da TIFF a PNG",
|
||||
description: "Converti TIFF in PNG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "Da PSD a JPG",
|
||||
description: "Converti PSD in JPG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "Da PSD a PNG",
|
||||
description: "Converti PSD in PNG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "Da PNG a EPS",
|
||||
description: "Converti PNG in EPS online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "Da JPG a EPS",
|
||||
description: "Converti JPG in EPS online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "Da EPS a PNG",
|
||||
description: "Converti EPS in PNG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "Da EPS a JPG",
|
||||
description: "Converti EPS in JPG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "Da PNG a SVG",
|
||||
description: "Converti PNG in SVG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "Da JPG a SVG",
|
||||
description: "Converti JPG in SVG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "Da TIFF a SVG",
|
||||
description: "Converti TIFF in SVG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "Da PSD a SVG",
|
||||
description: "Converti PSD in SVG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "Da EPS a SVG",
|
||||
description: "Converti EPS in SVG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "Da SVG a PNG",
|
||||
description: "Converti SVG in PNG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "Da SVG a JPG",
|
||||
description: "Converti SVG in JPG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "Da JPG a PDF",
|
||||
description: "Converti JPG in PDF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "Da PNG a PDF",
|
||||
description: "Converti PNG in PDF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "Da HEIC a PDF",
|
||||
description: "Converti HEIC in PDF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "Da TIFF a PDF",
|
||||
description: "Converti TIFF in PDF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "Da WebP a PDF",
|
||||
description: "Converti WebP in PDF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "Da GIF a PDF",
|
||||
description: "Converti GIF in PDF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "Da EPS a PDF",
|
||||
description: "Converti EPS in PDF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "Da PDF a JPG",
|
||||
description: "Converti PDF in JPG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "Da PDF a PNG",
|
||||
description: "Converti PDF in PNG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "Da PDF a TIFF",
|
||||
description: "Converti PDF in TIFF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "Da MOV a MP4",
|
||||
description: "Converti MOV in MP4 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "Da WEBM a MP4",
|
||||
description: "Converti WEBM in MP4 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "Da MKV a MP4",
|
||||
description: "Converti MKV in MP4 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "Da AVI a MP4",
|
||||
description: "Converti AVI in MP4 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "Da MP4 a MOV",
|
||||
description: "Converti MP4 in MOV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "Da MP4 a WEBM",
|
||||
description: "Converti MP4 in WEBM online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "Da WEBM a MOV",
|
||||
description: "Converti WEBM in MOV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "Da MKV a MOV",
|
||||
description: "Converti MKV in MOV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "Da AVI a MOV",
|
||||
description: "Converti AVI in MOV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "Da MP4 a AVI",
|
||||
description: "Converti MP4 in AVI online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "Da MOV a AVI",
|
||||
description: "Converti MOV in AVI online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "Da MKV a AVI",
|
||||
description: "Converti MKV in AVI online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "Da AVI a MKV",
|
||||
description: "Converti AVI in MKV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "Da MP4 a GIF",
|
||||
description: "Converti MP4 in GIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "Da MOV a GIF",
|
||||
description: "Converti MOV in GIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "Da MKV a GIF",
|
||||
description: "Converti MKV in GIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "Da AVI a GIF",
|
||||
description: "Converti AVI in GIF online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "Da GIF a MP4",
|
||||
description: "Converti GIF in MP4 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "Da GIF a WEBM",
|
||||
description: "Converti GIF in WEBM online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "Da GIF a MOV",
|
||||
description: "Converti GIF in MOV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "Da MP4 a MP3",
|
||||
description: "Converti MP4 in MP3 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "Da MOV a MP3",
|
||||
description: "Converti MOV in MP3 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "Da MKV a MP3",
|
||||
description: "Converti MKV in MP3 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "Da WEBM a MP3",
|
||||
description: "Converti WEBM in MP3 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "Da AVI a MP3",
|
||||
description: "Converti AVI in MP3 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "Da MP4 a WAV",
|
||||
description: "Converti MP4 in WAV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "Da MOV a WAV",
|
||||
description: "Converti MOV in WAV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "Da MP4 a OGG",
|
||||
description: "Converti MP4 in OGG online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "Da M4A a MP3",
|
||||
description: "Converti M4A in MP3 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "Da M4A a WAV",
|
||||
description: "Converti M4A in WAV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "Da AAC a MP3",
|
||||
description: "Converti AAC in MP3 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "Da AAC a WAV",
|
||||
description: "Converti AAC in WAV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "Da AAC a FLAC",
|
||||
description: "Converti AAC in FLAC online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "Da OGG a MP3",
|
||||
description: "Converti OGG in MP3 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "Da OGG a WAV",
|
||||
description: "Converti OGG in WAV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "Da WAV a MP3",
|
||||
description: "Converti WAV in MP3 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "Da MP3 a WAV",
|
||||
description: "Converti MP3 in WAV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "Da FLAC a MP3",
|
||||
description: "Converti FLAC in MP3 online. Veloce, gratuito e privato.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Da Excel a CSV",
|
||||
description: "Converti Excel in CSV online. Veloce, gratuito e privato.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Ridimensiona",
|
||||
description: "Ridimensiona per pixel, percentuale o preset per social media",
|
||||
@@ -150,7 +484,7 @@ export const it: TranslationKeys = {
|
||||
description: "Rimuovi oggetti indesiderati con l'IA",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / Estrazione testo",
|
||||
name: "Estrai testo da immagine (OCR)",
|
||||
description: "Estrai testo dalle immagini con precisione basata sull'IA",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2757,6 +3091,14 @@ export const it: TranslationKeys = {
|
||||
"Scarica uno ZIP diagnostico con configurazioni oscurate, log e dati dei job per la risoluzione dei problemi.",
|
||||
supportBundleFailed: "Impossibile scaricare il pacchetto di supporto.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Privacy",
|
||||
description:
|
||||
"Le analisi di prodotto anonime aiutano a migliorare SnapOtter. Sono attive per impostazione predefinita e vengono configurate per l'intera istanza da un amministratore.",
|
||||
analyticsLabel: "Analisi di prodotto anonime",
|
||||
analyticsDescription:
|
||||
"Condividi dati d'uso anonimi (quali strumenti vengono eseguiti, durate, codici di errore) e segnalazioni di arresto anomalo. Nessun indirizzo IP, nessuna identità dell'account. Non raccogliamo mai i nomi dei tuoi file, il contenuto dei file, l'output OCR, i metadati delle immagini o il testo dei documenti. Disattiva questa opzione per fermare tutto.",
|
||||
},
|
||||
security: {
|
||||
heading: "Sicurezza",
|
||||
description: "Impostazioni di password e autenticazione.",
|
||||
@@ -3054,7 +3396,7 @@ export const it: TranslationKeys = {
|
||||
heading: "Informazioni",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"Una suite di elaborazione file self-hosted e orientata alla privacy, con 157 strumenti. Converti, comprimi, modifica, trascrivi, OCR e automatizza flussi di lavoro per immagini, video, audio, documenti e dati senza inviare dati al cloud.",
|
||||
"Una suite di elaborazione file self-hosted e orientata alla privacy, con 240 strumenti. Converti, comprimi, modifica, trascrivi, OCR e automatizza flussi di lavoro per immagini, video, audio, documenti e dati senza inviare dati al cloud.",
|
||||
versionLabel: "Versione:",
|
||||
linksHeading: "Link",
|
||||
githubLink: "Repository GitHub",
|
||||
@@ -3114,7 +3456,7 @@ export const it: TranslationKeys = {
|
||||
"Nessun limite. Nessun tetto nascosto.",
|
||||
"Funziona completamente offline.",
|
||||
"Elaborazione in blocco illimitata.",
|
||||
"157 strumenti.",
|
||||
"240 strumenti.",
|
||||
"15 modelli IA. Il tuo hardware.",
|
||||
"Velocissimo. Basato su Sharp.",
|
||||
"Pronto per reti isolate.",
|
||||
@@ -3179,6 +3521,7 @@ export const it: TranslationKeys = {
|
||||
toolPalette: "Palette strumenti",
|
||||
clickToAdd: "Fai clic per aggiungere al Pipeline",
|
||||
savedLabel: "Salvato",
|
||||
templatesLabel: "Modelli",
|
||||
importButton: "Importa",
|
||||
noSavedPipelines: "Nessun Pipeline salvato",
|
||||
stepCount: "({count} passaggio)",
|
||||
@@ -3465,4 +3808,45 @@ export const it: TranslationKeys = {
|
||||
navigationMenu: "Menu di navigazione",
|
||||
homeLink: "Home di SnapOtter",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Ottimizza per il web",
|
||||
description:
|
||||
"Ridimensiona, converti in WebP e comprimi un'immagine per un caricamento web veloce.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "Adatta alle email",
|
||||
description: "Riduci un'immagine così da renderla abbastanza piccola da allegare a un'email.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Pulizia privacy",
|
||||
description: "Rimuovi EXIF, GPS e altri metadati, poi comprimi.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Quadrato social",
|
||||
description: "Ritaglia in un quadrato 1:1 e comprimi per i post sui social.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Filigrana in serie",
|
||||
description: "Applica una filigrana di testo alle immagini, poi comprimi.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "Riduci PDF per email",
|
||||
description:
|
||||
"Appiattisci e comprimi con decisione un PDF per rispettare i limiti delle email.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Rifinitura podcast",
|
||||
description:
|
||||
"Rimuovi i silenzi, normalizza il volume e aggiungi dissolvenze a una registrazione.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Scontorna il soggetto",
|
||||
description: "Rimuovi lo sfondo con l'IA e comprimi il ritaglio trasparente.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Restaura vecchia foto",
|
||||
description: "Ripara, colora e ingrandisci una foto vecchia o danneggiata con l'IA.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const ja: TranslationKeys = {
|
||||
documentsAndFiles: "PDF & ファイル",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPGをPNGに変換",
|
||||
description: "JPGをPNGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNGをJPGに変換",
|
||||
description: "PNGをJPGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPGをWebPに変換",
|
||||
description: "JPGをWebPにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNGをWebPに変換",
|
||||
description: "PNGをWebPにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebPをJPGに変換",
|
||||
description: "WebPをJPGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebPをPNGに変換",
|
||||
description: "WebPをPNGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPGをAVIFに変換",
|
||||
description: "JPGをAVIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNGをAVIFに変換",
|
||||
description: "PNGをAVIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebPをAVIFに変換",
|
||||
description: "WebPをAVIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEICをJPGに変換",
|
||||
description: "HEICをJPGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEICをPNGに変換",
|
||||
description: "HEICをPNGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEICをAVIFに変換",
|
||||
description: "HEICをAVIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPGをGIFに変換",
|
||||
description: "JPGをGIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNGをGIFに変換",
|
||||
description: "PNGをGIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIFをJPGに変換",
|
||||
description: "GIFをJPGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIFをPNGに変換",
|
||||
description: "GIFをPNGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebPをGIFに変換",
|
||||
description: "WebPをGIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPGをTIFFに変換",
|
||||
description: "JPGをTIFFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNGをTIFFに変換",
|
||||
description: "PNGをTIFFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFFをJPGに変換",
|
||||
description: "TIFFをJPGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFFをPNGに変換",
|
||||
description: "TIFFをPNGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSDをJPGに変換",
|
||||
description: "PSDをJPGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSDをPNGに変換",
|
||||
description: "PSDをPNGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNGをEPSに変換",
|
||||
description: "PNGをEPSにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPGをEPSに変換",
|
||||
description: "JPGをEPSにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPSをPNGに変換",
|
||||
description: "EPSをPNGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPSをJPGに変換",
|
||||
description: "EPSをJPGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNGをSVGに変換",
|
||||
description: "PNGをSVGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPGをSVGに変換",
|
||||
description: "JPGをSVGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFFをSVGに変換",
|
||||
description: "TIFFをSVGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSDをSVGに変換",
|
||||
description: "PSDをSVGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPSをSVGに変換",
|
||||
description: "EPSをSVGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVGをPNGに変換",
|
||||
description: "SVGをPNGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVGをJPGに変換",
|
||||
description: "SVGをJPGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPGをPDFに変換",
|
||||
description: "JPGをPDFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNGをPDFに変換",
|
||||
description: "PNGをPDFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEICをPDFに変換",
|
||||
description: "HEICをPDFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFFをPDFに変換",
|
||||
description: "TIFFをPDFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebPをPDFに変換",
|
||||
description: "WebPをPDFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIFをPDFに変換",
|
||||
description: "GIFをPDFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPSをPDFに変換",
|
||||
description: "EPSをPDFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDFをJPGに変換",
|
||||
description: "PDFをJPGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDFをPNGに変換",
|
||||
description: "PDFをPNGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDFをTIFFに変換",
|
||||
description: "PDFをTIFFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOVをMP4に変換",
|
||||
description: "MOVをMP4にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBMをMP4に変換",
|
||||
description: "WEBMをMP4にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKVをMP4に変換",
|
||||
description: "MKVをMP4にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVIをMP4に変換",
|
||||
description: "AVIをMP4にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4をMOVに変換",
|
||||
description: "MP4をMOVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4をWEBMに変換",
|
||||
description: "MP4をWEBMにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBMをMOVに変換",
|
||||
description: "WEBMをMOVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKVをMOVに変換",
|
||||
description: "MKVをMOVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVIをMOVに変換",
|
||||
description: "AVIをMOVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4をAVIに変換",
|
||||
description: "MP4をAVIにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOVをAVIに変換",
|
||||
description: "MOVをAVIにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKVをAVIに変換",
|
||||
description: "MKVをAVIにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVIをMKVに変換",
|
||||
description: "AVIをMKVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4をGIFに変換",
|
||||
description: "MP4をGIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOVをGIFに変換",
|
||||
description: "MOVをGIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKVをGIFに変換",
|
||||
description: "MKVをGIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVIをGIFに変換",
|
||||
description: "AVIをGIFにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIFをMP4に変換",
|
||||
description: "GIFをMP4にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIFをWEBMに変換",
|
||||
description: "GIFをWEBMにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIFをMOVに変換",
|
||||
description: "GIFをMOVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4をMP3に変換",
|
||||
description: "MP4をMP3にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOVをMP3に変換",
|
||||
description: "MOVをMP3にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKVをMP3に変換",
|
||||
description: "MKVをMP3にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBMをMP3に変換",
|
||||
description: "WEBMをMP3にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVIをMP3に変換",
|
||||
description: "AVIをMP3にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4をWAVに変換",
|
||||
description: "MP4をWAVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOVをWAVに変換",
|
||||
description: "MOVをWAVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4をOGGに変換",
|
||||
description: "MP4をOGGにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4AをMP3に変換",
|
||||
description: "M4AをMP3にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4AをWAVに変換",
|
||||
description: "M4AをWAVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AACをMP3に変換",
|
||||
description: "AACをMP3にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AACをWAVに変換",
|
||||
description: "AACをWAVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AACをFLACに変換",
|
||||
description: "AACをFLACにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGGをMP3に変換",
|
||||
description: "OGGをMP3にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGGをWAVに変換",
|
||||
description: "OGGをWAVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAVをMP3に変換",
|
||||
description: "WAVをMP3にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3をWAVに変換",
|
||||
description: "MP3をWAVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLACをMP3に変換",
|
||||
description: "FLACをMP3にオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "ExcelをCSVに変換",
|
||||
description: "ExcelをCSVにオンラインで変換。高速、無料、プライベート。",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "リサイズ",
|
||||
description: "ピクセル、パーセント、SNSプリセットでリサイズ",
|
||||
@@ -129,7 +463,7 @@ export const ja: TranslationKeys = {
|
||||
upscale: { name: "画像アップスケール", description: "AI超解像エンハンスメント" },
|
||||
"erase-object": { name: "オブジェクト消去", description: "AIで不要なオブジェクトを除去" },
|
||||
ocr: {
|
||||
name: "OCR / テキスト抽出",
|
||||
name: "画像から文字を抽出 (OCR)",
|
||||
description: "AIで画像からテキストを高精度に抽出",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2708,6 +3042,14 @@ export const ja: TranslationKeys = {
|
||||
"トラブルシューティング用の診断 ZIP(設定、ログ、ジョブデータを含む)をダウンロードします。",
|
||||
supportBundleFailed: "サポートバンドルのダウンロードに失敗しました。",
|
||||
},
|
||||
privacy: {
|
||||
title: "プライバシー",
|
||||
description:
|
||||
"匿名のプロダクト分析は SnapOtter の改善に役立ちます。既定で有効になっており、インスタンス全体に対して管理者が設定します。",
|
||||
analyticsLabel: "匿名のプロダクト分析",
|
||||
analyticsDescription:
|
||||
"匿名の使用状況データ(実行されたツール、処理時間、エラーコード)とクラッシュレポートを共有します。IP アドレスやアカウント情報は含まれません。ファイル名、ファイルの内容、OCR の出力、画像のメタデータ、ドキュメントのテキストを収集することは一切ありません。これをオフにすると、すべて停止します。",
|
||||
},
|
||||
security: {
|
||||
heading: "セキュリティ",
|
||||
description: "パスワードと認証の設定。",
|
||||
@@ -2996,7 +3338,7 @@ export const ja: TranslationKeys = {
|
||||
heading: "SnapOtterについて",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"セルフホスト型、プライバシー第一のファイル処理スイート。157のツールを搭載。変換、圧縮、編集、文字起こし、OCRなど、画像・動画・音声・ドキュメント・データのワークフローをクラウドにデータを送ることなく自動化できます。",
|
||||
"セルフホスト型、プライバシー第一のファイル処理スイート。240のツールを搭載。変換、圧縮、編集、文字起こし、OCRなど、画像・動画・音声・ドキュメント・データのワークフローをクラウドにデータを送ることなく自動化できます。",
|
||||
versionLabel: "バージョン:",
|
||||
linksHeading: "リンク",
|
||||
githubLink: "GitHubリポジトリ",
|
||||
@@ -3054,7 +3396,7 @@ export const ja: TranslationKeys = {
|
||||
"制限なし。隠れた上限なし。",
|
||||
"完全オフラインで動作。",
|
||||
"バッチ処理も無制限。",
|
||||
"157のツールを搭載。",
|
||||
"240のツールを搭載。",
|
||||
"15のAIモデル。あなたのハードウェアで。",
|
||||
"Sharp搭載で超高速処理。",
|
||||
"エアギャップ環境にも対応。",
|
||||
@@ -3118,6 +3460,7 @@ export const ja: TranslationKeys = {
|
||||
toolPalette: "ツールパレット",
|
||||
clickToAdd: "クリックでPipelineに追加",
|
||||
savedLabel: "保存済み",
|
||||
templatesLabel: "テンプレート",
|
||||
importButton: "インポート",
|
||||
noSavedPipelines: "保存されたPipelineはありません",
|
||||
stepCount: "({count}ステップ)",
|
||||
@@ -3405,6 +3748,44 @@ export const ja: TranslationKeys = {
|
||||
navigationMenu: "ナビゲーション",
|
||||
homeLink: "SnapOtter ホーム",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Web向けに最適化",
|
||||
description: "画像をリサイズし、WebPに変換して圧縮し、Webでの読み込みを高速化します。",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "メール添付向け",
|
||||
description: "メールに添付できるよう、画像を十分に小さく縮小します。",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "プライバシー消去",
|
||||
description: "EXIF、GPS、その他のメタデータを削除してから圧縮します。",
|
||||
},
|
||||
"social-square": {
|
||||
name: "SNS用スクエア",
|
||||
description: "1:1の正方形にトリミングし、SNS投稿向けに圧縮します。",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "透かし一括",
|
||||
description: "画像にテキストの透かしを入れてから圧縮します。",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "メール用にPDFを縮小",
|
||||
description: "PDFをフラット化し、メールの制限に収まるよう強力に圧縮します。",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "ポッドキャスト仕上げ",
|
||||
description: "無音部分を削除し、音量を正規化して、録音にフェードを追加します。",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "被写体を切り抜き",
|
||||
description: "AIで背景を除去し、透明な切り抜きを圧縮します。",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "古い写真を復元",
|
||||
description: "AIで古い写真や傷んだ写真を修復し、着色して拡大します。",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default ja;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const ko: TranslationKeys = {
|
||||
documentsAndFiles: "PDF & 파일",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG을(를) PNG(으)로",
|
||||
description: "JPG을(를) PNG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG을(를) JPG(으)로",
|
||||
description: "PNG을(를) JPG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG을(를) WebP(으)로",
|
||||
description: "JPG을(를) WebP(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG을(를) WebP(으)로",
|
||||
description: "PNG을(를) WebP(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP을(를) JPG(으)로",
|
||||
description: "WebP을(를) JPG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP을(를) PNG(으)로",
|
||||
description: "WebP을(를) PNG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG을(를) AVIF(으)로",
|
||||
description: "JPG을(를) AVIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG을(를) AVIF(으)로",
|
||||
description: "PNG을(를) AVIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP을(를) AVIF(으)로",
|
||||
description: "WebP을(를) AVIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC을(를) JPG(으)로",
|
||||
description: "HEIC을(를) JPG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC을(를) PNG(으)로",
|
||||
description: "HEIC을(를) PNG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC을(를) AVIF(으)로",
|
||||
description: "HEIC을(를) AVIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG을(를) GIF(으)로",
|
||||
description: "JPG을(를) GIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG을(를) GIF(으)로",
|
||||
description: "PNG을(를) GIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF을(를) JPG(으)로",
|
||||
description: "GIF을(를) JPG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF을(를) PNG(으)로",
|
||||
description: "GIF을(를) PNG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP을(를) GIF(으)로",
|
||||
description: "WebP을(를) GIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG을(를) TIFF(으)로",
|
||||
description: "JPG을(를) TIFF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG을(를) TIFF(으)로",
|
||||
description: "PNG을(를) TIFF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF을(를) JPG(으)로",
|
||||
description: "TIFF을(를) JPG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF을(를) PNG(으)로",
|
||||
description: "TIFF을(를) PNG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD을(를) JPG(으)로",
|
||||
description: "PSD을(를) JPG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD을(를) PNG(으)로",
|
||||
description: "PSD을(를) PNG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG을(를) EPS(으)로",
|
||||
description: "PNG을(를) EPS(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG을(를) EPS(으)로",
|
||||
description: "JPG을(를) EPS(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS을(를) PNG(으)로",
|
||||
description: "EPS을(를) PNG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS을(를) JPG(으)로",
|
||||
description: "EPS을(를) JPG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG을(를) SVG(으)로",
|
||||
description: "PNG을(를) SVG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG을(를) SVG(으)로",
|
||||
description: "JPG을(를) SVG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF을(를) SVG(으)로",
|
||||
description: "TIFF을(를) SVG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD을(를) SVG(으)로",
|
||||
description: "PSD을(를) SVG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS을(를) SVG(으)로",
|
||||
description: "EPS을(를) SVG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG을(를) PNG(으)로",
|
||||
description: "SVG을(를) PNG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG을(를) JPG(으)로",
|
||||
description: "SVG을(를) JPG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG을(를) PDF(으)로",
|
||||
description: "JPG을(를) PDF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG을(를) PDF(으)로",
|
||||
description: "PNG을(를) PDF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC을(를) PDF(으)로",
|
||||
description: "HEIC을(를) PDF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF을(를) PDF(으)로",
|
||||
description: "TIFF을(를) PDF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP을(를) PDF(으)로",
|
||||
description: "WebP을(를) PDF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF을(를) PDF(으)로",
|
||||
description: "GIF을(를) PDF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS을(를) PDF(으)로",
|
||||
description: "EPS을(를) PDF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF을(를) JPG(으)로",
|
||||
description: "PDF을(를) JPG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF을(를) PNG(으)로",
|
||||
description: "PDF을(를) PNG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF을(를) TIFF(으)로",
|
||||
description: "PDF을(를) TIFF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV을(를) MP4(으)로",
|
||||
description: "MOV을(를) MP4(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM을(를) MP4(으)로",
|
||||
description: "WEBM을(를) MP4(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV을(를) MP4(으)로",
|
||||
description: "MKV을(를) MP4(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI을(를) MP4(으)로",
|
||||
description: "AVI을(를) MP4(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4을(를) MOV(으)로",
|
||||
description: "MP4을(를) MOV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4을(를) WEBM(으)로",
|
||||
description: "MP4을(를) WEBM(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM을(를) MOV(으)로",
|
||||
description: "WEBM을(를) MOV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV을(를) MOV(으)로",
|
||||
description: "MKV을(를) MOV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI을(를) MOV(으)로",
|
||||
description: "AVI을(를) MOV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4을(를) AVI(으)로",
|
||||
description: "MP4을(를) AVI(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV을(를) AVI(으)로",
|
||||
description: "MOV을(를) AVI(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV을(를) AVI(으)로",
|
||||
description: "MKV을(를) AVI(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI을(를) MKV(으)로",
|
||||
description: "AVI을(를) MKV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4을(를) GIF(으)로",
|
||||
description: "MP4을(를) GIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV을(를) GIF(으)로",
|
||||
description: "MOV을(를) GIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV을(를) GIF(으)로",
|
||||
description: "MKV을(를) GIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI을(를) GIF(으)로",
|
||||
description: "AVI을(를) GIF(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF을(를) MP4(으)로",
|
||||
description: "GIF을(를) MP4(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF을(를) WEBM(으)로",
|
||||
description: "GIF을(를) WEBM(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF을(를) MOV(으)로",
|
||||
description: "GIF을(를) MOV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4을(를) MP3(으)로",
|
||||
description: "MP4을(를) MP3(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV을(를) MP3(으)로",
|
||||
description: "MOV을(를) MP3(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV을(를) MP3(으)로",
|
||||
description: "MKV을(를) MP3(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM을(를) MP3(으)로",
|
||||
description: "WEBM을(를) MP3(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI을(를) MP3(으)로",
|
||||
description: "AVI을(를) MP3(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4을(를) WAV(으)로",
|
||||
description: "MP4을(를) WAV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV을(를) WAV(으)로",
|
||||
description: "MOV을(를) WAV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4을(를) OGG(으)로",
|
||||
description: "MP4을(를) OGG(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A을(를) MP3(으)로",
|
||||
description: "M4A을(를) MP3(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A을(를) WAV(으)로",
|
||||
description: "M4A을(를) WAV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC을(를) MP3(으)로",
|
||||
description: "AAC을(를) MP3(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC을(를) WAV(으)로",
|
||||
description: "AAC을(를) WAV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC을(를) FLAC(으)로",
|
||||
description: "AAC을(를) FLAC(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG을(를) MP3(으)로",
|
||||
description: "OGG을(를) MP3(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG을(를) WAV(으)로",
|
||||
description: "OGG을(를) WAV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV을(를) MP3(으)로",
|
||||
description: "WAV을(를) MP3(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3을(를) WAV(으)로",
|
||||
description: "MP3을(를) WAV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC을(를) MP3(으)로",
|
||||
description: "FLAC을(를) MP3(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel을(를) CSV(으)로",
|
||||
description: "Excel을(를) CSV(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "리사이즈",
|
||||
description: "픽셀, 퍼센트 또는 소셜 미디어 프리셋으로 리사이즈",
|
||||
@@ -126,7 +460,7 @@ export const ko: TranslationKeys = {
|
||||
upscale: { name: "이미지 업스케일", description: "AI 초해상도 향상" },
|
||||
"erase-object": { name: "객체 지우기", description: "AI로 원하지 않는 객체 제거" },
|
||||
ocr: {
|
||||
name: "OCR / 텍스트 추출",
|
||||
name: "이미지에서 텍스트 추출 (OCR)",
|
||||
description: "AI로 이미지에서 높은 정확도로 텍스트 추출",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2692,6 +3026,14 @@ export const ko: TranslationKeys = {
|
||||
"편집된 설정, 로그 및 작업 데이터가 포함된 진단 zip을 다운로드합니다.",
|
||||
supportBundleFailed: "지원 번들 다운로드에 실패했습니다.",
|
||||
},
|
||||
privacy: {
|
||||
title: "개인정보",
|
||||
description:
|
||||
"익명 제품 분석은 SnapOtter 개선에 도움이 됩니다. 기본적으로 켜져 있으며 관리자가 전체 인스턴스에 대해 설정합니다.",
|
||||
analyticsLabel: "익명 제품 분석",
|
||||
analyticsDescription:
|
||||
"익명 사용 데이터(실행된 도구, 소요 시간, 오류 코드)와 충돌 보고서를 공유합니다. IP 주소나 계정 정보는 포함되지 않습니다. 파일 이름, 파일 내용, OCR 결과, 이미지 메타데이터, 문서 텍스트는 절대 수집하지 않습니다. 모두 중단하려면 이 옵션을 끄세요.",
|
||||
},
|
||||
security: {
|
||||
heading: "보안",
|
||||
description: "비밀번호 및 인증 설정.",
|
||||
@@ -2980,7 +3322,7 @@ export const ko: TranslationKeys = {
|
||||
heading: "정보",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"셀프 호스팅, 개인정보 우선의 파일 처리 도구 모음으로 157개의 도구를 제공합니다. 변환, 압축, 편집, 전사, OCR 등 이미지, 동영상, 오디오, 문서, 데이터 워크플로를 클라우드에 데이터를 보내지 않고 자동화할 수 있습니다.",
|
||||
"셀프 호스팅, 개인정보 우선의 파일 처리 도구 모음으로 240개의 도구를 제공합니다. 변환, 압축, 편집, 전사, OCR 등 이미지, 동영상, 오디오, 문서, 데이터 워크플로를 클라우드에 데이터를 보내지 않고 자동화할 수 있습니다.",
|
||||
versionLabel: "버전:",
|
||||
linksHeading: "링크",
|
||||
githubLink: "GitHub 저장소",
|
||||
@@ -3037,7 +3379,7 @@ export const ko: TranslationKeys = {
|
||||
"제한 없음. 숨겨진 상한 없음.",
|
||||
"완전히 오프라인으로 작동해요.",
|
||||
"무제한 일괄 처리.",
|
||||
"157개의 도구.",
|
||||
"240개의 도구.",
|
||||
"15개 AI 모델. 당신의 하드웨어에서.",
|
||||
"Sharp 기반의 초고속 처리.",
|
||||
"에어갭 환경에서도 사용 가능.",
|
||||
@@ -3101,6 +3443,7 @@ export const ko: TranslationKeys = {
|
||||
toolPalette: "도구 팔레트",
|
||||
clickToAdd: "클릭하여 Pipeline에 추가",
|
||||
savedLabel: "저장됨",
|
||||
templatesLabel: "템플릿",
|
||||
importButton: "가져오기",
|
||||
noSavedPipelines: "저장된 Pipeline이 없습니다",
|
||||
stepCount: "({count}단계)",
|
||||
@@ -3387,6 +3730,45 @@ export const ko: TranslationKeys = {
|
||||
navigationMenu: "탐색 메뉴",
|
||||
homeLink: "SnapOtter 홈",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "웹 최적화",
|
||||
description:
|
||||
"이미지 크기를 조정하고 WebP로 변환한 뒤 압축하여 웹에서 빠르게 로드되도록 합니다.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "이메일 적합",
|
||||
description: "이메일에 첨부할 수 있을 만큼 이미지를 충분히 작게 줄입니다.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "개인정보 정리",
|
||||
description: "EXIF, GPS 및 기타 메타데이터를 제거한 뒤 압축합니다.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "소셜 정사각형",
|
||||
description: "1:1 정사각형으로 자르고 소셜 게시물용으로 압축합니다.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "워터마크 일괄 처리",
|
||||
description: "이미지에 텍스트 워터마크를 찍은 뒤 압축합니다.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "이메일용 PDF 축소",
|
||||
description: "PDF를 평면화하고 이메일 용량 제한에 맞게 강하게 압축합니다.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "팟캐스트 다듬기",
|
||||
description: "무음 구간을 제거하고 음량을 정규화한 뒤 녹음에 페이드를 추가합니다.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "피사체 오려내기",
|
||||
description: "AI로 배경을 제거하고 투명한 누끼 이미지를 압축합니다.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "오래된 사진 복원",
|
||||
description: "AI로 오래되거나 손상된 사진을 복구하고 색을 입히며 업스케일합니다.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default ko;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const nl: TranslationKeys = {
|
||||
documentsAndFiles: "PDF & bestanden",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG naar PNG",
|
||||
description: "Zet JPG online om naar PNG. Snel, gratis en privé.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG naar JPG",
|
||||
description: "Zet PNG online om naar JPG. Snel, gratis en privé.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG naar WebP",
|
||||
description: "Zet JPG online om naar WebP. Snel, gratis en privé.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG naar WebP",
|
||||
description: "Zet PNG online om naar WebP. Snel, gratis en privé.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP naar JPG",
|
||||
description: "Zet WebP online om naar JPG. Snel, gratis en privé.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP naar PNG",
|
||||
description: "Zet WebP online om naar PNG. Snel, gratis en privé.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG naar AVIF",
|
||||
description: "Zet JPG online om naar AVIF. Snel, gratis en privé.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG naar AVIF",
|
||||
description: "Zet PNG online om naar AVIF. Snel, gratis en privé.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP naar AVIF",
|
||||
description: "Zet WebP online om naar AVIF. Snel, gratis en privé.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC naar JPG",
|
||||
description: "Zet HEIC online om naar JPG. Snel, gratis en privé.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC naar PNG",
|
||||
description: "Zet HEIC online om naar PNG. Snel, gratis en privé.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC naar AVIF",
|
||||
description: "Zet HEIC online om naar AVIF. Snel, gratis en privé.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG naar GIF",
|
||||
description: "Zet JPG online om naar GIF. Snel, gratis en privé.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG naar GIF",
|
||||
description: "Zet PNG online om naar GIF. Snel, gratis en privé.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF naar JPG",
|
||||
description: "Zet GIF online om naar JPG. Snel, gratis en privé.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF naar PNG",
|
||||
description: "Zet GIF online om naar PNG. Snel, gratis en privé.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP naar GIF",
|
||||
description: "Zet WebP online om naar GIF. Snel, gratis en privé.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG naar TIFF",
|
||||
description: "Zet JPG online om naar TIFF. Snel, gratis en privé.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG naar TIFF",
|
||||
description: "Zet PNG online om naar TIFF. Snel, gratis en privé.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF naar JPG",
|
||||
description: "Zet TIFF online om naar JPG. Snel, gratis en privé.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF naar PNG",
|
||||
description: "Zet TIFF online om naar PNG. Snel, gratis en privé.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD naar JPG",
|
||||
description: "Zet PSD online om naar JPG. Snel, gratis en privé.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD naar PNG",
|
||||
description: "Zet PSD online om naar PNG. Snel, gratis en privé.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG naar EPS",
|
||||
description: "Zet PNG online om naar EPS. Snel, gratis en privé.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG naar EPS",
|
||||
description: "Zet JPG online om naar EPS. Snel, gratis en privé.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS naar PNG",
|
||||
description: "Zet EPS online om naar PNG. Snel, gratis en privé.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS naar JPG",
|
||||
description: "Zet EPS online om naar JPG. Snel, gratis en privé.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG naar SVG",
|
||||
description: "Zet PNG online om naar SVG. Snel, gratis en privé.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG naar SVG",
|
||||
description: "Zet JPG online om naar SVG. Snel, gratis en privé.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF naar SVG",
|
||||
description: "Zet TIFF online om naar SVG. Snel, gratis en privé.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD naar SVG",
|
||||
description: "Zet PSD online om naar SVG. Snel, gratis en privé.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS naar SVG",
|
||||
description: "Zet EPS online om naar SVG. Snel, gratis en privé.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG naar PNG",
|
||||
description: "Zet SVG online om naar PNG. Snel, gratis en privé.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG naar JPG",
|
||||
description: "Zet SVG online om naar JPG. Snel, gratis en privé.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG naar PDF",
|
||||
description: "Zet JPG online om naar PDF. Snel, gratis en privé.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG naar PDF",
|
||||
description: "Zet PNG online om naar PDF. Snel, gratis en privé.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC naar PDF",
|
||||
description: "Zet HEIC online om naar PDF. Snel, gratis en privé.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF naar PDF",
|
||||
description: "Zet TIFF online om naar PDF. Snel, gratis en privé.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP naar PDF",
|
||||
description: "Zet WebP online om naar PDF. Snel, gratis en privé.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF naar PDF",
|
||||
description: "Zet GIF online om naar PDF. Snel, gratis en privé.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS naar PDF",
|
||||
description: "Zet EPS online om naar PDF. Snel, gratis en privé.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF naar JPG",
|
||||
description: "Zet PDF online om naar JPG. Snel, gratis en privé.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF naar PNG",
|
||||
description: "Zet PDF online om naar PNG. Snel, gratis en privé.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF naar TIFF",
|
||||
description: "Zet PDF online om naar TIFF. Snel, gratis en privé.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV naar MP4",
|
||||
description: "Zet MOV online om naar MP4. Snel, gratis en privé.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM naar MP4",
|
||||
description: "Zet WEBM online om naar MP4. Snel, gratis en privé.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV naar MP4",
|
||||
description: "Zet MKV online om naar MP4. Snel, gratis en privé.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI naar MP4",
|
||||
description: "Zet AVI online om naar MP4. Snel, gratis en privé.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 naar MOV",
|
||||
description: "Zet MP4 online om naar MOV. Snel, gratis en privé.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 naar WEBM",
|
||||
description: "Zet MP4 online om naar WEBM. Snel, gratis en privé.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM naar MOV",
|
||||
description: "Zet WEBM online om naar MOV. Snel, gratis en privé.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV naar MOV",
|
||||
description: "Zet MKV online om naar MOV. Snel, gratis en privé.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI naar MOV",
|
||||
description: "Zet AVI online om naar MOV. Snel, gratis en privé.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 naar AVI",
|
||||
description: "Zet MP4 online om naar AVI. Snel, gratis en privé.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV naar AVI",
|
||||
description: "Zet MOV online om naar AVI. Snel, gratis en privé.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV naar AVI",
|
||||
description: "Zet MKV online om naar AVI. Snel, gratis en privé.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI naar MKV",
|
||||
description: "Zet AVI online om naar MKV. Snel, gratis en privé.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 naar GIF",
|
||||
description: "Zet MP4 online om naar GIF. Snel, gratis en privé.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV naar GIF",
|
||||
description: "Zet MOV online om naar GIF. Snel, gratis en privé.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV naar GIF",
|
||||
description: "Zet MKV online om naar GIF. Snel, gratis en privé.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI naar GIF",
|
||||
description: "Zet AVI online om naar GIF. Snel, gratis en privé.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF naar MP4",
|
||||
description: "Zet GIF online om naar MP4. Snel, gratis en privé.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF naar WEBM",
|
||||
description: "Zet GIF online om naar WEBM. Snel, gratis en privé.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF naar MOV",
|
||||
description: "Zet GIF online om naar MOV. Snel, gratis en privé.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 naar MP3",
|
||||
description: "Zet MP4 online om naar MP3. Snel, gratis en privé.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV naar MP3",
|
||||
description: "Zet MOV online om naar MP3. Snel, gratis en privé.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV naar MP3",
|
||||
description: "Zet MKV online om naar MP3. Snel, gratis en privé.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM naar MP3",
|
||||
description: "Zet WEBM online om naar MP3. Snel, gratis en privé.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI naar MP3",
|
||||
description: "Zet AVI online om naar MP3. Snel, gratis en privé.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 naar WAV",
|
||||
description: "Zet MP4 online om naar WAV. Snel, gratis en privé.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV naar WAV",
|
||||
description: "Zet MOV online om naar WAV. Snel, gratis en privé.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 naar OGG",
|
||||
description: "Zet MP4 online om naar OGG. Snel, gratis en privé.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A naar MP3",
|
||||
description: "Zet M4A online om naar MP3. Snel, gratis en privé.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A naar WAV",
|
||||
description: "Zet M4A online om naar WAV. Snel, gratis en privé.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC naar MP3",
|
||||
description: "Zet AAC online om naar MP3. Snel, gratis en privé.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC naar WAV",
|
||||
description: "Zet AAC online om naar WAV. Snel, gratis en privé.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC naar FLAC",
|
||||
description: "Zet AAC online om naar FLAC. Snel, gratis en privé.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG naar MP3",
|
||||
description: "Zet OGG online om naar MP3. Snel, gratis en privé.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG naar WAV",
|
||||
description: "Zet OGG online om naar WAV. Snel, gratis en privé.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV naar MP3",
|
||||
description: "Zet WAV online om naar MP3. Snel, gratis en privé.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 naar WAV",
|
||||
description: "Zet MP3 online om naar WAV. Snel, gratis en privé.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC naar MP3",
|
||||
description: "Zet FLAC online om naar MP3. Snel, gratis en privé.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel naar CSV",
|
||||
description: "Zet Excel online om naar CSV. Snel, gratis en privé.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Formaat wijzigen",
|
||||
description: "Formaat wijzigen op pixels, percentage of social media-presets",
|
||||
@@ -153,7 +487,7 @@ export const nl: TranslationKeys = {
|
||||
description: "Ongewenste objecten verwijderen met AI",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / Tekstherkenning",
|
||||
name: "Tekst uit afbeelding halen (OCR)",
|
||||
description: "Tekst uit afbeeldingen halen met AI-nauwkeurigheid",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2757,6 +3091,14 @@ export const nl: TranslationKeys = {
|
||||
"Download een diagnostisch zipbestand met afgeschermde configuratie, logs en taakgegevens voor probleemoplossing.",
|
||||
supportBundleFailed: "Ondersteuningsbundel downloaden mislukt.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Privacy",
|
||||
description:
|
||||
"Anonieme productanalyse helpt SnapOtter te verbeteren. Deze staat standaard aan en wordt voor de hele instantie ingesteld door een beheerder.",
|
||||
analyticsLabel: "Anonieme productanalyse",
|
||||
analyticsDescription:
|
||||
"Deel anonieme gebruiksgegevens (welke tools worden uitgevoerd, duur, foutcodes) en crashrapporten. Geen IP-adres, geen accountidentiteit. We verzamelen nooit uw bestandsnamen, bestandsinhoud, OCR-uitvoer, afbeeldingsmetadata of documenttekst. Schakel dit uit om alles te stoppen.",
|
||||
},
|
||||
security: {
|
||||
heading: "Beveiliging",
|
||||
description: "Wachtwoord- en authenticatie-instellingen.",
|
||||
@@ -3051,7 +3393,7 @@ export const nl: TranslationKeys = {
|
||||
heading: "Over",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"Een zelf-gehoste, privacy-first bestandsverwerkingssuite met 157 tools. Converteren, comprimeren, bewerken, transcriberen, OCR en automatiseer je beeld-, video-, audio-, document- en dataworkflows zonder data naar de cloud te sturen.",
|
||||
"Een zelf-gehoste, privacy-first bestandsverwerkingssuite met 240 tools. Converteren, comprimeren, bewerken, transcriberen, OCR en automatiseer je beeld-, video-, audio-, document- en dataworkflows zonder data naar de cloud te sturen.",
|
||||
versionLabel: "Versie:",
|
||||
linksHeading: "Links",
|
||||
githubLink: "GitHub-repository",
|
||||
@@ -3110,7 +3452,7 @@ export const nl: TranslationKeys = {
|
||||
"Geen limieten. Geen verborgen plafonds.",
|
||||
"Werkt volledig offline.",
|
||||
"Onbeperkte batchverwerking.",
|
||||
"157 tools.",
|
||||
"240 tools.",
|
||||
"16 AI-modellen. Jouw hardware.",
|
||||
"Razendsnel. Gebouwd op Sharp.",
|
||||
"Geschikt voor air-gapped netwerken.",
|
||||
@@ -3175,6 +3517,7 @@ export const nl: TranslationKeys = {
|
||||
toolPalette: "Gereedschapspalet",
|
||||
clickToAdd: "Klik om toe te voegen aan pipeline",
|
||||
savedLabel: "Opgeslagen",
|
||||
templatesLabel: "Sjablonen",
|
||||
importButton: "Importeren",
|
||||
noSavedPipelines: "Geen opgeslagen pipelines",
|
||||
stepCount: "({count} stap)",
|
||||
@@ -3461,4 +3804,45 @@ export const nl: TranslationKeys = {
|
||||
navigationMenu: "Navigatie",
|
||||
homeLink: "SnapOtter startpagina",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Optimaliseren voor web",
|
||||
description:
|
||||
"Verklein, converteer naar WebP en comprimeer een afbeelding voor snel laden op het web.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "E-mailvriendelijk",
|
||||
description:
|
||||
"Verklein een afbeelding zodat die klein genoeg is om aan een e-mail toe te voegen.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Privacy opschonen",
|
||||
description: "Verwijder EXIF, GPS en andere metadata en comprimeer daarna.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Sociaal vierkant",
|
||||
description:
|
||||
"Bijsnijden tot een vierkant van 1:1 en comprimeren voor berichten op sociale media.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Watermerk in batch",
|
||||
description: "Plaats een tekstwatermerk op afbeeldingen en comprimeer daarna.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "PDF verkleinen voor e-mail",
|
||||
description: "Plat maken en een PDF stevig comprimeren zodat die binnen e-maillimieten past.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Podcast bijschaven",
|
||||
description: "Verwijder stiltes, normaliseer het volume en voeg fades toe aan een opname.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Onderwerp uitsnijden",
|
||||
description: "Verwijder de achtergrond met AI en comprimeer de transparante uitsnede.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Oude foto herstellen",
|
||||
description: "Repareer, kleur in en vergroot een oude of beschadigde foto met AI.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const pl: TranslationKeys = {
|
||||
documentsAndFiles: "PDF i pliki",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG na PNG",
|
||||
description: "Konwertuj JPG na PNG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG na JPG",
|
||||
description: "Konwertuj PNG na JPG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG na WebP",
|
||||
description: "Konwertuj JPG na WebP online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG na WebP",
|
||||
description: "Konwertuj PNG na WebP online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP na JPG",
|
||||
description: "Konwertuj WebP na JPG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP na PNG",
|
||||
description: "Konwertuj WebP na PNG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG na AVIF",
|
||||
description: "Konwertuj JPG na AVIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG na AVIF",
|
||||
description: "Konwertuj PNG na AVIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP na AVIF",
|
||||
description: "Konwertuj WebP na AVIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC na JPG",
|
||||
description: "Konwertuj HEIC na JPG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC na PNG",
|
||||
description: "Konwertuj HEIC na PNG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC na AVIF",
|
||||
description: "Konwertuj HEIC na AVIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG na GIF",
|
||||
description: "Konwertuj JPG na GIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG na GIF",
|
||||
description: "Konwertuj PNG na GIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF na JPG",
|
||||
description: "Konwertuj GIF na JPG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF na PNG",
|
||||
description: "Konwertuj GIF na PNG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP na GIF",
|
||||
description: "Konwertuj WebP na GIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG na TIFF",
|
||||
description: "Konwertuj JPG na TIFF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG na TIFF",
|
||||
description: "Konwertuj PNG na TIFF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF na JPG",
|
||||
description: "Konwertuj TIFF na JPG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF na PNG",
|
||||
description: "Konwertuj TIFF na PNG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD na JPG",
|
||||
description: "Konwertuj PSD na JPG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD na PNG",
|
||||
description: "Konwertuj PSD na PNG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG na EPS",
|
||||
description: "Konwertuj PNG na EPS online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG na EPS",
|
||||
description: "Konwertuj JPG na EPS online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS na PNG",
|
||||
description: "Konwertuj EPS na PNG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS na JPG",
|
||||
description: "Konwertuj EPS na JPG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG na SVG",
|
||||
description: "Konwertuj PNG na SVG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG na SVG",
|
||||
description: "Konwertuj JPG na SVG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF na SVG",
|
||||
description: "Konwertuj TIFF na SVG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD na SVG",
|
||||
description: "Konwertuj PSD na SVG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS na SVG",
|
||||
description: "Konwertuj EPS na SVG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG na PNG",
|
||||
description: "Konwertuj SVG na PNG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG na JPG",
|
||||
description: "Konwertuj SVG na JPG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG na PDF",
|
||||
description: "Konwertuj JPG na PDF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG na PDF",
|
||||
description: "Konwertuj PNG na PDF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC na PDF",
|
||||
description: "Konwertuj HEIC na PDF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF na PDF",
|
||||
description: "Konwertuj TIFF na PDF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP na PDF",
|
||||
description: "Konwertuj WebP na PDF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF na PDF",
|
||||
description: "Konwertuj GIF na PDF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS na PDF",
|
||||
description: "Konwertuj EPS na PDF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF na JPG",
|
||||
description: "Konwertuj PDF na JPG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF na PNG",
|
||||
description: "Konwertuj PDF na PNG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF na TIFF",
|
||||
description: "Konwertuj PDF na TIFF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV na MP4",
|
||||
description: "Konwertuj MOV na MP4 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM na MP4",
|
||||
description: "Konwertuj WEBM na MP4 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV na MP4",
|
||||
description: "Konwertuj MKV na MP4 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI na MP4",
|
||||
description: "Konwertuj AVI na MP4 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 na MOV",
|
||||
description: "Konwertuj MP4 na MOV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 na WEBM",
|
||||
description: "Konwertuj MP4 na WEBM online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM na MOV",
|
||||
description: "Konwertuj WEBM na MOV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV na MOV",
|
||||
description: "Konwertuj MKV na MOV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI na MOV",
|
||||
description: "Konwertuj AVI na MOV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 na AVI",
|
||||
description: "Konwertuj MP4 na AVI online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV na AVI",
|
||||
description: "Konwertuj MOV na AVI online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV na AVI",
|
||||
description: "Konwertuj MKV na AVI online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI na MKV",
|
||||
description: "Konwertuj AVI na MKV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 na GIF",
|
||||
description: "Konwertuj MP4 na GIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV na GIF",
|
||||
description: "Konwertuj MOV na GIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV na GIF",
|
||||
description: "Konwertuj MKV na GIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI na GIF",
|
||||
description: "Konwertuj AVI na GIF online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF na MP4",
|
||||
description: "Konwertuj GIF na MP4 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF na WEBM",
|
||||
description: "Konwertuj GIF na WEBM online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF na MOV",
|
||||
description: "Konwertuj GIF na MOV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 na MP3",
|
||||
description: "Konwertuj MP4 na MP3 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV na MP3",
|
||||
description: "Konwertuj MOV na MP3 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV na MP3",
|
||||
description: "Konwertuj MKV na MP3 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM na MP3",
|
||||
description: "Konwertuj WEBM na MP3 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI na MP3",
|
||||
description: "Konwertuj AVI na MP3 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 na WAV",
|
||||
description: "Konwertuj MP4 na WAV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV na WAV",
|
||||
description: "Konwertuj MOV na WAV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 na OGG",
|
||||
description: "Konwertuj MP4 na OGG online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A na MP3",
|
||||
description: "Konwertuj M4A na MP3 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A na WAV",
|
||||
description: "Konwertuj M4A na WAV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC na MP3",
|
||||
description: "Konwertuj AAC na MP3 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC na WAV",
|
||||
description: "Konwertuj AAC na WAV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC na FLAC",
|
||||
description: "Konwertuj AAC na FLAC online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG na MP3",
|
||||
description: "Konwertuj OGG na MP3 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG na WAV",
|
||||
description: "Konwertuj OGG na WAV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV na MP3",
|
||||
description: "Konwertuj WAV na MP3 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 na WAV",
|
||||
description: "Konwertuj MP3 na WAV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC na MP3",
|
||||
description: "Konwertuj FLAC na MP3 online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel na CSV",
|
||||
description: "Konwertuj Excel na CSV online. Szybko, za darmo i prywatnie.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Zmiana rozmiaru",
|
||||
description:
|
||||
@@ -149,7 +483,7 @@ export const pl: TranslationKeys = {
|
||||
description: "Usuwanie niechcianych obiektów za pomocą AI",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / Wyodrębnianie tekstu",
|
||||
name: "Wyodrębnij tekst z obrazu (OCR)",
|
||||
description: "Wyodrębnianie tekstu z obrazów z dokładnością AI",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2757,6 +3091,14 @@ export const pl: TranslationKeys = {
|
||||
"Pobierz archiwum diagnostyczne z zanonimizowaną konfiguracją, logami i danymi zadań do rozwiązywania problemów.",
|
||||
supportBundleFailed: "Nie udało się pobrać pakietu diagnostycznego.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Prywatność",
|
||||
description:
|
||||
"Anonimowe analizy produktu pomagają ulepszać SnapOtter. Są domyślnie włączone i ustawiane dla całej instancji przez administratora.",
|
||||
analyticsLabel: "Anonimowe analizy produktu",
|
||||
analyticsDescription:
|
||||
"Udostępniaj anonimowe dane o użyciu (które narzędzia są uruchamiane, czasy trwania, kody błędów) oraz raporty o awariach. Bez adresu IP, bez tożsamości konta. Nigdy nie zbieramy nazw plików, ich zawartości, wyników OCR, metadanych obrazów ani tekstu dokumentów. Wyłącz tę opcję, aby wszystko zatrzymać.",
|
||||
},
|
||||
security: {
|
||||
heading: "Bezpieczeństwo",
|
||||
description: "Ustawienia hasła i uwierzytelniania.",
|
||||
@@ -3056,7 +3398,7 @@ export const pl: TranslationKeys = {
|
||||
heading: "Informacje",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"Samoobsługowy pakiet narzędzi do przetwarzania plików z priorytetem prywatności. 157 narzędzi. Konwersja, kompresja, edycja, transkrypcja, OCR i automatyzacja procesów obrazów, wideo, audio, dokumentów i danych bez wysyłania danych do chmury.",
|
||||
"Samoobsługowy pakiet narzędzi do przetwarzania plików z priorytetem prywatności. 240 narzędzi. Konwersja, kompresja, edycja, transkrypcja, OCR i automatyzacja procesów obrazów, wideo, audio, dokumentów i danych bez wysyłania danych do chmury.",
|
||||
versionLabel: "Wersja:",
|
||||
linksHeading: "Odnośniki",
|
||||
githubLink: "Repozytorium GitHub",
|
||||
@@ -3117,7 +3459,7 @@ export const pl: TranslationKeys = {
|
||||
"Bez ograniczeń. Bez ukrytych limitów.",
|
||||
"Działa w pełni offline.",
|
||||
"Przetwarzanie wsadowe bez ograniczeń.",
|
||||
"157 narzędzi.",
|
||||
"240 narzędzi.",
|
||||
"15 modeli AI. Na Twoim sprzęcie.",
|
||||
"Błyskawiczna szybkość. Oparte na Sharp.",
|
||||
"Gotowe do sieci izolowanych.",
|
||||
@@ -3182,6 +3524,7 @@ export const pl: TranslationKeys = {
|
||||
toolPalette: "Paleta narzędzi",
|
||||
clickToAdd: "Kliknij, aby dodać do Pipeline",
|
||||
savedLabel: "Zapisane",
|
||||
templatesLabel: "Szablony",
|
||||
importButton: "Importuj",
|
||||
noSavedPipelines: "Brak zapisanych Pipeline",
|
||||
stepCount: "({count} krok)",
|
||||
@@ -3468,4 +3811,45 @@ export const pl: TranslationKeys = {
|
||||
navigationMenu: "Nawigacja",
|
||||
homeLink: "Strona główna SnapOtter",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Optymalizuj pod kątem sieci",
|
||||
description:
|
||||
"Zmień rozmiar, przekonwertuj na WebP i skompresuj obraz, aby szybko wczytywał się w sieci.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "Przyjazny dla e-maila",
|
||||
description:
|
||||
"Zmniejsz obraz tak, aby był wystarczająco mały do dołączenia do wiadomości e-mail.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Czyszczenie prywatności",
|
||||
description: "Usuń dane EXIF, GPS i inne metadane, a następnie skompresuj.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Kwadrat społecznościowy",
|
||||
description:
|
||||
"Przytnij do kwadratu 1:1 i skompresuj na potrzeby wpisów w mediach społecznościowych.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Znak wodny wsadowo",
|
||||
description: "Nanieś tekstowy znak wodny na obrazy, a następnie skompresuj.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "Zmniejsz PDF do e-maila",
|
||||
description: "Spłaszcz i mocno skompresuj plik PDF, aby zmieścił się w limitach e-maila.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Dopracowanie podcastu",
|
||||
description: "Usuń ciszę, znormalizuj głośność i dodaj przejścia do nagrania.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Wytnij obiekt",
|
||||
description: "Usuń tło za pomocą AI i skompresuj przezroczysty wycinek.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Odnów stare zdjęcie",
|
||||
description: "Napraw, pokoloruj i powiększ stare lub uszkodzone zdjęcie za pomocą AI.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const ptBR: TranslationKeys = {
|
||||
documentsAndFiles: "PDF & Arquivos",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG para PNG",
|
||||
description: "Converta JPG para PNG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG para JPG",
|
||||
description: "Converta PNG para JPG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG para WebP",
|
||||
description: "Converta JPG para WebP online. Rápido, grátis e privado.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG para WebP",
|
||||
description: "Converta PNG para WebP online. Rápido, grátis e privado.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP para JPG",
|
||||
description: "Converta WebP para JPG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP para PNG",
|
||||
description: "Converta WebP para PNG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG para AVIF",
|
||||
description: "Converta JPG para AVIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG para AVIF",
|
||||
description: "Converta PNG para AVIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP para AVIF",
|
||||
description: "Converta WebP para AVIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC para JPG",
|
||||
description: "Converta HEIC para JPG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC para PNG",
|
||||
description: "Converta HEIC para PNG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC para AVIF",
|
||||
description: "Converta HEIC para AVIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG para GIF",
|
||||
description: "Converta JPG para GIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG para GIF",
|
||||
description: "Converta PNG para GIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF para JPG",
|
||||
description: "Converta GIF para JPG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF para PNG",
|
||||
description: "Converta GIF para PNG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP para GIF",
|
||||
description: "Converta WebP para GIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG para TIFF",
|
||||
description: "Converta JPG para TIFF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG para TIFF",
|
||||
description: "Converta PNG para TIFF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF para JPG",
|
||||
description: "Converta TIFF para JPG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF para PNG",
|
||||
description: "Converta TIFF para PNG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD para JPG",
|
||||
description: "Converta PSD para JPG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD para PNG",
|
||||
description: "Converta PSD para PNG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG para EPS",
|
||||
description: "Converta PNG para EPS online. Rápido, grátis e privado.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG para EPS",
|
||||
description: "Converta JPG para EPS online. Rápido, grátis e privado.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS para PNG",
|
||||
description: "Converta EPS para PNG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS para JPG",
|
||||
description: "Converta EPS para JPG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG para SVG",
|
||||
description: "Converta PNG para SVG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG para SVG",
|
||||
description: "Converta JPG para SVG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF para SVG",
|
||||
description: "Converta TIFF para SVG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD para SVG",
|
||||
description: "Converta PSD para SVG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS para SVG",
|
||||
description: "Converta EPS para SVG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG para PNG",
|
||||
description: "Converta SVG para PNG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG para JPG",
|
||||
description: "Converta SVG para JPG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG para PDF",
|
||||
description: "Converta JPG para PDF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG para PDF",
|
||||
description: "Converta PNG para PDF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC para PDF",
|
||||
description: "Converta HEIC para PDF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF para PDF",
|
||||
description: "Converta TIFF para PDF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP para PDF",
|
||||
description: "Converta WebP para PDF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF para PDF",
|
||||
description: "Converta GIF para PDF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS para PDF",
|
||||
description: "Converta EPS para PDF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF para JPG",
|
||||
description: "Converta PDF para JPG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF para PNG",
|
||||
description: "Converta PDF para PNG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF para TIFF",
|
||||
description: "Converta PDF para TIFF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV para MP4",
|
||||
description: "Converta MOV para MP4 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM para MP4",
|
||||
description: "Converta WEBM para MP4 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV para MP4",
|
||||
description: "Converta MKV para MP4 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI para MP4",
|
||||
description: "Converta AVI para MP4 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 para MOV",
|
||||
description: "Converta MP4 para MOV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 para WEBM",
|
||||
description: "Converta MP4 para WEBM online. Rápido, grátis e privado.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM para MOV",
|
||||
description: "Converta WEBM para MOV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV para MOV",
|
||||
description: "Converta MKV para MOV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI para MOV",
|
||||
description: "Converta AVI para MOV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 para AVI",
|
||||
description: "Converta MP4 para AVI online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV para AVI",
|
||||
description: "Converta MOV para AVI online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV para AVI",
|
||||
description: "Converta MKV para AVI online. Rápido, grátis e privado.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI para MKV",
|
||||
description: "Converta AVI para MKV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 para GIF",
|
||||
description: "Converta MP4 para GIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV para GIF",
|
||||
description: "Converta MOV para GIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV para GIF",
|
||||
description: "Converta MKV para GIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI para GIF",
|
||||
description: "Converta AVI para GIF online. Rápido, grátis e privado.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF para MP4",
|
||||
description: "Converta GIF para MP4 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF para WEBM",
|
||||
description: "Converta GIF para WEBM online. Rápido, grátis e privado.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF para MOV",
|
||||
description: "Converta GIF para MOV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 para MP3",
|
||||
description: "Converta MP4 para MP3 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV para MP3",
|
||||
description: "Converta MOV para MP3 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV para MP3",
|
||||
description: "Converta MKV para MP3 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM para MP3",
|
||||
description: "Converta WEBM para MP3 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI para MP3",
|
||||
description: "Converta AVI para MP3 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 para WAV",
|
||||
description: "Converta MP4 para WAV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV para WAV",
|
||||
description: "Converta MOV para WAV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 para OGG",
|
||||
description: "Converta MP4 para OGG online. Rápido, grátis e privado.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A para MP3",
|
||||
description: "Converta M4A para MP3 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A para WAV",
|
||||
description: "Converta M4A para WAV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC para MP3",
|
||||
description: "Converta AAC para MP3 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC para WAV",
|
||||
description: "Converta AAC para WAV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC para FLAC",
|
||||
description: "Converta AAC para FLAC online. Rápido, grátis e privado.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG para MP3",
|
||||
description: "Converta OGG para MP3 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG para WAV",
|
||||
description: "Converta OGG para WAV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV para MP3",
|
||||
description: "Converta WAV para MP3 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 para WAV",
|
||||
description: "Converta MP3 para WAV online. Rápido, grátis e privado.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC para MP3",
|
||||
description: "Converta FLAC para MP3 online. Rápido, grátis e privado.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel para CSV",
|
||||
description: "Converta Excel para CSV online. Rápido, grátis e privado.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Redimensionar",
|
||||
description: "Redimensione por pixels, porcentagem ou presets de redes sociais",
|
||||
@@ -149,7 +483,7 @@ export const ptBR: TranslationKeys = {
|
||||
description: "Remova objetos indesejados com AI",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / Extração de texto",
|
||||
name: "Extrair texto de imagem (OCR)",
|
||||
description: "Extraia texto de imagens com precisão alimentada por AI",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2754,6 +3088,14 @@ export const ptBR: TranslationKeys = {
|
||||
"Baixe um ZIP de diagnóstico com configurações ocultadas, logs e dados de tarefas para solução de problemas.",
|
||||
supportBundleFailed: "Falha ao baixar o pacote de suporte.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Privacidade",
|
||||
description:
|
||||
"As análises de produto anônimas ajudam a melhorar o SnapOtter. Elas ficam ativadas por padrão e são configuradas para toda a instância por um administrador.",
|
||||
analyticsLabel: "Análises de produto anônimas",
|
||||
analyticsDescription:
|
||||
"Compartilhe dados de uso anônimos (quais ferramentas são executadas, durações, códigos de erro) e relatórios de falha. Sem endereço IP, sem identidade de conta. Nunca coletamos os nomes dos seus arquivos, o conteúdo dos arquivos, a saída de OCR, os metadados de imagem ou o texto de documentos. Desative isso para interromper tudo.",
|
||||
},
|
||||
security: {
|
||||
heading: "Segurança",
|
||||
description: "Configurações de senha e autenticação.",
|
||||
@@ -3049,7 +3391,7 @@ export const ptBR: TranslationKeys = {
|
||||
heading: "Sobre",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"Uma suite de processamento de arquivos auto-hospedada e focada em privacidade, com 157 ferramentas. Converta, comprima, edite, transcreva, OCR, e automatize fluxos de trabalho de imagem, vídeo, áudio, documentos e dados sem enviar dados para a nuvem.",
|
||||
"Uma suite de processamento de arquivos auto-hospedada e focada em privacidade, com 240 ferramentas. Converta, comprima, edite, transcreva, OCR, e automatize fluxos de trabalho de imagem, vídeo, áudio, documentos e dados sem enviar dados para a nuvem.",
|
||||
versionLabel: "Versão:",
|
||||
linksHeading: "Links",
|
||||
githubLink: "Repositório no GitHub",
|
||||
@@ -3110,7 +3452,7 @@ export const ptBR: TranslationKeys = {
|
||||
"Sem limites. Sem tetos ocultos.",
|
||||
"Funciona totalmente offline.",
|
||||
"Processamento em lote ilimitado.",
|
||||
"157 ferramentas.",
|
||||
"240 ferramentas.",
|
||||
"15 modelos AI. Seu hardware.",
|
||||
"Ultra rápido. Construído sobre Sharp.",
|
||||
"Pronto para redes isoladas.",
|
||||
@@ -3174,6 +3516,7 @@ export const ptBR: TranslationKeys = {
|
||||
toolPalette: "Paleta de ferramentas",
|
||||
clickToAdd: "Clique para adicionar ao Pipeline",
|
||||
savedLabel: "Salvo",
|
||||
templatesLabel: "Modelos",
|
||||
importButton: "Importar",
|
||||
noSavedPipelines: "Nenhum Pipeline salvo",
|
||||
stepCount: "({count} passo)",
|
||||
@@ -3460,6 +3803,46 @@ export const ptBR: TranslationKeys = {
|
||||
navigationMenu: "Navegação",
|
||||
homeLink: "Início do SnapOtter",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Otimizar para a web",
|
||||
description:
|
||||
"Redimensione, converta para WebP e comprima uma imagem para carregamento rápido na web.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "Compatível com e-mail",
|
||||
description: "Reduza uma imagem para que fique pequena o suficiente para anexar a um e-mail.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Limpeza de privacidade",
|
||||
description: "Remova EXIF, GPS e outros metadados e, em seguida, comprima.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Quadrado para redes sociais",
|
||||
description: "Recorte em um quadrado 1:1 e comprima para publicações nas redes sociais.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Marca-d'água em lote",
|
||||
description: "Aplique uma marca-d'água de texto nas imagens e, em seguida, comprima.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "Reduzir PDF para e-mail",
|
||||
description:
|
||||
"Achate e comprima um PDF de forma agressiva para respeitar os limites de e-mail.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Acabamento de podcast",
|
||||
description: "Remova os silêncios, normalize o volume e adicione fades a uma gravação.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Recortar o objeto",
|
||||
description: "Remova o fundo com IA e comprima o recorte transparente.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Restaurar foto antiga",
|
||||
description: "Repare, colorize e amplie uma foto antiga ou danificada com IA.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default ptBR;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const ru: TranslationKeys = {
|
||||
documentsAndFiles: "PDF и файлы",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG в PNG",
|
||||
description: "Конвертируйте JPG в PNG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG в JPG",
|
||||
description: "Конвертируйте PNG в JPG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG в WebP",
|
||||
description: "Конвертируйте JPG в WebP онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG в WebP",
|
||||
description: "Конвертируйте PNG в WebP онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP в JPG",
|
||||
description: "Конвертируйте WebP в JPG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP в PNG",
|
||||
description: "Конвертируйте WebP в PNG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG в AVIF",
|
||||
description: "Конвертируйте JPG в AVIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG в AVIF",
|
||||
description: "Конвертируйте PNG в AVIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP в AVIF",
|
||||
description: "Конвертируйте WebP в AVIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC в JPG",
|
||||
description: "Конвертируйте HEIC в JPG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC в PNG",
|
||||
description: "Конвертируйте HEIC в PNG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC в AVIF",
|
||||
description: "Конвертируйте HEIC в AVIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG в GIF",
|
||||
description: "Конвертируйте JPG в GIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG в GIF",
|
||||
description: "Конвертируйте PNG в GIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF в JPG",
|
||||
description: "Конвертируйте GIF в JPG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF в PNG",
|
||||
description: "Конвертируйте GIF в PNG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP в GIF",
|
||||
description: "Конвертируйте WebP в GIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG в TIFF",
|
||||
description: "Конвертируйте JPG в TIFF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG в TIFF",
|
||||
description: "Конвертируйте PNG в TIFF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF в JPG",
|
||||
description: "Конвертируйте TIFF в JPG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF в PNG",
|
||||
description: "Конвертируйте TIFF в PNG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD в JPG",
|
||||
description: "Конвертируйте PSD в JPG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD в PNG",
|
||||
description: "Конвертируйте PSD в PNG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG в EPS",
|
||||
description: "Конвертируйте PNG в EPS онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG в EPS",
|
||||
description: "Конвертируйте JPG в EPS онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS в PNG",
|
||||
description: "Конвертируйте EPS в PNG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS в JPG",
|
||||
description: "Конвертируйте EPS в JPG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG в SVG",
|
||||
description: "Конвертируйте PNG в SVG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG в SVG",
|
||||
description: "Конвертируйте JPG в SVG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF в SVG",
|
||||
description: "Конвертируйте TIFF в SVG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD в SVG",
|
||||
description: "Конвертируйте PSD в SVG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS в SVG",
|
||||
description: "Конвертируйте EPS в SVG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG в PNG",
|
||||
description: "Конвертируйте SVG в PNG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG в JPG",
|
||||
description: "Конвертируйте SVG в JPG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG в PDF",
|
||||
description: "Конвертируйте JPG в PDF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG в PDF",
|
||||
description: "Конвертируйте PNG в PDF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC в PDF",
|
||||
description: "Конвертируйте HEIC в PDF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF в PDF",
|
||||
description: "Конвертируйте TIFF в PDF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP в PDF",
|
||||
description: "Конвертируйте WebP в PDF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF в PDF",
|
||||
description: "Конвертируйте GIF в PDF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS в PDF",
|
||||
description: "Конвертируйте EPS в PDF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF в JPG",
|
||||
description: "Конвертируйте PDF в JPG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF в PNG",
|
||||
description: "Конвертируйте PDF в PNG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF в TIFF",
|
||||
description: "Конвертируйте PDF в TIFF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV в MP4",
|
||||
description: "Конвертируйте MOV в MP4 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM в MP4",
|
||||
description: "Конвертируйте WEBM в MP4 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV в MP4",
|
||||
description: "Конвертируйте MKV в MP4 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI в MP4",
|
||||
description: "Конвертируйте AVI в MP4 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 в MOV",
|
||||
description: "Конвертируйте MP4 в MOV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 в WEBM",
|
||||
description: "Конвертируйте MP4 в WEBM онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM в MOV",
|
||||
description: "Конвертируйте WEBM в MOV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV в MOV",
|
||||
description: "Конвертируйте MKV в MOV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI в MOV",
|
||||
description: "Конвертируйте AVI в MOV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 в AVI",
|
||||
description: "Конвертируйте MP4 в AVI онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV в AVI",
|
||||
description: "Конвертируйте MOV в AVI онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV в AVI",
|
||||
description: "Конвертируйте MKV в AVI онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI в MKV",
|
||||
description: "Конвертируйте AVI в MKV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 в GIF",
|
||||
description: "Конвертируйте MP4 в GIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV в GIF",
|
||||
description: "Конвертируйте MOV в GIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV в GIF",
|
||||
description: "Конвертируйте MKV в GIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI в GIF",
|
||||
description: "Конвертируйте AVI в GIF онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF в MP4",
|
||||
description: "Конвертируйте GIF в MP4 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF в WEBM",
|
||||
description: "Конвертируйте GIF в WEBM онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF в MOV",
|
||||
description: "Конвертируйте GIF в MOV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 в MP3",
|
||||
description: "Конвертируйте MP4 в MP3 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV в MP3",
|
||||
description: "Конвертируйте MOV в MP3 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV в MP3",
|
||||
description: "Конвертируйте MKV в MP3 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM в MP3",
|
||||
description: "Конвертируйте WEBM в MP3 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI в MP3",
|
||||
description: "Конвертируйте AVI в MP3 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 в WAV",
|
||||
description: "Конвертируйте MP4 в WAV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV в WAV",
|
||||
description: "Конвертируйте MOV в WAV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 в OGG",
|
||||
description: "Конвертируйте MP4 в OGG онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A в MP3",
|
||||
description: "Конвертируйте M4A в MP3 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A в WAV",
|
||||
description: "Конвертируйте M4A в WAV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC в MP3",
|
||||
description: "Конвертируйте AAC в MP3 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC в WAV",
|
||||
description: "Конвертируйте AAC в WAV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC в FLAC",
|
||||
description: "Конвертируйте AAC в FLAC онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG в MP3",
|
||||
description: "Конвертируйте OGG в MP3 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG в WAV",
|
||||
description: "Конвертируйте OGG в WAV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV в MP3",
|
||||
description: "Конвертируйте WAV в MP3 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 в WAV",
|
||||
description: "Конвертируйте MP3 в WAV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC в MP3",
|
||||
description: "Конвертируйте FLAC в MP3 онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel в CSV",
|
||||
description: "Конвертируйте Excel в CSV онлайн. Быстро, бесплатно и приватно.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Изменение размера",
|
||||
description: "Изменение размера в пикселях, процентах или по шаблонам соцсетей",
|
||||
@@ -150,7 +484,7 @@ export const ru: TranslationKeys = {
|
||||
description: "Удаление нежелательных объектов с помощью AI",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / Извлечение текста",
|
||||
name: "Извлечь текст из изображения (OCR)",
|
||||
description: "Извлечение текста из изображений с точностью AI",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2751,6 +3085,14 @@ export const ru: TranslationKeys = {
|
||||
"Скачать архив с обезличенной конфигурацией, логами и данными задач для диагностики.",
|
||||
supportBundleFailed: "Не удалось скачать диагностический архив.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Конфиденциальность",
|
||||
description:
|
||||
"Анонимная продуктовая аналитика помогает улучшать SnapOtter. Она включена по умолчанию и настраивается для всего экземпляра администратором.",
|
||||
analyticsLabel: "Анонимная продуктовая аналитика",
|
||||
analyticsDescription:
|
||||
"Делитесь анонимными данными об использовании (какие инструменты запускаются, длительность, коды ошибок) и отчётами о сбоях. Без IP-адреса и без идентификации аккаунта. Мы никогда не собираем имена ваших файлов, их содержимое, результаты OCR, метаданные изображений или текст документов. Отключите это, чтобы остановить всё.",
|
||||
},
|
||||
security: {
|
||||
heading: "Безопасность",
|
||||
description: "Настройки пароля и аутентификации.",
|
||||
@@ -3046,7 +3388,7 @@ export const ru: TranslationKeys = {
|
||||
heading: "О программе",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"Самостоятельно размещаемый набор инструментов для обработки файлов с приоритетом конфиденциальности. 157 инструментов. Конвертация, сжатие, редактирование, транскрипция, OCR и автоматизация рабочих процессов с изображениями, видео, аудио, документами и данными без отправки данных в облако.",
|
||||
"Самостоятельно размещаемый набор инструментов для обработки файлов с приоритетом конфиденциальности. 240 инструментов. Конвертация, сжатие, редактирование, транскрипция, OCR и автоматизация рабочих процессов с изображениями, видео, аудио, документами и данными без отправки данных в облако.",
|
||||
versionLabel: "Версия:",
|
||||
linksHeading: "Ссылки",
|
||||
githubLink: "Репозиторий GitHub",
|
||||
@@ -3105,7 +3447,7 @@ export const ru: TranslationKeys = {
|
||||
"Без ограничений. Без скрытых лимитов.",
|
||||
"Работает полностью офлайн.",
|
||||
"Пакетная обработка без ограничений.",
|
||||
"157 инструментов.",
|
||||
"240 инструментов.",
|
||||
"16 AI-моделей. На Вашем оборудовании.",
|
||||
"Молниеносная скорость. На базе Sharp.",
|
||||
"Готов к изолированным сетям.",
|
||||
@@ -3170,6 +3512,7 @@ export const ru: TranslationKeys = {
|
||||
toolPalette: "Палитра инструментов",
|
||||
clickToAdd: "Нажмите для добавления в Pipeline",
|
||||
savedLabel: "Сохранённые",
|
||||
templatesLabel: "Шаблоны",
|
||||
importButton: "Импорт",
|
||||
noSavedPipelines: "Нет сохранённых Pipeline",
|
||||
stepCount: "({count} шаг)",
|
||||
@@ -3456,4 +3799,44 @@ export const ru: TranslationKeys = {
|
||||
navigationMenu: "Навигация",
|
||||
homeLink: "Главная SnapOtter",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Оптимизация для веба",
|
||||
description:
|
||||
"Измените размер, преобразуйте в WebP и сожмите изображение для быстрой загрузки в вебе.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "Для электронной почты",
|
||||
description: "Уменьшите изображение, чтобы его можно было прикрепить к электронному письму.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Очистка конфиденциальности",
|
||||
description: "Удалите EXIF, GPS и другие метаданные, затем сожмите.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Квадрат для соцсетей",
|
||||
description: "Обрежьте до квадрата 1:1 и сожмите для публикаций в соцсетях.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Водяной знак пакетно",
|
||||
description: "Нанесите текстовый водяной знак на изображения, затем сожмите.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "Уменьшить PDF для почты",
|
||||
description: "Сведите и сильно сожмите PDF, чтобы он уложился в лимиты электронной почты.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Шлифовка подкаста",
|
||||
description: "Удалите тишину, нормализуйте громкость и добавьте плавные переходы к записи.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Вырезать объект",
|
||||
description: "Удалите фон с помощью ИИ и сожмите прозрачную вырезку.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Восстановить старое фото",
|
||||
description:
|
||||
"Восстановите, раскрасьте и увеличьте старое или повреждённое фото с помощью ИИ.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -73,6 +73,340 @@ export const sv: TranslationKeys = {
|
||||
documentsAndFiles: "PDF & Filer",
|
||||
},
|
||||
tools: {
|
||||
// BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)
|
||||
"jpg-to-png": {
|
||||
name: "JPG till PNG",
|
||||
description: "Konvertera JPG till PNG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"png-to-jpg": {
|
||||
name: "PNG till JPG",
|
||||
description: "Konvertera PNG till JPG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"jpg-to-webp": {
|
||||
name: "JPG till WebP",
|
||||
description: "Konvertera JPG till WebP online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"png-to-webp": {
|
||||
name: "PNG till WebP",
|
||||
description: "Konvertera PNG till WebP online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"webp-to-jpg": {
|
||||
name: "WebP till JPG",
|
||||
description: "Konvertera WebP till JPG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"webp-to-png": {
|
||||
name: "WebP till PNG",
|
||||
description: "Konvertera WebP till PNG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"jpg-to-avif": {
|
||||
name: "JPG till AVIF",
|
||||
description: "Konvertera JPG till AVIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"png-to-avif": {
|
||||
name: "PNG till AVIF",
|
||||
description: "Konvertera PNG till AVIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"webp-to-avif": {
|
||||
name: "WebP till AVIF",
|
||||
description: "Konvertera WebP till AVIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"heic-to-jpg": {
|
||||
name: "HEIC till JPG",
|
||||
description: "Konvertera HEIC till JPG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"heic-to-png": {
|
||||
name: "HEIC till PNG",
|
||||
description: "Konvertera HEIC till PNG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"heic-to-avif": {
|
||||
name: "HEIC till AVIF",
|
||||
description: "Konvertera HEIC till AVIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"jpg-to-gif": {
|
||||
name: "JPG till GIF",
|
||||
description: "Konvertera JPG till GIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"png-to-gif": {
|
||||
name: "PNG till GIF",
|
||||
description: "Konvertera PNG till GIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"gif-to-jpg": {
|
||||
name: "GIF till JPG",
|
||||
description: "Konvertera GIF till JPG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"gif-to-png": {
|
||||
name: "GIF till PNG",
|
||||
description: "Konvertera GIF till PNG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"webp-to-gif": {
|
||||
name: "WebP till GIF",
|
||||
description: "Konvertera WebP till GIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"jpg-to-tiff": {
|
||||
name: "JPG till TIFF",
|
||||
description: "Konvertera JPG till TIFF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"png-to-tiff": {
|
||||
name: "PNG till TIFF",
|
||||
description: "Konvertera PNG till TIFF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"tiff-to-jpg": {
|
||||
name: "TIFF till JPG",
|
||||
description: "Konvertera TIFF till JPG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"tiff-to-png": {
|
||||
name: "TIFF till PNG",
|
||||
description: "Konvertera TIFF till PNG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"psd-to-jpg": {
|
||||
name: "PSD till JPG",
|
||||
description: "Konvertera PSD till JPG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"psd-to-png": {
|
||||
name: "PSD till PNG",
|
||||
description: "Konvertera PSD till PNG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"png-to-eps": {
|
||||
name: "PNG till EPS",
|
||||
description: "Konvertera PNG till EPS online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"jpg-to-eps": {
|
||||
name: "JPG till EPS",
|
||||
description: "Konvertera JPG till EPS online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"eps-to-png": {
|
||||
name: "EPS till PNG",
|
||||
description: "Konvertera EPS till PNG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"eps-to-jpg": {
|
||||
name: "EPS till JPG",
|
||||
description: "Konvertera EPS till JPG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"png-to-svg": {
|
||||
name: "PNG till SVG",
|
||||
description: "Konvertera PNG till SVG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"jpg-to-svg": {
|
||||
name: "JPG till SVG",
|
||||
description: "Konvertera JPG till SVG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"tiff-to-svg": {
|
||||
name: "TIFF till SVG",
|
||||
description: "Konvertera TIFF till SVG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"psd-to-svg": {
|
||||
name: "PSD till SVG",
|
||||
description: "Konvertera PSD till SVG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"eps-to-svg": {
|
||||
name: "EPS till SVG",
|
||||
description: "Konvertera EPS till SVG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"svg-to-png": {
|
||||
name: "SVG till PNG",
|
||||
description: "Konvertera SVG till PNG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"svg-to-jpg": {
|
||||
name: "SVG till JPG",
|
||||
description: "Konvertera SVG till JPG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"jpg-to-pdf": {
|
||||
name: "JPG till PDF",
|
||||
description: "Konvertera JPG till PDF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"png-to-pdf": {
|
||||
name: "PNG till PDF",
|
||||
description: "Konvertera PNG till PDF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"heic-to-pdf": {
|
||||
name: "HEIC till PDF",
|
||||
description: "Konvertera HEIC till PDF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"tiff-to-pdf": {
|
||||
name: "TIFF till PDF",
|
||||
description: "Konvertera TIFF till PDF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"webp-to-pdf": {
|
||||
name: "WebP till PDF",
|
||||
description: "Konvertera WebP till PDF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"gif-to-pdf": {
|
||||
name: "GIF till PDF",
|
||||
description: "Konvertera GIF till PDF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"eps-to-pdf": {
|
||||
name: "EPS till PDF",
|
||||
description: "Konvertera EPS till PDF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"pdf-to-jpg": {
|
||||
name: "PDF till JPG",
|
||||
description: "Konvertera PDF till JPG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"pdf-to-png": {
|
||||
name: "PDF till PNG",
|
||||
description: "Konvertera PDF till PNG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"pdf-to-tiff": {
|
||||
name: "PDF till TIFF",
|
||||
description: "Konvertera PDF till TIFF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mov-to-mp4": {
|
||||
name: "MOV till MP4",
|
||||
description: "Konvertera MOV till MP4 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"webm-to-mp4": {
|
||||
name: "WEBM till MP4",
|
||||
description: "Konvertera WEBM till MP4 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mkv-to-mp4": {
|
||||
name: "MKV till MP4",
|
||||
description: "Konvertera MKV till MP4 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"avi-to-mp4": {
|
||||
name: "AVI till MP4",
|
||||
description: "Konvertera AVI till MP4 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mp4-to-mov": {
|
||||
name: "MP4 till MOV",
|
||||
description: "Konvertera MP4 till MOV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mp4-to-webm": {
|
||||
name: "MP4 till WEBM",
|
||||
description: "Konvertera MP4 till WEBM online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"webm-to-mov": {
|
||||
name: "WEBM till MOV",
|
||||
description: "Konvertera WEBM till MOV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mkv-to-mov": {
|
||||
name: "MKV till MOV",
|
||||
description: "Konvertera MKV till MOV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"avi-to-mov": {
|
||||
name: "AVI till MOV",
|
||||
description: "Konvertera AVI till MOV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mp4-to-avi": {
|
||||
name: "MP4 till AVI",
|
||||
description: "Konvertera MP4 till AVI online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mov-to-avi": {
|
||||
name: "MOV till AVI",
|
||||
description: "Konvertera MOV till AVI online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mkv-to-avi": {
|
||||
name: "MKV till AVI",
|
||||
description: "Konvertera MKV till AVI online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"avi-to-mkv": {
|
||||
name: "AVI till MKV",
|
||||
description: "Konvertera AVI till MKV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mp4-to-gif": {
|
||||
name: "MP4 till GIF",
|
||||
description: "Konvertera MP4 till GIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mov-to-gif": {
|
||||
name: "MOV till GIF",
|
||||
description: "Konvertera MOV till GIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mkv-to-gif": {
|
||||
name: "MKV till GIF",
|
||||
description: "Konvertera MKV till GIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"avi-to-gif": {
|
||||
name: "AVI till GIF",
|
||||
description: "Konvertera AVI till GIF online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"gif-to-mp4": {
|
||||
name: "GIF till MP4",
|
||||
description: "Konvertera GIF till MP4 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"gif-to-webm": {
|
||||
name: "GIF till WEBM",
|
||||
description: "Konvertera GIF till WEBM online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"gif-to-mov": {
|
||||
name: "GIF till MOV",
|
||||
description: "Konvertera GIF till MOV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mp4-to-mp3": {
|
||||
name: "MP4 till MP3",
|
||||
description: "Konvertera MP4 till MP3 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mov-to-mp3": {
|
||||
name: "MOV till MP3",
|
||||
description: "Konvertera MOV till MP3 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mkv-to-mp3": {
|
||||
name: "MKV till MP3",
|
||||
description: "Konvertera MKV till MP3 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"webm-to-mp3": {
|
||||
name: "WEBM till MP3",
|
||||
description: "Konvertera WEBM till MP3 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"avi-to-mp3": {
|
||||
name: "AVI till MP3",
|
||||
description: "Konvertera AVI till MP3 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mp4-to-wav": {
|
||||
name: "MP4 till WAV",
|
||||
description: "Konvertera MP4 till WAV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mov-to-wav": {
|
||||
name: "MOV till WAV",
|
||||
description: "Konvertera MOV till WAV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mp4-to-ogg": {
|
||||
name: "MP4 till OGG",
|
||||
description: "Konvertera MP4 till OGG online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"m4a-to-mp3": {
|
||||
name: "M4A till MP3",
|
||||
description: "Konvertera M4A till MP3 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"m4a-to-wav": {
|
||||
name: "M4A till WAV",
|
||||
description: "Konvertera M4A till WAV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"aac-to-mp3": {
|
||||
name: "AAC till MP3",
|
||||
description: "Konvertera AAC till MP3 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"aac-to-wav": {
|
||||
name: "AAC till WAV",
|
||||
description: "Konvertera AAC till WAV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"aac-to-flac": {
|
||||
name: "AAC till FLAC",
|
||||
description: "Konvertera AAC till FLAC online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"ogg-to-mp3": {
|
||||
name: "OGG till MP3",
|
||||
description: "Konvertera OGG till MP3 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"ogg-to-wav": {
|
||||
name: "OGG till WAV",
|
||||
description: "Konvertera OGG till WAV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"wav-to-mp3": {
|
||||
name: "WAV till MP3",
|
||||
description: "Konvertera WAV till MP3 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"mp3-to-wav": {
|
||||
name: "MP3 till WAV",
|
||||
description: "Konvertera MP3 till WAV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"flac-to-mp3": {
|
||||
name: "FLAC till MP3",
|
||||
description: "Konvertera FLAC till MP3 online. Snabbt, gratis och privat.",
|
||||
},
|
||||
"excel-to-csv": {
|
||||
name: "Excel till CSV",
|
||||
description: "Konvertera Excel till CSV online. Snabbt, gratis och privat.",
|
||||
},
|
||||
// END conversion-presets
|
||||
resize: {
|
||||
name: "Ändra storlek",
|
||||
description: "Ändra storlek efter pixlar, procent eller sociala medier-mallar",
|
||||
@@ -151,7 +485,7 @@ export const sv: TranslationKeys = {
|
||||
description: "Ta bort oönskade objekt med AI",
|
||||
},
|
||||
ocr: {
|
||||
name: "OCR / Textextraktion",
|
||||
name: "Extrahera text från bild (OCR)",
|
||||
description: "Extrahera text från bilder med AI-precision",
|
||||
},
|
||||
"ocr-pdf": {
|
||||
@@ -2748,6 +3082,14 @@ export const sv: TranslationKeys = {
|
||||
"Ladda ner en diagnostisk ZIP med maskerad konfiguration, loggar och jobbdata för felsökning.",
|
||||
supportBundleFailed: "Kunde inte ladda ner supportpaket.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Integritet",
|
||||
description:
|
||||
"Anonym produktanalys hjälper till att förbättra SnapOtter. Den är på som standard och ställs in för hela instansen av en administratör.",
|
||||
analyticsLabel: "Anonym produktanalys",
|
||||
analyticsDescription:
|
||||
"Dela anonyma användningsdata (vilka verktyg som körs, tidsåtgång, felkoder) och kraschrapporter. Ingen IP-adress, ingen kontoidentitet. Vi samlar aldrig in dina filnamn, filinnehåll, OCR-utdata, bildmetadata eller dokumenttext. Stäng av det här för att stoppa allt.",
|
||||
},
|
||||
security: {
|
||||
heading: "Säkerhet",
|
||||
description: "Lösenords- och autentiseringsinställningar.",
|
||||
@@ -3040,7 +3382,7 @@ export const sv: TranslationKeys = {
|
||||
heading: "Om",
|
||||
appName: "SnapOtter",
|
||||
appDescription:
|
||||
"En självhostad, integritetsfokuserad filbehandlingssvit med 157 verktyg. Konvertera, komprimera, redigera, transkribera, OCR, och automatisera bild-, video-, ljud-, dokument- och dataarbetsflöden utan att skicka data till molnet.",
|
||||
"En självhostad, integritetsfokuserad filbehandlingssvit med 240 verktyg. Konvertera, komprimera, redigera, transkribera, OCR, och automatisera bild-, video-, ljud-, dokument- och dataarbetsflöden utan att skicka data till molnet.",
|
||||
versionLabel: "Version:",
|
||||
linksHeading: "Länkar",
|
||||
githubLink: "GitHub-arkiv",
|
||||
@@ -3099,7 +3441,7 @@ export const sv: TranslationKeys = {
|
||||
"Inga gränser. Inga dolda tak.",
|
||||
"Fungerar helt offline.",
|
||||
"Obegränsad batchbearbetning.",
|
||||
"157 verktyg.",
|
||||
"240 verktyg.",
|
||||
"16 AI-modeller. Din hårdvara.",
|
||||
"Blixtsnabb. Byggd på Sharp.",
|
||||
"Fungerar i slutna nätverk.",
|
||||
@@ -3163,6 +3505,7 @@ export const sv: TranslationKeys = {
|
||||
toolPalette: "Verktygspalett",
|
||||
clickToAdd: "Klicka för att lägga till i pipeline",
|
||||
savedLabel: "Sparad",
|
||||
templatesLabel: "Mallar",
|
||||
importButton: "Importera",
|
||||
noSavedPipelines: "Inga sparade pipelines",
|
||||
stepCount: "({count} steg)",
|
||||
@@ -3448,4 +3791,44 @@ export const sv: TranslationKeys = {
|
||||
navigationMenu: "Navigering",
|
||||
homeLink: "SnapOtter hem",
|
||||
},
|
||||
pipelineTemplates: {
|
||||
"web-optimize": {
|
||||
name: "Webboptimera",
|
||||
description:
|
||||
"Ändra storlek, konvertera till WebP och komprimera en bild för snabb webbinläsning.",
|
||||
},
|
||||
"email-friendly": {
|
||||
name: "E-postvänlig",
|
||||
description:
|
||||
"Krymp en bild så att den blir tillräckligt liten för att bifoga i ett e-postmeddelande.",
|
||||
},
|
||||
"privacy-scrub": {
|
||||
name: "Integritetsrensning",
|
||||
description: "Ta bort EXIF, GPS och andra metadata, och komprimera sedan.",
|
||||
},
|
||||
"social-square": {
|
||||
name: "Social kvadrat",
|
||||
description: "Beskär till en kvadrat på 1:1 och komprimera för inlägg på sociala medier.",
|
||||
},
|
||||
"watermark-batch": {
|
||||
name: "Vattenstämpel i grupp",
|
||||
description: "Stämpla en textvattenstämpel på bilder och komprimera sedan.",
|
||||
},
|
||||
"shrink-pdf-email": {
|
||||
name: "Krymp PDF för e-post",
|
||||
description: "Platta ut och komprimera en PDF kraftigt så att den ryms inom e-postgränserna.",
|
||||
},
|
||||
"podcast-polish": {
|
||||
name: "Putsa podd",
|
||||
description: "Ta bort tystnad, normalisera ljudnivån och lägg till toningar i en inspelning.",
|
||||
},
|
||||
"cut-out-subject": {
|
||||
name: "Klipp ut motivet",
|
||||
description: "Ta bort bakgrunden med AI och komprimera den transparenta urklippsbilden.",
|
||||
},
|
||||
"restore-old-photo": {
|
||||
name: "Återställ gammalt foto",
|
||||
description: "Reparera, färglägg och skala upp ett gammalt eller skadat foto med AI.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||