diff --git a/apps/api-runner/node_modules/.bin/tsc b/apps/api-runner/node_modules/.bin/tsc index bc3dbe24..6d8d1011 100755 --- a/apps/api-runner/node_modules/.bin/tsc +++ b/apps/api-runner/node_modules/.bin/tsc @@ -1,17 +1,43 @@ #!/bin/sh basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") +basedir_win="$basedir" +exe="" +msys="" -case `uname` in - *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +case `uname -a` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir_win=`cygpath -w "$basedir"` + fi + exe=".exe" + msys="true" + ;; + *WSL2*) + if command -v wslpath > /dev/null 2>&1; then + basedir_win="$(wslpath -w "$basedir" 2> /dev/null)" + if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then + basedir_win="$basedir" + else + exe=".exe" + fi + fi + ;; esac if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/node_modules" + export NODE_PATH="/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/node_modules" else - export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/node_modules:$NODE_PATH" + export NODE_PATH="/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/node_modules:$NODE_PATH" fi -if [ -x "$basedir/node" ]; then +if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then + exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsc" "$@" +elif [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@" +elif command -v node >/dev/null 2>&1; then + exec node "$basedir/../typescript/bin/tsc" "$@" +elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then + exec node.exe "$basedir_win/../typescript/bin/tsc" "$@" else exec node "$basedir/../typescript/bin/tsc" "$@" fi +# cmd-shim-target=/Users/max/Works/Personal/mydevtools/apps/api-runner/node_modules/typescript/bin/tsc diff --git a/apps/api-runner/node_modules/.bin/tsserver b/apps/api-runner/node_modules/.bin/tsserver index 03c2f186..66a860ca 100755 --- a/apps/api-runner/node_modules/.bin/tsserver +++ b/apps/api-runner/node_modules/.bin/tsserver @@ -1,17 +1,43 @@ #!/bin/sh basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") +basedir_win="$basedir" +exe="" +msys="" -case `uname` in - *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +case `uname -a` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir_win=`cygpath -w "$basedir"` + fi + exe=".exe" + msys="true" + ;; + *WSL2*) + if command -v wslpath > /dev/null 2>&1; then + basedir_win="$(wslpath -w "$basedir" 2> /dev/null)" + if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then + basedir_win="$basedir" + else + exe=".exe" + fi + fi + ;; esac if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/node_modules" + export NODE_PATH="/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/node_modules" else - export NODE_PATH="/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools.tech/node_modules/.pnpm/node_modules:$NODE_PATH" + export NODE_PATH="/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/max/Works/Personal/mydevtools/node_modules/.pnpm/node_modules:$NODE_PATH" fi -if [ -x "$basedir/node" ]; then +if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then + exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsserver" "$@" +elif [ -x "$basedir/node" ]; then exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@" +elif command -v node >/dev/null 2>&1; then + exec node "$basedir/../typescript/bin/tsserver" "$@" +elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then + exec node.exe "$basedir_win/../typescript/bin/tsserver" "$@" else exec node "$basedir/../typescript/bin/tsserver" "$@" fi +# cmd-shim-target=/Users/max/Works/Personal/mydevtools/apps/api-runner/node_modules/typescript/bin/tsserver diff --git a/apps/backend/app/api/router.py b/apps/backend/app/api/router.py index 1b9361c8..47456993 100644 --- a/apps/backend/app/api/router.py +++ b/apps/backend/app/api/router.py @@ -23,6 +23,8 @@ from app.api.routes.redis_commander.api import router as redis_commander_router from app.api.routes.url_shortener.api import router as url_shortener_router from app.api.routes.dns_lookup.api import router as dns_lookup_router +from app.api.routes.whois.api import router as whois_router +from app.api.routes.webhook_tester.api import router as webhook_tester_router from app.api.routes.audit_log.api import router as audit_log_router from app.api.routes.workspaces.api import router as workspaces_router @@ -50,5 +52,7 @@ api_router.include_router(redis_commander_router) api_router.include_router(url_shortener_router) api_router.include_router(dns_lookup_router) +api_router.include_router(whois_router) +api_router.include_router(webhook_tester_router) api_router.include_router(audit_log_router) api_router.include_router(workspaces_router) diff --git a/apps/backend/app/api/routes/api_client/services.py b/apps/backend/app/api/routes/api_client/services.py index dac54d63..65001530 100644 --- a/apps/backend/app/api/routes/api_client/services.py +++ b/apps/backend/app/api/routes/api_client/services.py @@ -86,7 +86,6 @@ async def list_collections(*, ctx: WorkspaceContext) -> list[ApiClientCollection async def create_collection(ctx: WorkspaceContext, body: ApiClientCollectionCreate) -> ApiClientCollectionOut: doc: dict[str, Any] = { "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "name": body.name, @@ -133,7 +132,6 @@ async def list_environments(*, ctx: WorkspaceContext) -> list[ApiClientEnvironme async def create_environment(ctx: WorkspaceContext, body: ApiClientEnvironmentCreate) -> ApiClientEnvironmentOut: doc: dict[str, Any] = { "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "name": body.name, @@ -213,7 +211,6 @@ async def create_history(ctx: WorkspaceContext, body: ApiClientHistoryCreate) -> ts = body.timestamp if body.timestamp is not None else int(time.time() * 1000) doc: dict[str, Any] = { "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "method": body.method, @@ -257,7 +254,7 @@ async def clear_history(ctx: WorkspaceContext) -> None: # 2. Scoping by workspace_id would break the anonymous read path. # 3. The mock_id token (~144 bits of entropy) is the access control boundary. # Ownership is tracked by `created_by` only; the collection is NOT stamped with -# org_id / workspace_id / owner_uid. +# workspace_id / owner_uid. def _mock_doc_to_out(doc: dict[str, Any]) -> ApiClientPublicMockOut: @@ -315,7 +312,7 @@ async def get_public_mock_anonymous(mock_id: str) -> ApiClientPublicMockOut | No # # API_CLIENT_WORKSPACES stores the API Client tool's own "workspace" concept # (grouping of API requests) — completely separate from our global Workspaces -# feature. These are per-user data so they ARE stamped with org_id/workspace_id. +# feature. These are per-user data so they ARE stamped with workspace_id. def _ws_doc_to_out(doc: dict[str, Any]) -> ApiClientWorkspaceOut: @@ -340,7 +337,6 @@ async def list_workspaces(*, ctx: WorkspaceContext) -> list[ApiClientWorkspaceOu async def create_workspace(ctx: WorkspaceContext, body: ApiClientWorkspaceCreate) -> ApiClientWorkspaceOut: doc: dict[str, Any] = { "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "name": body.name, diff --git a/apps/backend/app/api/routes/api_key_vault/services.py b/apps/backend/app/api/routes/api_key_vault/services.py index 19193bbe..dfb5db18 100644 --- a/apps/backend/app/api/routes/api_key_vault/services.py +++ b/apps/backend/app/api/routes/api_key_vault/services.py @@ -52,7 +52,6 @@ async def create_entry(ctx: WorkspaceContext, body: ApiKeyEntryCreate) -> ApiKey doc: dict[str, Any] = { "_id": eid, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "encryptedData": body.encryptedData, diff --git a/apps/backend/app/api/routes/bookmarks/services.py b/apps/backend/app/api/routes/bookmarks/services.py index bcf3997c..9a066659 100644 --- a/apps/backend/app/api/routes/bookmarks/services.py +++ b/apps/backend/app/api/routes/bookmarks/services.py @@ -93,7 +93,6 @@ async def create_bookmark(ctx: WorkspaceContext, body: BookmarkCreate) -> Bookma doc: dict[str, Any] = { "_id": bid, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "title": body.title, @@ -155,7 +154,6 @@ async def import_bookmarks(ctx: WorkspaceContext, body: BookmarkImportBody) -> d doc = { "_id": fid, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "name": folder.name, @@ -174,7 +172,6 @@ async def import_bookmarks(ctx: WorkspaceContext, body: BookmarkImportBody) -> d doc = { "_id": bid, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "title": bookmark.title, @@ -237,7 +234,6 @@ async def create_folder(ctx: WorkspaceContext, body: BookmarkFolderCreate) -> Bo doc: dict[str, Any] = { "_id": fid, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "name": body.name, diff --git a/apps/backend/app/api/routes/code_snippets/services.py b/apps/backend/app/api/routes/code_snippets/services.py index 13862f4c..8ce4a00d 100644 --- a/apps/backend/app/api/routes/code_snippets/services.py +++ b/apps/backend/app/api/routes/code_snippets/services.py @@ -53,7 +53,6 @@ async def create_code_snippet(ctx: WorkspaceContext, body: CodeSnippetCreate) -> doc: dict[str, Any] = { "_id": sid, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "title": body.title, diff --git a/apps/backend/app/api/routes/environment_manager/services.py b/apps/backend/app/api/routes/environment_manager/services.py index 395ed5a5..84ffc5d9 100644 --- a/apps/backend/app/api/routes/environment_manager/services.py +++ b/apps/backend/app/api/routes/environment_manager/services.py @@ -52,7 +52,6 @@ async def create_entry(ctx: WorkspaceContext, body: EnvSetEntryCreate) -> EnvSet doc: dict[str, Any] = { "_id": eid, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "encryptedData": body.encryptedData, diff --git a/apps/backend/app/api/routes/json_formatter/services.py b/apps/backend/app/api/routes/json_formatter/services.py index d2a60169..74698d33 100644 --- a/apps/backend/app/api/routes/json_formatter/services.py +++ b/apps/backend/app/api/routes/json_formatter/services.py @@ -81,7 +81,6 @@ async def create_document(ctx: WorkspaceContext, body: JsonFormatterDocumentCrea now = datetime.now(timezone.utc) doc: dict[str, Any] = { "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "title": body.title, diff --git a/apps/backend/app/api/routes/nosql/services.py b/apps/backend/app/api/routes/nosql/services.py index 8a147724..af6addd0 100644 --- a/apps/backend/app/api/routes/nosql/services.py +++ b/apps/backend/app/api/routes/nosql/services.py @@ -48,7 +48,6 @@ async def create_connection(ctx: WorkspaceContext, body: ConnectionCreate) -> Co doc: dict[str, Any] = { "_id": _id, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "encryptedData": body.encryptedData, diff --git a/apps/backend/app/api/routes/notes/services.py b/apps/backend/app/api/routes/notes/services.py index 70a59644..fcd58d3c 100644 --- a/apps/backend/app/api/routes/notes/services.py +++ b/apps/backend/app/api/routes/notes/services.py @@ -82,7 +82,6 @@ async def create_note(ctx: WorkspaceContext, body: NoteCreate) -> NoteOut: doc = { "_id": note_id, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "title": body.title or "Untitled", diff --git a/apps/backend/app/api/routes/passwords/services.py b/apps/backend/app/api/routes/passwords/services.py index dda7b130..a0482dbb 100644 --- a/apps/backend/app/api/routes/passwords/services.py +++ b/apps/backend/app/api/routes/passwords/services.py @@ -79,7 +79,6 @@ async def setup_vault(ctx: WorkspaceContext, body: VaultSetupRequest) -> VaultOu doc: dict[str, Any] = { "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "salt": body.salt, @@ -113,7 +112,6 @@ async def create_entry(ctx: WorkspaceContext, body: PasswordEntryCreate) -> Pass doc: dict[str, Any] = { "_id": eid, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "encryptedData": body.encryptedData, diff --git a/apps/backend/app/api/routes/redis_commander/services.py b/apps/backend/app/api/routes/redis_commander/services.py index b32c1827..43347f57 100644 --- a/apps/backend/app/api/routes/redis_commander/services.py +++ b/apps/backend/app/api/routes/redis_commander/services.py @@ -52,7 +52,6 @@ async def create_connection(ctx: WorkspaceContext, body: RedisConnectionCreate) doc: dict[str, Any] = { "_id": _id, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "encryptedData": body.encryptedData, diff --git a/apps/backend/app/api/routes/s3_drive/services.py b/apps/backend/app/api/routes/s3_drive/services.py index 20862756..200c1192 100644 --- a/apps/backend/app/api/routes/s3_drive/services.py +++ b/apps/backend/app/api/routes/s3_drive/services.py @@ -72,7 +72,6 @@ async def create_connection(ctx: WorkspaceContext, body: S3ConnectionCreate) -> doc: dict[str, Any] = { "_id": conn_id, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "name": body.name, diff --git a/apps/backend/app/api/routes/sql_client/services.py b/apps/backend/app/api/routes/sql_client/services.py index 6d8209b4..1a67c081 100644 --- a/apps/backend/app/api/routes/sql_client/services.py +++ b/apps/backend/app/api/routes/sql_client/services.py @@ -53,7 +53,6 @@ async def create_connection(ctx: WorkspaceContext, body: SqlConnectionCreate) -> doc: dict[str, Any] = { "_id": _id, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "encryptedData": body.encryptedData, diff --git a/apps/backend/app/api/routes/tasks/services.py b/apps/backend/app/api/routes/tasks/services.py index 8e0707ec..d53b6a33 100644 --- a/apps/backend/app/api/routes/tasks/services.py +++ b/apps/backend/app/api/routes/tasks/services.py @@ -185,7 +185,6 @@ async def create_task(ctx: WorkspaceContext, body: TaskCreate) -> TaskOut: now = datetime.now(timezone.utc) doc: dict[str, Any] = { "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "text": body.text, @@ -270,7 +269,6 @@ async def import_tasks(ctx: WorkspaceContext, body: TaskImportRequest) -> dict[s row.pop("createdAt", None) row.pop("completedAt", None) row["created_by"] = ctx.uid - row["org_id"] = ctx.org_id row["workspace_id"] = ctx.workspace_id row["owner_uid"] = ctx.uid row["createdAt"] = now @@ -303,7 +301,6 @@ async def create_project(ctx: WorkspaceContext, body: ProjectCreate) -> ProjectO now = datetime.now(timezone.utc) doc = { "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "name": body.name, diff --git a/apps/backend/app/api/routes/url_shortener/services.py b/apps/backend/app/api/routes/url_shortener/services.py index 75d15e02..c4141d53 100644 --- a/apps/backend/app/api/routes/url_shortener/services.py +++ b/apps/backend/app/api/routes/url_shortener/services.py @@ -181,7 +181,6 @@ async def create_link(ctx: WorkspaceContext, body: ShortLinkCreate) -> ShortLink "original_url": url, "title": title, "created_by": ctx.uid, - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, "owner_uid": ctx.uid, "created_at": create_timestamp(), @@ -254,7 +253,7 @@ async def record_click(code: str, ua: str = "", referrer: str = "") -> None: # Fetch the link document to inherit workspace stamps db = db_manager.get_db() - link_doc = await db[COLLECTION].find_one({"_id": code}, {"org_id": 1, "workspace_id": 1, "owner_uid": 1}) + link_doc = await db[COLLECTION].find_one({"_id": code}, {"workspace_id": 1, "owner_uid": 1}) event = { "code": code, @@ -267,8 +266,6 @@ async def record_click(code: str, ua: str = "", referrer: str = "") -> None: # Inherit workspace stamps from the link if link_doc: - if "org_id" in link_doc: - event["org_id"] = link_doc["org_id"] if "workspace_id" in link_doc: event["workspace_id"] = link_doc["workspace_id"] if "owner_uid" in link_doc: diff --git a/apps/backend/app/api/routes/webhook_tester/__init__.py b/apps/backend/app/api/routes/webhook_tester/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/app/api/routes/webhook_tester/api.py b/apps/backend/app/api/routes/webhook_tester/api.py new file mode 100644 index 00000000..06f2cdcd --- /dev/null +++ b/apps/backend/app/api/routes/webhook_tester/api.py @@ -0,0 +1,76 @@ +# ponytail: public capability-URL tool — no auth on any endpoint (same as +# dns_lookup). Bin ids are unguessable; the receive endpoint MUST stay open +# because external services (Stripe, GitHub, ...) call it directly. +from fastapi import APIRouter, HTTPException, Query, Request + +from app.api.routes.webhook_tester import services as svc +from app.api.routes.webhook_tester.schema import BinOut, RequestListOut + +router = APIRouter(prefix="/webhook-tester", tags=["webhook-tester"]) + +_ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + + +@router.post("/bins", response_model=BinOut, summary="Create a webhook bin (expires after 24h)") +async def create_bin() -> BinOut: + return BinOut(**await svc.create_bin()) + + +@router.api_route("/in/{bin_id}", methods=_ALL_METHODS, summary="Receive a webhook (public, any method)") +@router.api_route( + "/in/{bin_id}/{path:path}", + methods=_ALL_METHODS, + summary="Receive a webhook with a path suffix (public, any method)", +) +async def receive(bin_id: str, request: Request, path: str = "") -> dict: + body = await request.body() + + query: dict[str, str] = {} + for key, value in request.query_params.multi_items(): + query[key] = f"{query[key]},{value}" if key in query else value + + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + source_ip: str | None = forwarded.split(",")[0].strip() + else: + source_ip = request.client.host if request.client else None + + record = svc.make_record( + method=request.method, + path=path, + query=query, + headers=dict(request.headers), + body=body, + content_type=request.headers.get("content-type"), + source_ip=source_ip, + ) + if not await svc.add_request(bin_id, record): + raise HTTPException(status_code=404, detail="Bin not found or expired") + return {"ok": True} + + +@router.get( + "/bins/{bin_id}/requests", + response_model=RequestListOut, + summary="List captured requests (newest first)", +) +async def list_requests( + bin_id: str, + after: str | None = Query(default=None, description="Cursor: request id or ISO timestamp"), +) -> RequestListOut: + result = await svc.list_requests(bin_id, after) + if result is None: + raise HTTPException(status_code=404, detail="Bin not found or expired") + meta, items = result + return RequestListOut( + bin_id=bin_id, + expires_at=meta["expires_at"], + count=len(items), + requests=items, + ) + + +@router.delete("/bins/{bin_id}/requests", status_code=204, summary="Clear captured requests") +async def clear_requests(bin_id: str) -> None: + if not await svc.clear_requests(bin_id): + raise HTTPException(status_code=404, detail="Bin not found or expired") diff --git a/apps/backend/app/api/routes/webhook_tester/schema.py b/apps/backend/app/api/routes/webhook_tester/schema.py new file mode 100644 index 00000000..908c21c7 --- /dev/null +++ b/apps/backend/app/api/routes/webhook_tester/schema.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class BinOut(BaseModel): + bin_id: str + created_at: datetime + expires_at: datetime + + +class RecordedRequest(BaseModel): + id: str + method: str + path: str = "" + query: dict[str, str] = {} + headers: dict[str, str] = {} + body: str = "" + body_encoding: str = "text" # "text" | "base64" (binary payloads) + body_truncated: bool = False + content_type: str | None = None + size: int = 0 # original body size in bytes (before truncation) + source_ip: str | None = None + received_at: datetime + + +class RequestListOut(BaseModel): + bin_id: str + expires_at: datetime + count: int + requests: list[RecordedRequest] diff --git a/apps/backend/app/api/routes/webhook_tester/services.py b/apps/backend/app/api/routes/webhook_tester/services.py new file mode 100644 index 00000000..9fddb4f3 --- /dev/null +++ b/apps/backend/app/api/routes/webhook_tester/services.py @@ -0,0 +1,222 @@ +"""Webhook Tester — short-lived request bins. + +Storage: Redis with a 24h TTL when configured (matches the dominant +short-lived-data pattern, see app/core/redis_client.py). When Redis is +unavailable (dev/tests without REDIS_URL) an in-process fallback store with +the same TTL semantics is used. +""" +from __future__ import annotations + +import base64 +import json +import logging +import secrets +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from app.core.redis_client import get_redis + +log = logging.getLogger("app.webhook_tester") + +BIN_TTL_SECONDS = 24 * 60 * 60 +MAX_REQUESTS_PER_BIN = 100 +MAX_BODY_BYTES = 64 * 1024 + +# bin_id -> {"bin_id", "created_at", "expires_at", "requests": [newest, ...]} +_memory_bins: dict[str, dict[str, Any]] = {} + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _bin_key(bin_id: str) -> str: + return f"webhook_tester:bin:{bin_id}" + + +def _req_key(bin_id: str) -> str: + return f"webhook_tester:req:{bin_id}" + + +def new_bin_id() -> str: + """Random URL-safe id (~11 chars) — unguessable capability id.""" + return secrets.token_urlsafe(8) + + +def _purge_memory() -> None: + now_iso = _now().isoformat() + expired = [bid for bid, entry in _memory_bins.items() if entry["expires_at"] <= now_iso] + for bid in expired: + _memory_bins.pop(bid, None) + + +def make_record( + *, + method: str, + path: str, + query: dict[str, str], + headers: dict[str, str], + body: bytes, + content_type: str | None, + source_ip: str | None, +) -> dict[str, Any]: + """Build a stored request record; truncates large bodies, base64s binary ones.""" + size = len(body) + truncated = False + if size > MAX_BODY_BYTES: + body = body[:MAX_BODY_BYTES] + truncated = True + try: + text = body.decode("utf-8") + encoding = "text" + except UnicodeDecodeError: + text = base64.b64encode(body).decode("ascii") + encoding = "base64" + return { + "id": uuid.uuid4().hex[:12], + "method": method.upper(), + "path": path, + "query": query, + "headers": headers, + "body": text, + "body_encoding": encoding, + "body_truncated": truncated, + "content_type": content_type, + "size": size, + "source_ip": source_ip, + "received_at": _now().isoformat(), + } + + +async def create_bin() -> dict[str, Any]: + created = _now() + meta = { + "bin_id": new_bin_id(), + "created_at": created.isoformat(), + "expires_at": (created + timedelta(seconds=BIN_TTL_SECONDS)).isoformat(), + } + r = get_redis() + if r is not None: + try: + await r.set(_bin_key(meta["bin_id"]), json.dumps(meta), ex=BIN_TTL_SECONDS) + return meta + except Exception as exc: # noqa: BLE001 — fail over to memory store + log.warning("webhook_tester.redis.create failed err=%s", exc) + _purge_memory() + _memory_bins[meta["bin_id"]] = {**meta, "requests": []} + return meta + + +async def get_bin(bin_id: str) -> dict[str, Any] | None: + """Return bin metadata or None if unknown/expired.""" + r = get_redis() + if r is not None: + try: + raw = await r.get(_bin_key(bin_id)) + if raw is not None: + return json.loads(raw) + except Exception as exc: # noqa: BLE001 + log.warning("webhook_tester.redis.get_bin failed err=%s", exc) + _purge_memory() + entry = _memory_bins.get(bin_id) + if entry is None: + return None + return {k: entry[k] for k in ("bin_id", "created_at", "expires_at")} + + +async def add_request(bin_id: str, record: dict[str, Any]) -> bool: + """Store a request (newest first, capped). False if the bin is unknown/expired.""" + r = get_redis() + if r is not None: + try: + ttl = await r.ttl(_bin_key(bin_id)) + if ttl is None or ttl <= 0: + return False + pipe = r.pipeline() + pipe.lpush(_req_key(bin_id), json.dumps(record)) + pipe.ltrim(_req_key(bin_id), 0, MAX_REQUESTS_PER_BIN - 1) + pipe.expire(_req_key(bin_id), ttl) + await pipe.execute() + return True + except Exception as exc: # noqa: BLE001 + log.warning("webhook_tester.redis.add failed err=%s", exc) + _purge_memory() + entry = _memory_bins.get(bin_id) + if entry is None: + return False + entry["requests"].insert(0, record) + del entry["requests"][MAX_REQUESTS_PER_BIN:] + return True + + +def _parse_ts(value: str) -> datetime | None: + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def filter_after(items: list[dict[str, Any]], after: str) -> list[dict[str, Any]]: + """Return only entries newer than the cursor (request id or ISO timestamp). + + `items` is newest-first. Unknown cursors return everything (safe for polling). + """ + newer: list[dict[str, Any]] = [] + for item in items: + if item.get("id") == after: + return newer + newer.append(item) + ts = _parse_ts(after) + if ts is None: + return items + result = [] + for item in items: + item_ts = _parse_ts(str(item.get("received_at", ""))) + if item_ts is not None and item_ts > ts: + result.append(item) + return result + + +async def list_requests( + bin_id: str, after: str | None = None +) -> tuple[dict[str, Any], list[dict[str, Any]]] | None: + """Return (bin meta, requests newest-first) or None if the bin is unknown/expired.""" + meta = await get_bin(bin_id) + if meta is None: + return None + items: list[dict[str, Any]] | None = None + r = get_redis() + if r is not None: + try: + raw = await r.lrange(_req_key(bin_id), 0, MAX_REQUESTS_PER_BIN - 1) + items = [json.loads(x) for x in raw] + except Exception as exc: # noqa: BLE001 + log.warning("webhook_tester.redis.list failed err=%s", exc) + if items is None: + entry = _memory_bins.get(bin_id) + items = list(entry["requests"]) if entry else [] + if after: + items = filter_after(items, after) + return meta, items + + +async def clear_requests(bin_id: str) -> bool: + """Delete all captured requests. False if the bin is unknown/expired.""" + meta = await get_bin(bin_id) + if meta is None: + return False + r = get_redis() + if r is not None: + try: + await r.delete(_req_key(bin_id)) + return True + except Exception as exc: # noqa: BLE001 + log.warning("webhook_tester.redis.clear failed err=%s", exc) + entry = _memory_bins.get(bin_id) + if entry is not None: + entry["requests"] = [] + return True diff --git a/apps/backend/app/api/routes/whois/__init__.py b/apps/backend/app/api/routes/whois/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/app/api/routes/whois/api.py b/apps/backend/app/api/routes/whois/api.py new file mode 100644 index 00000000..b7f2ffaf --- /dev/null +++ b/apps/backend/app/api/routes/whois/api.py @@ -0,0 +1,14 @@ +# ponytail: stateless tool — no per-user persistence, no workspace scoping needed +from fastapi import APIRouter, Query + +from app.api.routes.whois import services as svc +from app.api.routes.whois.schema import WhoisResult + +router = APIRouter(prefix="/whois", tags=["whois"]) + + +@router.get("/lookup", response_model=WhoisResult, summary="RDAP/WHOIS lookup for a domain or IP address") +async def lookup( + target: str = Query(..., min_length=1, max_length=300, description="Domain name or IP address to look up"), +) -> WhoisResult: + return await svc.lookup_whois(target) diff --git a/apps/backend/app/api/routes/whois/schema.py b/apps/backend/app/api/routes/whois/schema.py new file mode 100644 index 00000000..7baac512 --- /dev/null +++ b/apps/backend/app/api/routes/whois/schema.py @@ -0,0 +1,23 @@ +from typing import Any, Literal + +from pydantic import BaseModel + + +class WhoisContact(BaseModel): + roles: list[str] = [] + name: str | None = None + organization: str | None = None + handle: str | None = None + + +class WhoisResult(BaseModel): + target: str + type: Literal["domain", "ip"] + registrar: str | None = None + status: list[str] = [] + created: str | None = None + updated: str | None = None + expires: str | None = None + nameservers: list[str] = [] + contacts: list[WhoisContact] = [] + raw: dict[str, Any] = {} diff --git a/apps/backend/app/api/routes/whois/services.py b/apps/backend/app/api/routes/whois/services.py new file mode 100644 index 00000000..e4a7f26c --- /dev/null +++ b/apps/backend/app/api/routes/whois/services.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import ipaddress +import re +from typing import Any + +import httpx +from fastapi import HTTPException + +from app.api.routes.whois.schema import WhoisContact, WhoisResult +from app.core.cache import cached +from app.core.cache.keys import register_namespace + +# Global cache: RDAP data is public and slow-moving. Registered here (not in +# keys.py) so the whois module stays self-contained. +register_namespace("whois", scope="global", default_ttl=3600) + +RDAP_BASE_URL = "https://rdap.org" +_HTTP_TIMEOUT_S = 15.0 + +_DOMAIN_RE = re.compile(r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$") + +# RDAP eventAction -> normalized field +_EVENT_FIELD = { + "registration": "created", + "last changed": "updated", + "last update": "updated", + "expiration": "expires", +} + + +def sanitize_target(raw: str) -> tuple[str, str]: + """Sanitize and classify a lookup target. + + Strips scheme/path/port noise, lowercases, and validates. Returns + ``(target, kind)`` where kind is ``"domain"`` or ``"ip"``. + Raises ``HTTPException(400)`` for anything that is neither. + """ + target = raw.strip() + target = re.sub(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", "", target) # scheme + target = target.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0] # path/query + target = target.strip().strip(".").lower() + if target.startswith("[") and target.endswith("]"): # bracketed IPv6 + target = target[1:-1] + + if not target or len(target) > 253: + raise HTTPException(status_code=400, detail="Invalid domain or IP address") + + try: + ipaddress.ip_address(target) + return target, "ip" + except ValueError: + pass + + # Not an IP — strip a :port suffix if present, then validate as a domain. + host = target.rsplit(":", 1)[0] if re.search(r":\d+$", target) else target + if _is_valid_domain(host): + return host, "domain" + + raise HTTPException(status_code=400, detail="Invalid domain or IP address") + + +def _is_valid_domain(domain: str) -> bool: + return bool(_DOMAIN_RE.match(domain)) and len(domain) <= 253 + + +@cached(ns="whois", ttl=3600, scope="global") +async def fetch_rdap(*, kind: str, target: str) -> dict[str, Any] | None: + """Fetch the raw RDAP document for a domain or IP. + + Pure function of (kind, target) — cached globally for 1h. + Returns ``None`` when the registry has no record (RDAP 404). + Raises ``HTTPException`` for upstream failures (not cached). + """ + path = "domain" if kind == "domain" else "ip" + url = f"{RDAP_BASE_URL}/{path}/{target}" + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S, follow_redirects=True) as client: + resp = await client.get(url, headers={"accept": "application/rdap+json, application/json"}) + except httpx.HTTPError: + raise HTTPException(status_code=502, detail="RDAP lookup failed: upstream service unreachable") + + if resp.status_code == 404: + return None + if resp.status_code == 429: + raise HTTPException(status_code=503, detail="RDAP service is rate limiting requests; try again shortly") + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail=f"RDAP upstream error (HTTP {resp.status_code})") + + try: + data = resp.json() + except ValueError: + raise HTTPException(status_code=502, detail="RDAP upstream returned an invalid response") + if not isinstance(data, dict): + raise HTTPException(status_code=502, detail="RDAP upstream returned an invalid response") + return data + + +def _vcard_value(entity: dict[str, Any], key: str) -> str | None: + """Extract a text value (fn, org, …) from an RDAP entity's vcardArray.""" + vcard = entity.get("vcardArray") + if not isinstance(vcard, list) or len(vcard) < 2 or not isinstance(vcard[1], list): + return None + for item in vcard[1]: + if isinstance(item, list) and len(item) >= 4 and item[0] == key: + value = item[3] + if isinstance(value, list): + value = " ".join(str(v) for v in value if v) + text = str(value).strip() + if text: + return text + return None + + +def _collect_contacts(entities: Any, out: list[WhoisContact], depth: int = 0) -> None: + if depth > 3 or not isinstance(entities, list): + return + for entity in entities: + if not isinstance(entity, dict): + continue + roles = [str(r) for r in entity.get("roles", []) if r] + name = _vcard_value(entity, "fn") + org = _vcard_value(entity, "org") + handle = entity.get("handle") + handle = str(handle) if handle else None + # Respect redaction: keep whatever the registry exposes; skip entries + # that carry no usable information at all. + if roles or name or org or handle: + out.append(WhoisContact(roles=roles, name=name, organization=org, handle=handle)) + _collect_contacts(entity.get("entities"), out, depth + 1) + + +def _find_registrar(data: dict[str, Any]) -> str | None: + for entity in data.get("entities") or []: + if not isinstance(entity, dict): + continue + if "registrar" in (entity.get("roles") or []): + return _vcard_value(entity, "fn") or _vcard_value(entity, "org") or entity.get("handle") + return None + + +def _extract_events(data: dict[str, Any]) -> dict[str, str | None]: + out: dict[str, str | None] = {"created": None, "updated": None, "expires": None} + for event in data.get("events") or []: + if not isinstance(event, dict): + continue + action = str(event.get("eventAction", "")).lower() + field = _EVENT_FIELD.get(action) + if field and event.get("eventDate") and out[field] is None: + out[field] = str(event["eventDate"]) + return out + + +def normalize_rdap(target: str, kind: str, data: dict[str, Any]) -> WhoisResult: + events = _extract_events(data) + + nameservers: list[str] = [] + for ns in data.get("nameservers") or []: + if isinstance(ns, dict) and ns.get("ldhName"): + name = str(ns["ldhName"]).rstrip(".").lower() + if name and name not in nameservers: + nameservers.append(name) + + contacts: list[WhoisContact] = [] + _collect_contacts(data.get("entities"), contacts) + + registrar = _find_registrar(data) + if registrar is None and kind == "ip": + # For IP networks, surface the network name/handle as the "registrar" line. + registrar = data.get("name") or data.get("handle") + registrar = str(registrar) if registrar else None + + return WhoisResult( + target=target, + type=kind, # type: ignore[arg-type] + registrar=registrar, + status=[str(s) for s in data.get("status") or []], + created=events["created"], + updated=events["updated"], + expires=events["expires"], + nameservers=nameservers, + contacts=contacts, + raw=data, + ) + + +async def lookup_whois(raw_target: str) -> WhoisResult: + target, kind = sanitize_target(raw_target) + data = await fetch_rdap(kind=kind, target=target) + if data is None: + label = "domain" if kind == "domain" else "IP address" + raise HTTPException(status_code=404, detail=f"No RDAP/WHOIS record found for this {label}") + return normalize_rdap(target, kind, data) diff --git a/apps/backend/app/api/routes/workspaces/api.py b/apps/backend/app/api/routes/workspaces/api.py index 085c0638..0c58a8c7 100644 --- a/apps/backend/app/api/routes/workspaces/api.py +++ b/apps/backend/app/api/routes/workspaces/api.py @@ -1,43 +1,23 @@ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from app.api.routes.auth.services import get_current_uid -from app.api.routes.workspaces import crud_service from app.api.routes.workspaces.middleware import ACTIVE_WS_COOKIE -from app.api.routes.workspaces import repo from app.api.routes.workspaces.repo import ( - find_user_orgs, find_user_workspaces, find_workspace, find_ws_membership, - find_org_membership, find_workspace_members, + find_user_workspaces, find_workspace, find_ws_membership, ) -from app.api.routes.workspaces import members_service -from app.api.routes.workspaces import invitations_service from app.api.routes.workspaces.schema import ( - ChangeRoleRequest, DekWrapOut, DekWrapPostRequest, EncryptionBlob, InvitationOut, - InviteMemberRequest, KeypairOut, KeypairPostRequest, MemberOut, OrgCreate, OrgOut, OrgPatch, - PendingWrapOut, RotateDekRequest, SetActiveWorkspaceRequest, SetActiveWorkspaceResponse, - WorkspaceCreate, WorkspacePatch, WorkspaceOut, WrappedDekBlob, + EncryptionBlob, KeypairOut, KeypairPostRequest, SetActiveWorkspaceRequest, + SetActiveWorkspaceResponse, WorkspaceOut, ) from app.api.routes.workspaces import crypto_repo from app.core.config import get_settings -from app.database import db_manager -from app.utils.collection_name import USERS router = APIRouter(prefix="/workspaces-api", tags=["workspaces"]) -def _org_to_out(org: dict) -> OrgOut: - return OrgOut( - id=org["_id"], - name=org["name"], - slug=org["slug"], - kind=org["kind"], - org_role=org["org_role"], - ) - - def _ws_to_out(ws: dict) -> WorkspaceOut: return WorkspaceOut( id=ws["_id"], - org_id=ws["org_id"], name=ws["name"], slug=ws["slug"], is_personal=bool(ws.get("is_personal")), @@ -47,36 +27,11 @@ def _ws_to_out(ws: dict) -> WorkspaceOut: ) -@router.get("/orgs", response_model=list[OrgOut]) -async def list_orgs(uid: Annotated[str, Depends(get_current_uid)]) -> list[OrgOut]: - orgs = await find_user_orgs(uid) - return [_org_to_out(o) for o in orgs] - - @router.get("/workspaces", response_model=list[WorkspaceOut]) async def list_workspaces( uid: Annotated[str, Depends(get_current_uid)], - org_id: str | None = Query(default=None), ) -> list[WorkspaceOut]: - workspaces = await find_user_workspaces(uid, org_id=org_id) - # Auto-heal: any org the user belongs to but has no workspaces in (e.g. - # accepted an org-only invite under an older build) gets a Personal - # workspace seeded so the org-switcher has something to land on. - from app.api.routes.workspaces.repo import ( - find_user_orgs, - upsert_personal_workspace, - upsert_ws_membership, - ) - orgs_with_ws = {w["org_id"] for w in workspaces} - user_orgs = await find_user_orgs(uid) - missing = [o for o in user_orgs if o["_id"] not in orgs_with_ws] - if org_id is not None: - missing = [o for o in missing if o["_id"] == org_id] - if missing: - for org in missing: - ws_id = await upsert_personal_workspace(org["_id"], uid) - await upsert_ws_membership(ws_id, org["_id"], uid, "admin") - workspaces = await find_user_workspaces(uid, org_id=org_id) + workspaces = await find_user_workspaces(uid) return [_ws_to_out(w) for w in workspaces] @@ -116,150 +71,6 @@ async def set_active_workspace( return SetActiveWorkspaceResponse(workspace_id=body.workspace_id) -@router.post("/orgs", response_model=OrgOut, status_code=201) -async def create_org_route( - body: OrgCreate, - uid: Annotated[str, Depends(get_current_uid)], -) -> OrgOut: - return await crud_service.create_org(uid, body) - - -@router.patch("/orgs/{org_id}", response_model=OrgOut) -async def rename_org_route( - org_id: str, - body: OrgPatch, - uid: Annotated[str, Depends(get_current_uid)], -) -> OrgOut: - return await crud_service.rename_org(uid, org_id, body) - - -@router.delete("/orgs/{org_id}", status_code=204) -async def delete_org_route( - org_id: str, - uid: Annotated[str, Depends(get_current_uid)], -) -> None: - await crud_service.delete_org(uid, org_id) - - -@router.post( - "/orgs/{org_id}/workspaces", - response_model=WorkspaceOut, - status_code=201, -) -async def create_workspace_route( - org_id: str, - body: WorkspaceCreate, - uid: Annotated[str, Depends(get_current_uid)], -) -> WorkspaceOut: - return await crud_service.create_shared_workspace(uid, org_id, body) - - -@router.patch("/workspaces/{ws_id}", response_model=WorkspaceOut) -async def rename_workspace_route( - ws_id: str, - body: WorkspacePatch, - uid: Annotated[str, Depends(get_current_uid)], -) -> WorkspaceOut: - return await crud_service.rename_workspace(uid, ws_id, body) - - -@router.delete("/workspaces/{ws_id}", status_code=204) -async def delete_workspace_route( - ws_id: str, - uid: Annotated[str, Depends(get_current_uid)], -) -> None: - await crud_service.delete_workspace(uid, ws_id) - - -@router.get("/orgs/{org_id}/members", response_model=list[MemberOut]) -async def list_org_members_route( - org_id: str, uid: Annotated[str, Depends(get_current_uid)], -) -> list[MemberOut]: - return await members_service.list_org_members(uid, org_id) - - -@router.patch("/orgs/{org_id}/members/{target_uid}", response_model=MemberOut) -async def change_org_role_route( - org_id: str, target_uid: str, body: ChangeRoleRequest, - uid: Annotated[str, Depends(get_current_uid)], -) -> MemberOut: - return await members_service.change_org_role(uid, org_id, target_uid, body.role) - - -@router.delete("/orgs/{org_id}/members/{target_uid}", status_code=204) -async def remove_org_member_route( - org_id: str, target_uid: str, - uid: Annotated[str, Depends(get_current_uid)], -) -> None: - await members_service.remove_org_member(uid, org_id, target_uid) - - -@router.get("/workspaces/{ws_id}/members", response_model=list[MemberOut]) -async def list_workspace_members_route( - ws_id: str, uid: Annotated[str, Depends(get_current_uid)], -) -> list[MemberOut]: - return await members_service.list_workspace_members(uid, ws_id) - - -@router.patch("/workspaces/{ws_id}/members/{target_uid}", response_model=MemberOut) -async def change_workspace_role_route( - ws_id: str, target_uid: str, body: ChangeRoleRequest, - uid: Annotated[str, Depends(get_current_uid)], -) -> MemberOut: - return await members_service.change_workspace_role(uid, ws_id, target_uid, body.role) - - -@router.delete("/workspaces/{ws_id}/members/{target_uid}", status_code=204) -async def remove_workspace_member_route( - ws_id: str, target_uid: str, - uid: Annotated[str, Depends(get_current_uid)], -) -> None: - await members_service.remove_workspace_member(uid, ws_id, target_uid) - - -@router.post( - "/orgs/{org_id}/members", - response_model=InvitationOut, status_code=201, -) -async def invite_to_org_route( - org_id: str, body: InviteMemberRequest, - uid: Annotated[str, Depends(get_current_uid)], -) -> InvitationOut: - return await invitations_service.invite_to_org(uid, org_id, body) - - -@router.post( - "/workspaces/{ws_id}/members", - response_model=InvitationOut, status_code=201, -) -async def invite_to_workspace_route( - ws_id: str, body: InviteMemberRequest, - uid: Annotated[str, Depends(get_current_uid)], -) -> InvitationOut: - return await invitations_service.invite_to_workspace(uid, ws_id, body) - - -@router.get("/invitations/pending", response_model=list[InvitationOut]) -async def list_pending_invitations_route( - uid: Annotated[str, Depends(get_current_uid)], -) -> list[InvitationOut]: - return await invitations_service.list_pending_for_me(uid) - - -@router.post("/invitations/{token}/accept") -async def accept_invitation_route( - token: str, uid: Annotated[str, Depends(get_current_uid)], -) -> dict: - return await invitations_service.accept_invitation(uid, token) - - -@router.post("/invitations/{token}/revoke", status_code=204) -async def revoke_invitation_route( - token: str, uid: Annotated[str, Depends(get_current_uid)], -) -> None: - await invitations_service.revoke_invitation(uid, token) - - @router.get("/users/me/keypair", response_model=KeypairOut | None) async def get_my_keypair( uid: Annotated[str, Depends(get_current_uid)], @@ -292,166 +103,3 @@ async def set_my_keypair( }, salt=body.salt, ) - - -@router.get("/workspaces/{ws_id}/dek-wrap", response_model=DekWrapOut) -async def get_my_dek_wrap( - ws_id: str, - uid: Annotated[str, Depends(get_current_uid)], -) -> DekWrapOut: - ws = await find_workspace(ws_id) - if not ws: - raise HTTPException(404) - mem = await find_ws_membership(ws_id, uid) - if not mem: - raise HTTPException(403) - wrap = mem.get("wrappedDek") - enc = (ws.get("settings") or {}).get("encryption") or {} - return DekWrapOut( - wrappedDek=WrappedDekBlob(**wrap) if wrap else None, - wrappedDekVersion=mem.get("wrappedDekVersion", 0), - expectedFingerprint=enc.get("dekFingerprint"), - ) - - -@router.post("/workspaces/{ws_id}/dek-wrap", status_code=204) -async def post_dek_wrap_for_member( - ws_id: str, - body: DekWrapPostRequest, - uid: Annotated[str, Depends(get_current_uid)], -) -> None: - ws = await find_workspace(ws_id) - if not ws: - raise HTTPException(404) - caller_mem = await find_ws_membership(ws_id, uid) - org_mem = await find_org_membership(ws["org_id"], uid) if ws.get("org_id") else None - is_admin = (caller_mem and caller_mem["ws_role"] == "admin") or (org_mem and org_mem["org_role"] in ("owner", "admin")) - if not is_admin: - raise HTTPException(403) - # L-1: only wrap for an actual member. set_membership_wrapped_dek uses a - # non-upsert update, so a wrap for a non-member would silently no-op and - # hide the caller's mistake. Reject it explicitly instead. - target_mem = await find_ws_membership(ws_id, body.target_uid) - if not target_mem: - raise HTTPException(404, "Target user is not a workspace member") - existing = await crypto_repo.get_membership_wrap(ws_id, body.target_uid) - new_version = (existing["wrappedDekVersion"] if existing else 0) + 1 - await crypto_repo.set_membership_wrapped_dek( - ws_id, - body.target_uid, - wrapped={ - "encrypted": body.wrapped.encrypted, - "iv": body.wrapped.iv, - "senderPublicKey": body.wrapped.senderPublicKey, - }, - version=new_version, - ) - - -@router.post("/workspaces/{ws_id}/rotate-dek", status_code=204) -async def rotate_dek( - ws_id: str, - body: RotateDekRequest, - uid: Annotated[str, Depends(get_current_uid)], -) -> None: - from app.utils.utils import create_timestamp - ws = await find_workspace(ws_id) - if not ws: - raise HTTPException(404) - caller_mem = await find_ws_membership(ws_id, uid) - org_mem = await find_org_membership(ws["org_id"], uid) if ws.get("org_id") else None - is_admin = (caller_mem and caller_mem["ws_role"] == "admin") or (org_mem and org_mem["org_role"] in ("owner", "admin")) - if not is_admin: - raise HTTPException(403) - members = await find_workspace_members(ws_id) - member_uids = {m["uid"] for m in members} - submitted_uids = {w.uid for w in body.wraps} - if submitted_uids != member_uids: - raise HTTPException(400, "Wraps must cover all current members exactly") - max_existing = max([m.get("wrappedDekVersion", 0) for m in members] + [0]) - new_version = max_existing + 1 - await crypto_repo.bulk_set_wrapped_deks( - ws_id, - [ - { - "uid": w.uid, - "wrapped": { - "encrypted": w.wrapped.encrypted, - "iv": w.wrapped.iv, - "senderPublicKey": w.wrapped.senderPublicKey, - }, - "version": new_version, - } - for w in body.wraps - ], - ) - await crypto_repo.set_workspace_encryption( - ws_id, - scheme="shared-dek-v1", - dek_fingerprint=body.dekFingerprint, - rotated_at=create_timestamp(), - ) - - -@router.get("/workspaces/{ws_id}/member-publickeys", response_model=list[PendingWrapOut]) -async def list_member_publickeys( - ws_id: str, - uid: Annotated[str, Depends(get_current_uid)], -) -> list[PendingWrapOut]: - ws = await find_workspace(ws_id) - if not ws: - raise HTTPException(404) - caller_mem = await find_ws_membership(ws_id, uid) - org_mem = await find_org_membership(ws["org_id"], uid) if ws.get("org_id") else None - is_admin = ( - (caller_mem and caller_mem["ws_role"] == "admin") - or (org_mem and org_mem["org_role"] in ("owner", "admin")) - ) - if not is_admin: - raise HTTPException(403) - members = await find_workspace_members(ws_id) - if not members: - return [] - member_uids = [m["uid"] for m in members] - users = await db_manager.find(USERS, {"_id": {"$in": member_uids}}, limit=500) - by_uid = {u["_id"]: u for u in users} - return [ - PendingWrapOut( - uid=m["uid"], - email=by_uid.get(m["uid"], {}).get("email"), - publicKey=(by_uid.get(m["uid"], {}).get("encryption") or {}).get("publicKey"), - ) - for m in members - ] - - -@router.get("/workspaces/{ws_id}/pending-wraps", response_model=list[PendingWrapOut]) -async def list_pending_wraps( - ws_id: str, - uid: Annotated[str, Depends(get_current_uid)], -) -> list[PendingWrapOut]: - ws = await find_workspace(ws_id) - if not ws: - raise HTTPException(404) - caller_mem = await find_ws_membership(ws_id, uid) - org_mem = await find_org_membership(ws["org_id"], uid) if ws.get("org_id") else None - is_admin = ( - (caller_mem and caller_mem["ws_role"] == "admin") - or (org_mem and org_mem["org_role"] in ("owner", "admin")) - ) - if not is_admin: - raise HTTPException(403) - pendings = await crypto_repo.find_pending_wraps(ws_id) - if not pendings: - return [] - uids = [p["uid"] for p in pendings] - users = await db_manager.find(USERS, {"_id": {"$in": uids}}, limit=200) - by_uid = {u["_id"]: u for u in users} - return [ - PendingWrapOut( - uid=p["uid"], - email=by_uid.get(p["uid"], {}).get("email"), - publicKey=(by_uid.get(p["uid"], {}).get("encryption") or {}).get("publicKey"), - ) - for p in pendings - ] diff --git a/apps/backend/app/api/routes/workspaces/backfill.py b/apps/backend/app/api/routes/workspaces/backfill.py index f6094007..8aaea488 100644 --- a/apps/backend/app/api/routes/workspaces/backfill.py +++ b/apps/backend/app/api/routes/workspaces/backfill.py @@ -63,8 +63,8 @@ class BackfillSpec: ] -async def _stamp_collection(spec: BackfillSpec, uid: str, ws_id: str, org_id: str) -> int: - update: dict[str, Any] = {"org_id": org_id, "workspace_id": ws_id} +async def _stamp_collection(spec: BackfillSpec, uid: str, ws_id: str) -> int: + update: dict[str, Any] = {"workspace_id": ws_id} if spec.stamp_owner_uid: update["owner_uid"] = uid res = await db_manager.update_many( @@ -94,7 +94,7 @@ async def _rewrite_pinned_tools(uid: str, ws_id: str) -> None: ) -async def run_user_backfill(uid: str, personal_workspace_id: str, org_id: str) -> None: +async def run_user_backfill(uid: str, personal_workspace_id: str) -> None: # Skip if already migrated migrated_at = await users_repo.get_migrated_at(uid) if migrated_at: @@ -104,7 +104,7 @@ async def run_user_backfill(uid: str, personal_workspace_id: str, org_id: str) - for spec in BACKFILL_COLLECTIONS: if progress.get(spec.collection) == "done": continue - await _stamp_collection(spec, uid, personal_workspace_id, org_id) + await _stamp_collection(spec, uid, personal_workspace_id) progress[spec.collection] = "done" await users_repo.set_migration_progress(uid, progress) @@ -116,6 +116,5 @@ def schedule_backfill( background_tasks: BackgroundTasks, uid: str, personal_workspace_id: str, - org_id: str, ) -> None: - background_tasks.add_task(run_user_backfill, uid, personal_workspace_id, org_id) + background_tasks.add_task(run_user_backfill, uid, personal_workspace_id) diff --git a/apps/backend/app/api/routes/workspaces/crud_service.py b/apps/backend/app/api/routes/workspaces/crud_service.py deleted file mode 100644 index cd9b40e0..00000000 --- a/apps/backend/app/api/routes/workspaces/crud_service.py +++ /dev/null @@ -1,160 +0,0 @@ -import re -from fastapi import HTTPException -from app.api.routes.workspaces import repo -from app.api.routes.workspaces.schema import OrgCreate, OrgOut, OrgPatch, WorkspaceCreate, WorkspacePatch, WorkspaceOut -from app.database import db_manager -from app.utils.collection_name import ORGANIZATIONS, WORKSPACES -from app.utils.utils import create_timestamp, new_id - - -def _slugify(name: str) -> str: - slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") - return slug or "untitled" - - -async def _org_to_out(org: dict, uid: str) -> OrgOut: - mem = await repo.find_org_membership(org["_id"], uid) - return OrgOut( - id=org["_id"], - name=org["name"], - slug=org["slug"], - kind=org["kind"], - org_role=mem["org_role"] if mem else "viewer", - ) - - -async def _require_org(org_id: str) -> dict: - org = await repo.find_org(org_id) - if not org: - raise HTTPException(status_code=404, detail="Org not found") - return org - - -async def create_org(uid: str, body: OrgCreate) -> OrgOut: - slug = body.slug or _slugify(body.name) - org_id = await repo.upsert_org( - name=body.name, - slug=slug, - kind="user", - owner_uid=uid, - ) - await repo.upsert_org_membership(org_id, uid, "owner") - org = await _require_org(org_id) - return await _org_to_out(org, uid) - - -async def rename_org(uid: str, org_id: str, body: OrgPatch) -> OrgOut: - mem = await repo.find_org_membership(org_id, uid) - if not mem or mem["org_role"] not in ("owner", "admin"): - raise HTTPException(status_code=403, detail="Org admin required") - org = await _require_org(org_id) - if org["kind"] == "system": - raise HTTPException(status_code=403, detail="System org cannot be modified") - if body.name and body.name != org["name"]: - await db_manager.update_one( - ORGANIZATIONS, - {"_id": org_id}, - {"$set": {"name": body.name, "updatedAt": create_timestamp()}}, - ) - org = await _require_org(org_id) - return await _org_to_out(org, uid) - - -async def delete_org(uid: str, org_id: str) -> None: - mem = await repo.find_org_membership(org_id, uid) - if not mem or mem["org_role"] != "owner": - raise HTTPException(status_code=403, detail="Org owner required") - org = await _require_org(org_id) - if org["kind"] == "system": - raise HTTPException(status_code=403, detail="System org cannot be deleted") - ts = create_timestamp() - await repo.set_org_deleted(org_id, ts) - # Cascade soft-delete on all non-deleted workspaces in the org. - await db_manager.update_many( - WORKSPACES, - {"org_id": org_id, "deleted_at": None}, - {"$set": {"deleted_at": ts}}, - ) - - -async def _ws_to_out(ws: dict, ws_role: str) -> WorkspaceOut: - return WorkspaceOut( - id=ws["_id"], - org_id=ws["org_id"], - name=ws["name"], - slug=ws["slug"], - is_personal=bool(ws.get("is_personal")), - kind=ws.get("kind", "shared"), - ws_role=ws_role, - settings=ws.get("settings") or {}, - ) - - -async def create_shared_workspace( - uid: str, org_id: str, body: WorkspaceCreate, -) -> WorkspaceOut: - org = await repo.find_org(org_id) - if not org: - raise HTTPException(status_code=404, detail="Org not found") - org_mem = await repo.find_org_membership(org_id, uid) - if not org_mem: - raise HTTPException(status_code=403, detail="Not a member of this org") - # System orgs (Mydevtools Cloud) let any member create their own workspaces — - # listings are per-user, so workspaces here don't leak across the tenant. - is_system = org.get("kind") == "system" - if not is_system and org_mem["org_role"] not in ("owner", "admin"): - raise HTTPException(status_code=403, detail="Org admin required") - slug = body.slug or _slugify(body.name) - ts = create_timestamp() - doc = { - "_id": new_id(), - "org_id": org_id, - "name": body.name, - "slug": slug, - "is_personal": False, - "owner_uid": None, - "kind": "shared", - "settings": {"encryption": None}, - "createdAt": ts, - "updatedAt": ts, - "deleted_at": None, - } - await db_manager.insert_one(WORKSPACES, doc) - # Create explicit ws membership so the listing endpoint surfaces the workspace. - await repo.upsert_ws_membership(doc["_id"], org_id, uid, "admin") - return await _ws_to_out(doc, "admin") - - -async def rename_workspace( - uid: str, ws_id: str, body: WorkspacePatch, -) -> WorkspaceOut: - ws = await repo.find_workspace(ws_id) - if not ws: - raise HTTPException(status_code=404, detail="Workspace not found") - if ws.get("is_personal"): - raise HTTPException(status_code=403, detail="Personal workspace is locked") - org_mem = await repo.find_org_membership(ws["org_id"], uid) - if not org_mem or org_mem["org_role"] not in ("owner", "admin"): - ws_mem = await repo.find_ws_membership(ws_id, uid) - if not ws_mem or ws_mem["ws_role"] != "admin": - raise HTTPException(status_code=403, detail="Workspace admin required") - if body.name and body.name != ws["name"]: - await db_manager.update_one( - WORKSPACES, {"_id": ws_id}, - {"$set": {"name": body.name, "updatedAt": create_timestamp()}}, - ) - ws = await repo.find_workspace(ws_id) - mem = await repo.find_ws_membership(ws_id, uid) - return await _ws_to_out(ws, (mem or {"ws_role": "admin"})["ws_role"]) - - -async def delete_workspace(uid: str, ws_id: str) -> None: - ws = await repo.find_workspace(ws_id) - if not ws: - raise HTTPException(status_code=404, detail="Workspace not found") - if ws.get("is_personal"): - raise HTTPException(status_code=403, detail="Personal workspace is locked") - org_mem = await repo.find_org_membership(ws["org_id"], uid) - if not org_mem or org_mem["org_role"] not in ("owner", "admin"): - raise HTTPException(status_code=403, detail="Org admin required") - await repo.set_workspace_deleted(ws_id, create_timestamp()) diff --git a/apps/backend/app/api/routes/workspaces/crypto_repo.py b/apps/backend/app/api/routes/workspaces/crypto_repo.py index 350f089a..5cf241f2 100644 --- a/apps/backend/app/api/routes/workspaces/crypto_repo.py +++ b/apps/backend/app/api/routes/workspaces/crypto_repo.py @@ -1,6 +1,6 @@ from typing import Any from app.database import db_manager -from app.utils.collection_name import USERS, WORKSPACES, WORKSPACE_MEMBERSHIPS +from app.utils.collection_name import USERS from app.utils.utils import create_timestamp @@ -31,114 +31,3 @@ async def get_user_encryption(uid: str) -> dict[str, Any] | None: if not doc: return None return doc.get("encryption") - - -async def set_membership_wrapped_dek( - workspace_id: str, - uid: str, - wrapped: dict[str, str], # {encrypted, iv, senderPublicKey} - version: int, -) -> None: - await db_manager.update_one( - WORKSPACE_MEMBERSHIPS, - {"workspace_id": workspace_id, "uid": uid}, - {"$set": { - "wrappedDek": wrapped, - "wrappedDekVersion": version, - }}, - ) - - -async def get_membership_wrap(workspace_id: str, uid: str) -> dict | None: - doc = await db_manager.find_one( - WORKSPACE_MEMBERSHIPS, - {"workspace_id": workspace_id, "uid": uid}, - ) - if not doc: - return None - return { - "wrappedDek": doc.get("wrappedDek"), - "wrappedDekVersion": doc.get("wrappedDekVersion", 0), - } - - -async def set_workspace_encryption( - workspace_id: str, - *, - scheme: str, - dek_fingerprint: str, - rotated_at: int | None = None, -) -> None: - ws = await db_manager.find_one(WORKSPACES, {"_id": workspace_id}) - existing_enc = (ws.get("settings") or {}).get("encryption") if ws else None - created_at = (existing_enc or {}).get("createdAt") or create_timestamp() - await db_manager.update_one( - WORKSPACES, - {"_id": workspace_id}, - {"$set": {"settings.encryption": { - "scheme": scheme, - "dekFingerprint": dek_fingerprint, - "createdAt": created_at, - "rotatedAt": rotated_at, - }}}, - ) - - -async def flag_workspace_rotation_required(workspace_id: str) -> bool: - """Mark an encrypted workspace as needing a DEK rotation (e.g. after a member - is removed). No-op if the workspace has no encryption initialized. Returns - True if the flag was set. The flag is cleared on the next rotation because - set_workspace_encryption() rewrites settings.encryption without it. - """ - ws = await db_manager.find_one(WORKSPACES, {"_id": workspace_id}) - enc = (ws.get("settings") or {}).get("encryption") if ws else None - if not enc: - return False - await db_manager.update_one( - WORKSPACES, - {"_id": workspace_id}, - {"$set": {"settings.encryption.rotationRequired": True}}, - ) - return True - - -async def bulk_set_wrapped_deks( - workspace_id: str, - wraps: list[dict], # [{uid, wrapped, version}, ...] -) -> None: - for w in wraps: - await set_membership_wrapped_dek( - workspace_id, w["uid"], w["wrapped"], w["version"], - ) - - -async def find_pending_wraps(workspace_id: str) -> list[dict]: - docs = await db_manager.find( - WORKSPACE_MEMBERSHIPS, - { - "workspace_id": workspace_id, - "$or": [ - {"wrappedDek": None}, - {"wrappedDek": {"$exists": False}}, - ], - }, - limit=200, - ) - return docs - - -async def find_users_with_publickey_by_emails(emails: list[str]) -> dict[str, dict]: - """Return uid -> {publicKey, email} for any registered user with a published keypair.""" - docs = await db_manager.find( - USERS, - {"email": {"$in": [e.lower() for e in emails]}, "encryption.publicKey": {"$exists": True}}, - limit=500, - ) - return { - d["_id"]: { - "publicKey": d["encryption"]["publicKey"], - "email": d["email"], - } - for d in docs - if d.get("encryption") and d["encryption"].get("publicKey") - } diff --git a/apps/backend/app/api/routes/workspaces/invitations_repo.py b/apps/backend/app/api/routes/workspaces/invitations_repo.py deleted file mode 100644 index 3ab731eb..00000000 --- a/apps/backend/app/api/routes/workspaces/invitations_repo.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Any -from app.database import db_manager -from app.utils.collection_name import INVITATIONS - - -async def create_invitation(doc: dict[str, Any]) -> str: - await db_manager.insert_one(INVITATIONS, doc) - return doc["_id"] - - -async def find_invitation_by_token(token: str) -> dict[str, Any] | None: - return await db_manager.find_one(INVITATIONS, {"token": token}) - - -async def find_pending_for_email(email: str) -> list[dict[str, Any]]: - return await db_manager.find( - INVITATIONS, - {"invited_email": email.lower(), "status": "pending"}, - limit=50, - ) - - -async def find_pending_for_org(org_id: str) -> list[dict[str, Any]]: - return await db_manager.find( - INVITATIONS, - {"org_id": org_id, "status": "pending"}, - limit=200, - ) - - -async def update_invitation_status( - invitation_id: str, - status: str, - accepted_uid: str | None = None, - accepted_at: int | None = None, -) -> None: - patch: dict[str, Any] = {"status": status} - if accepted_uid is not None: - patch["accepted_uid"] = accepted_uid - if accepted_at is not None: - patch["accepted_at"] = accepted_at - await db_manager.update_one(INVITATIONS, {"_id": invitation_id}, {"$set": patch}) diff --git a/apps/backend/app/api/routes/workspaces/invitations_service.py b/apps/backend/app/api/routes/workspaces/invitations_service.py deleted file mode 100644 index ecff7fea..00000000 --- a/apps/backend/app/api/routes/workspaces/invitations_service.py +++ /dev/null @@ -1,191 +0,0 @@ -import secrets -from fastapi import HTTPException - -from app.api.routes.auth.users_repo import get_user_doc -from app.api.routes.workspaces import invitations_repo, repo -from app.api.routes.workspaces.schema import InvitationOut, InviteMemberRequest -from app.utils.utils import create_timestamp, new_id - -INVITATION_TTL_SECONDS = 14 * 24 * 3600 - - -def _new_token() -> str: - return secrets.token_urlsafe(32) - - -def _doc_to_out(doc: dict) -> InvitationOut: - return InvitationOut( - id=doc["_id"], - org_id=doc["org_id"], - workspace_id=doc.get("workspace_id"), - invited_email=doc["invited_email"], - invited_uid=doc.get("invited_uid"), - invited_role_org=doc.get("invited_role_org"), - invited_role_ws=doc.get("invited_role_ws"), - status=doc["status"], - token=doc["token"], - expires_at=int(doc["expires_at"]), - created_at=int(doc["created_at"]), - ) - - -async def _find_uid_by_email(email: str) -> str | None: - from app.database import db_manager - from app.utils.collection_name import USERS - doc = await db_manager.find_one(USERS, {"email": email.lower()}) - return doc["_id"] if doc else None - - -async def _ensure_org_admin(uid: str, org_id: str) -> None: - mem = await repo.find_org_membership(org_id, uid) - if not mem or mem["org_role"] not in ("owner", "admin"): - raise HTTPException(403, "Org admin required") - - -async def _ensure_ws_admin(uid: str, ws_id: str) -> dict: - ws = await repo.find_workspace(ws_id) - if not ws: - raise HTTPException(404, "Workspace not found") - org_mem = await repo.find_org_membership(ws["org_id"], uid) - ws_mem = await repo.find_ws_membership(ws_id, uid) - if not ( - (org_mem and org_mem["org_role"] in ("owner", "admin")) - or (ws_mem and ws_mem["ws_role"] == "admin") - ): - raise HTTPException(403, "Workspace admin required") - return ws - - -async def invite_to_org( - uid: str, org_id: str, body: InviteMemberRequest, -) -> InvitationOut: - await _ensure_org_admin(uid, org_id) - email = body.email.lower().strip() - if body.role not in ("owner", "admin", "member", "viewer"): - raise HTTPException(400, "Invalid org role") - # Optional bundled workspace grant on accept. - ws_id: str | None = body.workspace_id - ws_role: str | None = body.workspace_role - if ws_id: - if ws_role not in ("admin", "developer", "viewer"): - raise HTTPException(400, "Invalid workspace role") - ws = await repo.find_workspace(ws_id) - if not ws or ws.get("org_id") != org_id: - raise HTTPException(400, "Workspace does not belong to this org") - invited_uid = await _find_uid_by_email(email) - if not invited_uid: - raise HTTPException(404, "No user found with that email. Ask them to sign up first.") - now = create_timestamp() - doc = { - "_id": new_id(), - "org_id": org_id, - "workspace_id": ws_id, - "invited_email": email, - "invited_uid": invited_uid, - "invited_role_org": body.role, - "invited_role_ws": ws_role, - "token": _new_token(), - "status": "pending", - "invited_by": uid, - "created_at": now, - "expires_at": now + INVITATION_TTL_SECONDS * 1000, - } - await invitations_repo.create_invitation(doc) - # In-app notification only — the invited user sees this via the bell. - return _doc_to_out(doc) - - -async def invite_to_workspace( - uid: str, ws_id: str, body: InviteMemberRequest, -) -> InvitationOut: - ws = await _ensure_ws_admin(uid, ws_id) - email = body.email.lower().strip() - if body.role not in ("admin", "developer", "viewer"): - raise HTTPException(400, "Invalid ws role") - invited_uid = await _find_uid_by_email(email) - if not invited_uid: - raise HTTPException(404, "No user found with that email. Ask them to sign up first.") - now = create_timestamp() - doc = { - "_id": new_id(), - "org_id": ws["org_id"], - "workspace_id": ws_id, - "invited_email": email, - "invited_uid": invited_uid, - # Workspace-level invites also grant org Member by default. - "invited_role_org": "member", - "invited_role_ws": body.role, - "token": _new_token(), - "status": "pending", - "invited_by": uid, - "created_at": now, - "expires_at": now + INVITATION_TTL_SECONDS * 1000, - } - await invitations_repo.create_invitation(doc) - # In-app notification only — the invited user sees this via PendingInvitationsBadge. - return _doc_to_out(doc) - - -async def list_pending_for_me(uid: str) -> list[InvitationOut]: - user = await get_user_doc(uid) - if not user or not user.get("email"): - return [] - docs = await invitations_repo.find_pending_for_email(user["email"]) - return [_doc_to_out(d) for d in docs] - - -async def accept_invitation(uid: str, token: str) -> dict: - inv = await invitations_repo.find_invitation_by_token(token) - if not inv: - raise HTTPException(404, "Invitation not found") - user = await get_user_doc(uid) - if not user or not user.get("email") or user["email"].lower() != inv["invited_email"]: - raise HTTPException(403, "Invitation addressed to different email") - if inv["status"] != "pending": - raise HTTPException(400, f"Invitation is {inv['status']}") - if inv["expires_at"] < create_timestamp(): - await invitations_repo.update_invitation_status(inv["_id"], "expired") - raise HTTPException(400, "Invitation expired") - - # Apply memberships - if inv["invited_role_org"]: - existing = await repo.find_org_membership(inv["org_id"], uid) - if not existing: - await repo.upsert_org_membership( - inv["org_id"], uid, inv["invited_role_org"], - ) - if inv["workspace_id"] and inv["invited_role_ws"]: - existing_ws = await repo.find_ws_membership(inv["workspace_id"], uid) - if not existing_ws: - await repo.upsert_ws_membership( - inv["workspace_id"], inv["org_id"], uid, inv["invited_role_ws"], - ) - else: - # Org-only invite: seed a Personal workspace in this org so the user has - # somewhere to land when they switch to it. Mirrors signup behavior. - ws_id = await repo.upsert_personal_workspace(inv["org_id"], uid) - await repo.upsert_ws_membership(ws_id, inv["org_id"], uid, "admin") - await invitations_repo.update_invitation_status( - inv["_id"], "accepted", - accepted_uid=uid, accepted_at=create_timestamp(), - ) - return {"org_id": inv["org_id"], "workspace_id": inv.get("workspace_id")} - - -async def revoke_invitation(uid: str, token: str) -> None: - inv = await invitations_repo.find_invitation_by_token(token) - if not inv: - raise HTTPException(404, "Invitation not found") - # Allowed: original inviter, org owner/admin, OR the invitee themself (decline). - user = await get_user_doc(uid) - is_invitee = bool( - (inv.get("invited_uid") and inv["invited_uid"] == uid) - or (user and (user.get("email") or "").lower() == inv["invited_email"]) - ) - org_mem = await repo.find_org_membership(inv["org_id"], uid) - is_admin = bool(org_mem and org_mem["org_role"] in ("owner", "admin")) - if not (inv["invited_by"] == uid or is_admin or is_invitee): - raise HTTPException(403, "Cannot revoke this invitation") - if inv["status"] != "pending": - return - await invitations_repo.update_invitation_status(inv["_id"], "revoked") diff --git a/apps/backend/app/api/routes/workspaces/members_service.py b/apps/backend/app/api/routes/workspaces/members_service.py deleted file mode 100644 index 6829ff09..00000000 --- a/apps/backend/app/api/routes/workspaces/members_service.py +++ /dev/null @@ -1,156 +0,0 @@ -from fastapi import HTTPException -from app.api.routes.auth.users_repo import get_user_doc -from app.api.routes.workspaces import repo -from app.api.routes.workspaces.schema import MemberOut -from app.database import db_manager -from app.utils.collection_name import ( - ORG_MEMBERSHIPS, WORKSPACE_MEMBERSHIPS, WORKSPACES, -) -from app.utils.utils import create_timestamp - - -async def _membership_to_out(mem: dict, role_field: str) -> MemberOut: - user = await get_user_doc(mem["uid"]) or {} - return MemberOut( - uid=mem["uid"], - email=user.get("email"), - display_name=user.get("display_name"), - role=mem[role_field], - since=int(mem.get("createdAt", 0)), - ) - - -async def list_org_members(uid: str, org_id: str) -> list[MemberOut]: - caller = await repo.find_org_membership(org_id, uid) - if not caller: - raise HTTPException(403, "Not an org member") - members = await repo.find_org_members(org_id) - return [await _membership_to_out(m, "org_role") for m in members] - - -async def list_workspace_members(uid: str, ws_id: str) -> list[MemberOut]: - ws = await repo.find_workspace(ws_id) - if not ws: - raise HTTPException(404, "Workspace not found") - caller_org_mem = await repo.find_org_membership(ws["org_id"], uid) - caller_ws_mem = await repo.find_ws_membership(ws_id, uid) - if not caller_ws_mem and not ( - caller_org_mem and caller_org_mem["org_role"] in ("owner", "admin") - ): - raise HTTPException(403, "Not a workspace member") - members = await repo.find_workspace_members(ws_id) - return [await _membership_to_out(m, "ws_role") for m in members] - - -def _validate_org_role(role: str) -> None: - if role not in ("owner", "admin", "member", "viewer"): - raise HTTPException(400, "Invalid org role") - - -def _validate_ws_role(role: str) -> None: - if role not in ("admin", "developer", "viewer"): - raise HTTPException(400, "Invalid ws role") - - -async def change_org_role( - uid: str, org_id: str, target_uid: str, role: str, -) -> MemberOut: - _validate_org_role(role) - caller = await repo.find_org_membership(org_id, uid) - if not caller or caller["org_role"] not in ("owner", "admin"): - raise HTTPException(403, "Org admin required") - target = await repo.find_org_membership(org_id, target_uid) - if not target: - raise HTTPException(404, "Target not in org") - if target["org_role"] == "owner" and role != "owner": - owners = [ - m for m in await repo.find_org_members(org_id) - if m["org_role"] == "owner" - ] - if len(owners) == 1: - raise HTTPException(400, "Cannot demote sole owner") - await db_manager.update_one( - ORG_MEMBERSHIPS, - {"org_id": org_id, "uid": target_uid}, - {"$set": {"org_role": role, "updatedAt": create_timestamp()}}, - ) - updated = await repo.find_org_membership(org_id, target_uid) - return await _membership_to_out(updated, "org_role") - - -async def change_workspace_role( - uid: str, ws_id: str, target_uid: str, role: str, -) -> MemberOut: - _validate_ws_role(role) - ws = await repo.find_workspace(ws_id) - if not ws: - raise HTTPException(404, "Workspace not found") - caller_org_mem = await repo.find_org_membership(ws["org_id"], uid) - caller_ws_mem = await repo.find_ws_membership(ws_id, uid) - is_admin = ( - (caller_org_mem and caller_org_mem["org_role"] in ("owner", "admin")) - or (caller_ws_mem and caller_ws_mem["ws_role"] == "admin") - ) - if not is_admin: - raise HTTPException(403, "Workspace admin required") - target = await repo.find_ws_membership(ws_id, target_uid) - if not target: - raise HTTPException(404, "Target not in workspace") - await db_manager.update_one( - WORKSPACE_MEMBERSHIPS, - {"workspace_id": ws_id, "uid": target_uid}, - {"$set": {"ws_role": role, "updatedAt": create_timestamp()}}, - ) - updated = await repo.find_ws_membership(ws_id, target_uid) - return await _membership_to_out(updated, "ws_role") - - -async def remove_org_member(uid: str, org_id: str, target_uid: str) -> None: - caller = await repo.find_org_membership(org_id, uid) - if not caller or caller["org_role"] not in ("owner", "admin"): - raise HTTPException(403, "Org admin required") - target = await repo.find_org_membership(org_id, target_uid) - if not target: - raise HTTPException(404, "Target not in org") - if target["org_role"] == "owner": - owners = [ - m for m in await repo.find_org_members(org_id) - if m["org_role"] == "owner" - ] - if len(owners) == 1: - raise HTTPException(400, "Cannot remove sole owner") - # Delete org membership - await db_manager.delete_one( - ORG_MEMBERSHIPS, {"org_id": org_id, "uid": target_uid}, - ) - # Cascade — drop all ws memberships in this org for target - ws_in_org = await db_manager.find(WORKSPACES, {"org_id": org_id}, limit=500) - ws_ids = [w["_id"] for w in ws_in_org] - if ws_ids: - await db_manager.delete_many( - WORKSPACE_MEMBERSHIPS, - {"workspace_id": {"$in": ws_ids}, "uid": target_uid}, - ) - - -async def remove_workspace_member(uid: str, ws_id: str, target_uid: str) -> None: - ws = await repo.find_workspace(ws_id) - if not ws: - raise HTTPException(404, "Workspace not found") - caller_org_mem = await repo.find_org_membership(ws["org_id"], uid) - caller_ws_mem = await repo.find_ws_membership(ws_id, uid) - is_admin = ( - (caller_org_mem and caller_org_mem["org_role"] in ("owner", "admin")) - or (caller_ws_mem and caller_ws_mem["ws_role"] == "admin") - ) - if not is_admin: - raise HTTPException(403, "Workspace admin required") - await db_manager.delete_one( - WORKSPACE_MEMBERSHIPS, {"workspace_id": ws_id, "uid": target_uid}, - ) - # H-1: the removed member's client already holds the unwrapped DEK, so deleting - # their membership does not revoke decryption. Flag the workspace so an admin - # rotates the shared key (which re-encrypts entries under a key the removed - # member never receives). - from app.api.routes.workspaces import crypto_repo - await crypto_repo.flag_workspace_rotation_required(ws_id) diff --git a/apps/backend/app/api/routes/workspaces/middleware.py b/apps/backend/app/api/routes/workspaces/middleware.py index fcd83f17..55509097 100644 --- a/apps/backend/app/api/routes/workspaces/middleware.py +++ b/apps/backend/app/api/routes/workspaces/middleware.py @@ -2,12 +2,11 @@ from typing import Annotated, Any -from fastapi import Depends, HTTPException, Request, status +from fastapi import Depends, HTTPException, Request from pydantic import BaseModel from app.api.routes.auth.services import get_current_uid from app.api.routes.workspaces.repo import ( - find_org_membership, find_user_workspaces, find_workspace, find_ws_membership, @@ -19,7 +18,6 @@ class WorkspaceContext(BaseModel): uid: str - org_id: str workspace_id: str ws_role: WsRole is_personal: bool @@ -41,30 +39,35 @@ async def get_workspace_ctx( request: Request, uid: Annotated[str, Depends(get_current_uid)], ) -> WorkspaceContext: - ws_id = request.cookies.get(ACTIVE_WS_COOKIE) - if not ws_id: - ws_id = await default_personal_ws_id(uid) + cookie_ws_id = request.cookies.get(ACTIVE_WS_COOKIE) + ws_id = cookie_ws_id or await default_personal_ws_id(uid) if not ws_id: raise HTTPException(403, "No accessible workspace.") ws = await find_workspace(ws_id) + mem = await find_ws_membership(ws_id, uid) if ws else None + + # A stale/inaccessible `active_workspace` cookie (e.g. a workspace the user + # only reached via the old org-admin cascade, or one since removed) must not + # 403 every scoped request — that reads as "all my data vanished". Fall back + # to the user's personal workspace, which the frontend also resolves to. + if (not ws or not mem) and cookie_ws_id: + fallback_id = await default_personal_ws_id(uid) + if fallback_id and fallback_id != ws_id: + ws_id = fallback_id + ws = await find_workspace(ws_id) + mem = await find_ws_membership(ws_id, uid) if ws else None + if not ws: raise HTTPException(403, "Workspace not found.") - - mem = await find_ws_membership(ws_id, uid) - if mem: - ws_role = mem["ws_role"] - else: - org_mem = await find_org_membership(ws["org_id"], uid) - if not org_mem or org_mem["org_role"] not in ("owner", "admin"): - raise HTTPException(403, "Not a member of this workspace.") - ws_role = "admin" # implicit cascade + if not mem: + raise HTTPException(403, "Not a member of this workspace.") + ws_role = mem["ws_role"] enc_settings = (ws.get("settings") or {}).get("encryption") has_encryption = enc_settings is not None return WorkspaceContext( uid=uid, - org_id=ws["org_id"], workspace_id=ws_id, ws_role=ws_role, is_personal=bool(ws.get("is_personal")), @@ -91,7 +94,7 @@ async def get_workspace_write_ctx( def apply_workspace_filter( ctx: WorkspaceContext, base_filter: dict[str, Any] ) -> dict[str, Any]: - flt = {**base_filter, "org_id": ctx.org_id, "workspace_id": ctx.workspace_id} + flt = {**base_filter, "workspace_id": ctx.workspace_id} if ctx.is_personal: assert ctx.owner_uid is not None, ( f"Personal workspace {ctx.workspace_id} has no owner_uid — " @@ -114,7 +117,6 @@ def apply_legacy_or_filter( so it cannot leak across users. """ stamped: dict[str, Any] = { - "org_id": ctx.org_id, "workspace_id": ctx.workspace_id, } if ctx.is_personal: diff --git a/apps/backend/app/api/routes/workspaces/repo.py b/apps/backend/app/api/routes/workspaces/repo.py index a35f86e7..20946e9d 100644 --- a/apps/backend/app/api/routes/workspaces/repo.py +++ b/apps/backend/app/api/routes/workspaces/repo.py @@ -2,41 +2,15 @@ from pymongo.errors import DuplicateKeyError from app.database import db_manager from app.utils.collection_name import ( - ORGANIZATIONS, - ORG_MEMBERSHIPS, WORKSPACES, WORKSPACE_MEMBERSHIPS, ) from app.utils.utils import create_timestamp, new_id -async def find_user_orgs(uid: str) -> list[dict[str, Any]]: +async def find_user_workspaces(uid: str) -> list[dict[str, Any]]: memberships = await db_manager.find( - ORG_MEMBERSHIPS, {"uid": uid}, limit=100 - ) - if not memberships: - return [] - org_ids = [m["org_id"] for m in memberships] - orgs = await db_manager.find( - ORGANIZATIONS, {"_id": {"$in": org_ids}, "deleted_at": None}, limit=100 - ) - by_id = {o["_id"]: o for o in orgs} - out: list[dict[str, Any]] = [] - for m in memberships: - org = by_id.get(m["org_id"]) - if org: - out.append({**org, "org_role": m["org_role"]}) - return out - - -async def find_user_workspaces( - uid: str, org_id: str | None = None -) -> list[dict[str, Any]]: - flt: dict[str, Any] = {"uid": uid} - if org_id is not None: - flt["org_id"] = org_id - memberships = await db_manager.find( - WORKSPACE_MEMBERSHIPS, flt, limit=500 + WORKSPACE_MEMBERSHIPS, {"uid": uid}, limit=500 ) if not memberships: return [] @@ -57,16 +31,6 @@ async def find_workspace(workspace_id: str) -> dict[str, Any] | None: return await db_manager.find_one(WORKSPACES, {"_id": workspace_id}) -async def find_org(org_id: str) -> dict[str, Any] | None: - return await db_manager.find_one(ORGANIZATIONS, {"_id": org_id}) - - -async def find_org_membership(org_id: str, uid: str) -> dict[str, Any] | None: - return await db_manager.find_one( - ORG_MEMBERSHIPS, {"org_id": org_id, "uid": uid} - ) - - async def find_ws_membership( workspace_id: str, uid: str ) -> dict[str, Any] | None: @@ -75,73 +39,16 @@ async def find_ws_membership( ) -async def upsert_org( - name: str, slug: str, kind: str, owner_uid: str | None -) -> str: - existing = await db_manager.find_one(ORGANIZATIONS, {"slug": slug}) - if existing: - return existing["_id"] - ts = create_timestamp() - doc = { - "_id": new_id(), - "name": name, - "slug": slug, - "kind": kind, - "owner_uid": owner_uid, - "settings": {}, - "createdAt": ts, - "updatedAt": ts, - } - try: - await db_manager.insert_one(ORGANIZATIONS, doc) - return doc["_id"] - except DuplicateKeyError: - # Concurrent caller won the race — re-read and return their id. - existing = await db_manager.find_one(ORGANIZATIONS, {"slug": slug}) - if not existing: - raise - return existing["_id"] - - -async def upsert_org_membership( - org_id: str, uid: str, org_role: str -) -> None: - existing = await db_manager.find_one( - ORG_MEMBERSHIPS, {"org_id": org_id, "uid": uid} - ) - if existing: - return - try: - await db_manager.insert_one( - ORG_MEMBERSHIPS, - { - "_id": new_id(), - "org_id": org_id, - "uid": uid, - "org_role": org_role, - "createdAt": create_timestamp(), - }, - ) - except DuplicateKeyError: - # Concurrent caller won the race — membership already exists, nothing to do. - existing = await db_manager.find_one( - ORG_MEMBERSHIPS, {"org_id": org_id, "uid": uid} - ) - if not existing: - raise - - def _personal_slug(uid: str) -> str: return f"personal-{uid[:12]}" # ponytail: workspace creation race tolerated — workspace_setup_at short-circuit in T3 # prevents repeat calls per user. Tighten if a workspace dup is ever observed in prod. -async def upsert_personal_workspace(org_id: str, owner_uid: str) -> str: +async def upsert_personal_workspace(owner_uid: str) -> str: existing = await db_manager.find_one( WORKSPACES, { - "org_id": org_id, "owner_uid": owner_uid, "is_personal": True, }, @@ -151,7 +58,6 @@ async def upsert_personal_workspace(org_id: str, owner_uid: str) -> str: ts = create_timestamp() doc = { "_id": new_id(), - "org_id": org_id, "name": "Personal", "slug": _personal_slug(owner_uid), "is_personal": True, @@ -166,7 +72,7 @@ async def upsert_personal_workspace(org_id: str, owner_uid: str) -> str: async def upsert_ws_membership( - workspace_id: str, org_id: str, uid: str, ws_role: str + workspace_id: str, uid: str, ws_role: str ) -> None: existing = await db_manager.find_one( WORKSPACE_MEMBERSHIPS, @@ -180,7 +86,6 @@ async def upsert_ws_membership( { "_id": new_id(), "workspace_id": workspace_id, - "org_id": org_id, "uid": uid, "ws_role": ws_role, "createdAt": create_timestamp(), @@ -195,25 +100,8 @@ async def upsert_ws_membership( raise -async def set_org_deleted(org_id: str, deleted_at: int | None) -> None: - await db_manager.update_one( - ORGANIZATIONS, {"_id": org_id}, - {"$set": {"deleted_at": deleted_at, "updatedAt": create_timestamp()}}, - ) - - async def set_workspace_deleted(workspace_id: str, deleted_at: int | None) -> None: await db_manager.update_one( WORKSPACES, {"_id": workspace_id}, {"$set": {"deleted_at": deleted_at, "updatedAt": create_timestamp()}}, ) - - -async def find_org_members(org_id: str) -> list[dict[str, Any]]: - return await db_manager.find(ORG_MEMBERSHIPS, {"org_id": org_id}, limit=500) - - -async def find_workspace_members(workspace_id: str) -> list[dict[str, Any]]: - return await db_manager.find( - WORKSPACE_MEMBERSHIPS, {"workspace_id": workspace_id}, limit=500, - ) diff --git a/apps/backend/app/api/routes/workspaces/schema.py b/apps/backend/app/api/routes/workspaces/schema.py index 754f269b..4728d97a 100644 --- a/apps/backend/app/api/routes/workspaces/schema.py +++ b/apps/backend/app/api/routes/workspaces/schema.py @@ -1,28 +1,17 @@ from typing import Literal from pydantic import BaseModel, Field -OrgKind = Literal["system", "user"] -OrgRole = Literal["owner", "admin", "member", "viewer"] WsKind = Literal["personal", "shared"] WsRole = Literal["admin", "developer", "viewer"] -class OrgOut(BaseModel): - id: str - name: str - slug: str - kind: OrgKind - org_role: OrgRole - - class WorkspaceEncryptionInfo(BaseModel): scheme: str dekFingerprint: str createdAt: int rotatedAt: int | None = None - # Set true when a member is removed; cleared on the next DEK rotation. - # Signals admins that the shared key must be rotated to revoke the removed - # member's decryption capability. + # Retained for schema compatibility with existing encrypted workspaces. + # No longer set now that member sharing is removed (single-user workspaces). rotationRequired: bool = False @@ -32,7 +21,6 @@ class WorkspaceSettings(BaseModel): class WorkspaceOut(BaseModel): id: str - org_id: str name: str slug: str is_personal: bool @@ -49,61 +37,6 @@ class SetActiveWorkspaceResponse(BaseModel): workspace_id: str -InvitationStatus = Literal["pending", "accepted", "revoked", "expired", "wrapping_pending"] - - -class OrgCreate(BaseModel): - name: str = Field(min_length=1, max_length=80) - slug: str | None = Field(default=None, min_length=1, max_length=80) - - -class OrgPatch(BaseModel): - name: str | None = Field(default=None, min_length=1, max_length=80) - - -class WorkspaceCreate(BaseModel): - name: str = Field(min_length=1, max_length=80) - slug: str | None = Field(default=None, min_length=1, max_length=80) - - -class WorkspacePatch(BaseModel): - name: str | None = Field(default=None, min_length=1, max_length=80) - - -class MemberOut(BaseModel): - uid: str - email: str | None - display_name: str | None = None - role: str - since: int - - -class InviteMemberRequest(BaseModel): - email: str = Field(min_length=1) - role: str - # Org-level invite only — optionally also grant a workspace membership on accept. - workspace_id: str | None = None - workspace_role: str | None = None - - -class ChangeRoleRequest(BaseModel): - role: str - - -class InvitationOut(BaseModel): - id: str - org_id: str - workspace_id: str | None - invited_email: str - invited_uid: str | None - invited_role_org: OrgRole | None - invited_role_ws: WsRole | None - status: InvitationStatus - token: str - expires_at: int - created_at: int - - class EncryptionBlob(BaseModel): encrypted: str iv: str @@ -120,38 +53,3 @@ class KeypairPostRequest(BaseModel): publicKey: str = Field(min_length=1) privateKeyEncrypted: EncryptionBlob salt: str = Field(min_length=1) - - -class WrappedDekBlob(BaseModel): - encrypted: str - iv: str - senderPublicKey: str - - -class DekWrapOut(BaseModel): - wrappedDek: WrappedDekBlob | None - wrappedDekVersion: int - # SHA-256 fingerprint of the current workspace DEK (from workspace settings). - # Client verifies the unwrapped DEK matches this before trusting it (M-1). - expectedFingerprint: str | None = None - - -class DekWrapPostRequest(BaseModel): - target_uid: str - wrapped: WrappedDekBlob - - -class WrapForMember(BaseModel): - uid: str - wrapped: WrappedDekBlob - - -class RotateDekRequest(BaseModel): - dekFingerprint: str = Field(min_length=1) - wraps: list[WrapForMember] - - -class PendingWrapOut(BaseModel): - uid: str - email: str | None - publicKey: str | None diff --git a/apps/backend/app/api/routes/workspaces/seed.py b/apps/backend/app/api/routes/workspaces/seed.py deleted file mode 100644 index ba0f6d3a..00000000 --- a/apps/backend/app/api/routes/workspaces/seed.py +++ /dev/null @@ -1,24 +0,0 @@ -from app.api.routes.workspaces.repo import upsert_org -from app.database import db_manager -from app.utils.collection_name import ORGANIZATIONS - -SYSTEM_ORG_NAME = "MyDevTools Cloud" -SYSTEM_ORG_SLUG = "mydevtools-cloud" - - -async def ensure_system_org() -> str: - """Idempotently create the system org. Returns its id.""" - return await upsert_org( - name=SYSTEM_ORG_NAME, - slug=SYSTEM_ORG_SLUG, - kind="system", - owner_uid=None, - ) - - -async def get_system_org_id() -> str | None: - """Return the singleton system org id, or None if not yet seeded.""" - doc = await db_manager.find_one(ORGANIZATIONS, {"slug": SYSTEM_ORG_SLUG}) - if not doc: - return None - return doc["_id"] diff --git a/apps/backend/app/api/routes/workspaces/services.py b/apps/backend/app/api/routes/workspaces/services.py index c5495b17..3b88ab44 100644 --- a/apps/backend/app/api/routes/workspaces/services.py +++ b/apps/backend/app/api/routes/workspaces/services.py @@ -2,11 +2,9 @@ from app.api.routes.auth import users_repo from app.api.routes.workspaces.repo import ( - upsert_org_membership, upsert_personal_workspace, upsert_ws_membership, ) -from app.api.routes.workspaces.seed import ensure_system_org from app.api.routes.workspaces.backfill import schedule_backfill @@ -14,8 +12,8 @@ async def ensure_user_workspace_setup( uid: str, background_tasks: BackgroundTasks | None = None, ) -> str: - """Idempotently create the user's MyDevTools Cloud membership and - Personal workspace. Returns the user's Personal workspace_id. + """Idempotently create the user's Personal workspace. + Returns the user's Personal workspace_id. """ setup_at = await users_repo.get_workspace_setup_at(uid) if setup_at: @@ -23,10 +21,8 @@ async def ensure_user_workspace_setup( if ws_id: return ws_id - org_id = await ensure_system_org() - await upsert_org_membership(org_id, uid, "member") - ws_id = await upsert_personal_workspace(org_id, uid) - await upsert_ws_membership(ws_id, org_id, uid, "admin") + ws_id = await upsert_personal_workspace(uid) + await upsert_ws_membership(ws_id, uid, "admin") await users_repo.mark_workspace_setup(uid, ws_id) @@ -34,6 +30,6 @@ async def ensure_user_workspace_setup( if not migrated_at: await users_repo.mark_migration_pending(uid) if background_tasks is not None: - schedule_backfill(background_tasks, uid, ws_id, org_id) + schedule_backfill(background_tasks, uid, ws_id) return ws_id diff --git a/apps/backend/app/api/routes/workspaces/sweeper.py b/apps/backend/app/api/routes/workspaces/sweeper.py deleted file mode 100644 index d5a26e33..00000000 --- a/apps/backend/app/api/routes/workspaces/sweeper.py +++ /dev/null @@ -1,71 +0,0 @@ -import asyncio -import logging -from app.database import db_manager -from app.utils.collection_name import ( - INVITATIONS, ORGANIZATIONS, WORKSPACES, - ORG_MEMBERSHIPS, WORKSPACE_MEMBERSHIPS, -) -from app.utils.utils import create_timestamp - -log = logging.getLogger(__name__) -SOFT_DELETE_RETENTION_MS = 30 * 24 * 3600 * 1000 - - -async def _hard_delete_orgs(threshold: int) -> int: - olds = await db_manager.find( - ORGANIZATIONS, - {"deleted_at": {"$ne": None, "$lt": threshold}}, - limit=500, - ) - if not olds: - return 0 - org_ids = [o["_id"] for o in olds] - await db_manager.delete_many(ORG_MEMBERSHIPS, {"org_id": {"$in": org_ids}}) - await db_manager.delete_many(WORKSPACE_MEMBERSHIPS, {"org_id": {"$in": org_ids}}) - await db_manager.delete_many(WORKSPACES, {"org_id": {"$in": org_ids}}) - await db_manager.delete_many(ORGANIZATIONS, {"_id": {"$in": org_ids}}) - return len(org_ids) - - -async def _hard_delete_workspaces(threshold: int) -> int: - olds = await db_manager.find( - WORKSPACES, - {"deleted_at": {"$ne": None, "$lt": threshold}, "is_personal": False}, - limit=500, - ) - if not olds: - return 0 - ws_ids = [w["_id"] for w in olds] - await db_manager.delete_many(WORKSPACE_MEMBERSHIPS, {"workspace_id": {"$in": ws_ids}}) - await db_manager.delete_many(WORKSPACES, {"_id": {"$in": ws_ids}}) - return len(ws_ids) - - -async def _expire_invitations(now: int) -> int: - res = await db_manager.update_many( - INVITATIONS, - {"status": "pending", "expires_at": {"$lt": now}}, - {"$set": {"status": "expired"}}, - ) - return getattr(res, "modified_count", 0) - - -async def run_sweeper_once() -> dict[str, int]: - now = create_timestamp() - threshold = now - SOFT_DELETE_RETENTION_MS - return { - "orgs_hard_deleted": await _hard_delete_orgs(threshold), - "workspaces_hard_deleted": await _hard_delete_workspaces(threshold), - "invitations_expired": await _expire_invitations(now), - } - - -async def sweeper_loop(interval_seconds: int = 3600) -> None: - while True: - try: - stats = await run_sweeper_once() - if any(stats.values()): - log.info("sweeper: %s", stats) - except Exception as exc: - log.warning("sweeper error: %s", exc) - await asyncio.sleep(interval_seconds) diff --git a/apps/backend/app/core/email.py b/apps/backend/app/core/email.py deleted file mode 100644 index d46b8f9f..00000000 --- a/apps/backend/app/core/email.py +++ /dev/null @@ -1,63 +0,0 @@ -import asyncio -import logging -from urllib.parse import urlencode - -from app.core.config import get_settings - -log = logging.getLogger(__name__) - - -def _build_invite_url(token: str) -> str: - settings = get_settings() - qs = urlencode({"invite": token}) - return f"{settings.APP_PUBLIC_URL.rstrip('/')}/login?{qs}" - - -def _render_html(*, inviter_name: str, org_name: str, workspace_name: str | None, invite_url: str) -> str: - target = f"{org_name} / {workspace_name}" if workspace_name else org_name - return f""" -

Hi,

-

{inviter_name} invited you to join {target} on MyDevTools.

-

Accept invitation

-

This link is valid for 14 days.

- """ - - -async def send_invitation_email( - *, - to: str, - token: str, - inviter_name: str, - org_name: str, - workspace_name: str | None, -) -> None: - invite_url = _build_invite_url(token) - settings = get_settings() - if not settings.RESEND_API_KEY: - log.info( - "DEV email: would send to=%s token=%s url=%s org=%s workspace=%s", - to, token, invite_url, org_name, workspace_name, - ) - return - - import resend - - resend.api_key = settings.RESEND_API_KEY - html = _render_html( - inviter_name=inviter_name, - org_name=org_name, - workspace_name=workspace_name, - invite_url=invite_url, - ) - target = f"{org_name}" + (f" / {workspace_name}" if workspace_name else "") - - # resend.Emails.send is sync, so wrap it with asyncio.to_thread - await asyncio.to_thread( - resend.Emails.send, - { - "from": settings.INVITATION_FROM_EMAIL, - "to": [to], - "subject": f"You're invited to {target} on MyDevTools", - "html": html, - }, - ) diff --git a/apps/backend/app/core/indexes.py b/apps/backend/app/core/indexes.py index 1917a156..a42a711e 100644 --- a/apps/backend/app/core/indexes.py +++ b/apps/backend/app/core/indexes.py @@ -25,11 +25,8 @@ USER_PREFERENCES, USERS, WEBAUTHN_CHALLENGES, - ORGANIZATIONS, - ORG_MEMBERSHIPS, WORKSPACES, WORKSPACE_MEMBERSHIPS, - INVITATIONS, ) from app.core.config import get_settings @@ -77,14 +74,9 @@ async def ensure_indexes() -> None: await db_manager.create_index(AUDIT_LOG, [("uid", 1), ("ts", -1)]) await db_manager.create_index(AUDIT_LOG, [("uid", 1), ("module", 1), ("ts", -1)]) await db_manager.create_index(AUDIT_LOG, "expireAt", expire_after_seconds=0) - await db_manager.create_index(ORGANIZATIONS, [("slug", 1)], unique=True) - await db_manager.create_index( - ORG_MEMBERSHIPS, [("org_id", 1), ("uid", 1)], unique=True - ) - await db_manager.create_index(ORG_MEMBERSHIPS, [("uid", 1)]) await db_manager.create_index( WORKSPACES, - [("org_id", 1), ("owner_uid", 1), ("is_personal", 1)], + [("owner_uid", 1), ("is_personal", 1)], ) await db_manager.create_index( WORKSPACE_MEMBERSHIPS, @@ -92,7 +84,3 @@ async def ensure_indexes() -> None: unique=True, ) await db_manager.create_index(WORKSPACE_MEMBERSHIPS, [("uid", 1)]) - await db_manager.create_index(INVITATIONS, [("token", 1)], unique=True) - await db_manager.create_index(INVITATIONS, [("invited_email", 1), ("status", 1)]) - await db_manager.create_index(INVITATIONS, [("org_id", 1), ("status", 1)]) - await db_manager.create_index(INVITATIONS, [("expires_at", 1), ("status", 1)]) diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 7703281b..9c24cee5 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -25,21 +25,12 @@ async def lifespan(_app: FastAPI): except Exception as exc: logging.getLogger(__name__).warning("Index creation failed: %s", exc) - try: - from app.api.routes.workspaces.seed import ensure_system_org - await ensure_system_org() - except Exception as exc: - logging.getLogger(__name__).warning("System org seed failed: %s", exc) - from app.core.redis_client import open_redis, close_redis await open_redis() from app.api.routes.url_shortener.click_queue import flush_loop, close_flush_client click_flush_task = asyncio.create_task(flush_loop()) - from app.api.routes.workspaces.sweeper import sweeper_loop - sweeper_task = asyncio.create_task(sweeper_loop()) - try: yield finally: @@ -48,11 +39,6 @@ async def lifespan(_app: FastAPI): await click_flush_task except asyncio.CancelledError: pass - sweeper_task.cancel() - try: - await sweeper_task - except asyncio.CancelledError: - pass await close_flush_client() await close_redis() diff --git a/apps/backend/app/utils/collection_name.py b/apps/backend/app/utils/collection_name.py index ccd2e649..64a6ee9a 100644 --- a/apps/backend/app/utils/collection_name.py +++ b/apps/backend/app/utils/collection_name.py @@ -27,9 +27,6 @@ URL_CLICK_EVENTS = "url_click_events" AUDIT_LOG = "audit_log" WEBAUTHN_CHALLENGES = "webauthn_challenges" -ORGANIZATIONS = "organizations" -ORG_MEMBERSHIPS = "org_memberships" WORKSPACES = "workspaces" WORKSPACE_MEMBERSHIPS = "workspace_memberships" -INVITATIONS = "invitations" COUNTERS = "counters" diff --git a/apps/backend/tests/api/routes/api_client/test_collections_delta.py b/apps/backend/tests/api/routes/api_client/test_collections_delta.py index 2b042288..513eb3f8 100644 --- a/apps/backend/tests/api/routes/api_client/test_collections_delta.py +++ b/apps/backend/tests/api/routes/api_client/test_collections_delta.py @@ -28,7 +28,6 @@ BASE_COLLECTION: dict[str, Any] = { "_id": COLLECTION_OID, "created_by": OWNER_UID, - "org_id": "org-test", "workspace_id": "ws-owner", "owner_uid": OWNER_UID, "name": "My Collection", @@ -56,10 +55,9 @@ } -def _make_ctx(uid: str, ws_id: str = "ws-owner", org_id: str = "org-test") -> WorkspaceContext: +def _make_ctx(uid: str, ws_id: str = "ws-owner") -> WorkspaceContext: return WorkspaceContext( uid=uid, - org_id=org_id, workspace_id=ws_id, ws_role="admin", is_personal=True, diff --git a/apps/backend/tests/api/routes/webhook_tester/__init__.py b/apps/backend/tests/api/routes/webhook_tester/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/tests/api/routes/webhook_tester/test_webhook_tester.py b/apps/backend/tests/api/routes/webhook_tester/test_webhook_tester.py new file mode 100644 index 00000000..4437d351 --- /dev/null +++ b/apps/backend/tests/api/routes/webhook_tester/test_webhook_tester.py @@ -0,0 +1,200 @@ +"""Webhook Tester route tests. + +The router is not yet wired into app.api.router (the coordinator adds the +include line), so we mount it on the app here. REDIS_URL is unset in tests, +so the service's in-memory fallback store is exercised. +""" +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi.testclient import TestClient + +from app.api.routes.webhook_tester import services as svc +from app.api.routes.webhook_tester.api import router as webhook_tester_router +from app.main import app + +if not any(getattr(r, "path", "").startswith("/api/v1/webhook-tester") for r in app.routes): + app.include_router(webhook_tester_router, prefix="/api/v1") + +client = TestClient(app) + +BASE = "/api/v1/webhook-tester" + + +@pytest.fixture(autouse=True) +def clean_store(): + svc._memory_bins.clear() + yield + svc._memory_bins.clear() + + +def _create_bin() -> dict: + res = client.post(f"{BASE}/bins") + assert res.status_code == 200 + return res.json() + + +def test_create_bin_shape_and_ttl() -> None: + data = _create_bin() + assert set(data) == {"bin_id", "created_at", "expires_at"} + assert 8 <= len(data["bin_id"]) <= 16 + created = datetime.fromisoformat(data["created_at"]) + expires = datetime.fromisoformat(data["expires_at"]) + assert expires - created == timedelta(hours=24) + + +def test_bin_ids_are_unique() -> None: + ids = {_create_bin()["bin_id"] for _ in range(5)} + assert len(ids) == 5 + + +def test_receive_records_request_details() -> None: + bin_id = _create_bin()["bin_id"] + res = client.post( + f"{BASE}/in/{bin_id}?foo=bar&foo=baz&x=1", + json={"hello": "world"}, + headers={"x-custom": "abc"}, + ) + assert res.status_code == 200 + assert res.json() == {"ok": True} + + listed = client.get(f"{BASE}/bins/{bin_id}/requests") + assert listed.status_code == 200 + body = listed.json() + assert body["bin_id"] == bin_id + assert body["count"] == 1 + req = body["requests"][0] + assert req["method"] == "POST" + assert req["path"] == "" + assert req["query"] == {"foo": "bar,baz", "x": "1"} + assert req["headers"]["x-custom"] == "abc" + assert req["body"] == '{"hello":"world"}' + assert req["body_encoding"] == "text" + assert req["body_truncated"] is False + assert req["content_type"] == "application/json" + assert req["size"] == len('{"hello":"world"}') + assert req["id"] + assert req["received_at"] + + +def test_receive_captures_path_suffix() -> None: + bin_id = _create_bin()["bin_id"] + res = client.put(f"{BASE}/in/{bin_id}/github/push/events", content=b"payload") + assert res.status_code == 200 + req = client.get(f"{BASE}/bins/{bin_id}/requests").json()["requests"][0] + assert req["method"] == "PUT" + assert req["path"] == "github/push/events" + assert req["body"] == "payload" + + +@pytest.mark.parametrize("method", ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) +def test_receive_accepts_all_methods(method: str) -> None: + bin_id = _create_bin()["bin_id"] + res = client.request(method, f"{BASE}/in/{bin_id}") + assert res.status_code == 200 + reqs = client.get(f"{BASE}/bins/{bin_id}/requests").json()["requests"] + assert [r["method"] for r in reqs] == [method] + + +def test_receive_unknown_bin_404() -> None: + assert client.post(f"{BASE}/in/does-not-exist").status_code == 404 + + +def test_list_unknown_bin_404() -> None: + assert client.get(f"{BASE}/bins/does-not-exist/requests").status_code == 404 + + +def test_clear_unknown_bin_404() -> None: + assert client.delete(f"{BASE}/bins/does-not-exist/requests").status_code == 404 + + +def test_expired_bin_rejected() -> None: + bin_id = _create_bin()["bin_id"] + past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat() + svc._memory_bins[bin_id]["expires_at"] = past + assert client.post(f"{BASE}/in/{bin_id}").status_code == 404 + assert client.get(f"{BASE}/bins/{bin_id}/requests").status_code == 404 + + +def test_newest_first_and_capped_at_100() -> None: + bin_id = _create_bin()["bin_id"] + for i in range(105): + client.post(f"{BASE}/in/{bin_id}", content=f"n{i}".encode()) + body = client.get(f"{BASE}/bins/{bin_id}/requests").json() + assert body["count"] == 100 + assert body["requests"][0]["body"] == "n104" + assert body["requests"][-1]["body"] == "n5" # oldest 5 dropped + + +def test_after_cursor_by_request_id() -> None: + bin_id = _create_bin()["bin_id"] + for i in range(3): + client.post(f"{BASE}/in/{bin_id}", content=f"n{i}".encode()) + all_reqs = client.get(f"{BASE}/bins/{bin_id}/requests").json()["requests"] + cursor = all_reqs[1]["id"] # the middle (n1) + newer = client.get(f"{BASE}/bins/{bin_id}/requests", params={"after": cursor}).json() + assert newer["count"] == 1 + assert newer["requests"][0]["body"] == "n2" + + +def test_after_cursor_by_timestamp() -> None: + bin_id = _create_bin()["bin_id"] + client.post(f"{BASE}/in/{bin_id}", content=b"old") + old = client.get(f"{BASE}/bins/{bin_id}/requests").json()["requests"][0] + client.post(f"{BASE}/in/{bin_id}", content=b"new") + newer = client.get( + f"{BASE}/bins/{bin_id}/requests", params={"after": old["received_at"]} + ).json()["requests"] + assert [r["body"] for r in newer] == ["new"] + + +def test_after_unknown_cursor_returns_everything() -> None: + bin_id = _create_bin()["bin_id"] + client.post(f"{BASE}/in/{bin_id}", content=b"a") + body = client.get(f"{BASE}/bins/{bin_id}/requests", params={"after": "bogus"}).json() + assert body["count"] == 1 + + +def test_binary_body_is_base64() -> None: + bin_id = _create_bin()["bin_id"] + client.post(f"{BASE}/in/{bin_id}", content=b"\xff\xfe\x00binary") + req = client.get(f"{BASE}/bins/{bin_id}/requests").json()["requests"][0] + assert req["body_encoding"] == "base64" + import base64 + + assert base64.b64decode(req["body"]) == b"\xff\xfe\x00binary" + + +def test_large_body_truncated_at_64kb() -> None: + bin_id = _create_bin()["bin_id"] + client.post(f"{BASE}/in/{bin_id}", content=b"a" * (svc.MAX_BODY_BYTES + 500)) + req = client.get(f"{BASE}/bins/{bin_id}/requests").json()["requests"][0] + assert req["body_truncated"] is True + assert len(req["body"]) == svc.MAX_BODY_BYTES + assert req["size"] == svc.MAX_BODY_BYTES + 500 + + +def test_clear_requests() -> None: + bin_id = _create_bin()["bin_id"] + client.post(f"{BASE}/in/{bin_id}", content=b"x") + res = client.delete(f"{BASE}/bins/{bin_id}/requests") + assert res.status_code == 204 + body = client.get(f"{BASE}/bins/{bin_id}/requests").json() + assert body["count"] == 0 + # Bin itself still alive after clearing + assert client.post(f"{BASE}/in/{bin_id}", content=b"y").status_code == 200 + + +def test_source_ip_prefers_x_forwarded_for() -> None: + bin_id = _create_bin()["bin_id"] + client.post(f"{BASE}/in/{bin_id}", headers={"x-forwarded-for": "203.0.113.7, 10.0.0.1"}) + req = client.get(f"{BASE}/bins/{bin_id}/requests").json()["requests"][0] + assert req["source_ip"] == "203.0.113.7" + + +def test_endpoints_do_not_require_auth() -> None: + # No cookies / Authorization header anywhere in this file — reaching 200s + # above already proves it, but assert explicitly for the receive endpoint. + bin_id = _create_bin()["bin_id"] + res = client.post(f"{BASE}/in/{bin_id}", json={"ping": 1}) + assert res.status_code == 200 diff --git a/apps/backend/tests/api/routes/whois/__init__.py b/apps/backend/tests/api/routes/whois/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/tests/api/routes/whois/test_whois_lookup.py b/apps/backend/tests/api/routes/whois/test_whois_lookup.py new file mode 100644 index 00000000..c8c695d7 --- /dev/null +++ b/apps/backend/tests/api/routes/whois/test_whois_lookup.py @@ -0,0 +1,294 @@ +import asyncio + +import httpx +import pytest +from fastapi import HTTPException + +from app.api.routes.whois import services as svc + +RDAP_DOMAIN = { + "objectClassName": "domain", + "ldhName": "EXAMPLE.COM", + "status": ["client delete prohibited", "client transfer prohibited"], + "events": [ + {"eventAction": "registration", "eventDate": "1995-08-14T04:00:00Z"}, + {"eventAction": "expiration", "eventDate": "2026-08-13T04:00:00Z"}, + {"eventAction": "last changed", "eventDate": "2025-08-14T07:01:44Z"}, + {"eventAction": "last update of RDAP database", "eventDate": "2026-01-01T00:00:00Z"}, + ], + "entities": [ + { + "objectClassName": "entity", + "handle": "376", + "roles": ["registrar"], + "vcardArray": [ + "vcard", + [ + ["version", {}, "text", "4.0"], + ["fn", {}, "text", "RESERVED-Internet Assigned Numbers Authority"], + ], + ], + "entities": [ + { + "objectClassName": "entity", + "handle": "ABUSE-1", + "roles": ["abuse"], + "vcardArray": ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "Abuse Desk"]]], + } + ], + }, + { + "objectClassName": "entity", + "roles": ["registrant"], + "vcardArray": [ + "vcard", + [ + ["version", {}, "text", "4.0"], + ["fn", {}, "text", "REDACTED FOR PRIVACY"], + ["org", {}, "text", "Example Org"], + ], + ], + }, + ], + "nameservers": [ + {"ldhName": "A.IANA-SERVERS.NET."}, + {"ldhName": "B.IANA-SERVERS.NET"}, + {"ldhName": "a.iana-servers.net"}, + ], +} + +RDAP_IP = { + "objectClassName": "ip network", + "handle": "NET-8-8-8-0-1", + "name": "LVLT-GOGL-8-8-8", + "startAddress": "8.8.8.0", + "endAddress": "8.8.8.255", + "status": ["active"], + "events": [ + {"eventAction": "registration", "eventDate": "2014-03-14T00:00:00Z"}, + {"eventAction": "last changed", "eventDate": "2014-03-14T00:00:00Z"}, + ], + "entities": [ + { + "objectClassName": "entity", + "handle": "GOGL", + "roles": ["registrant"], + "vcardArray": ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "Google LLC"]]], + } + ], +} + + +# --------------------------------------------------------------------------- +# sanitize_target +# --------------------------------------------------------------------------- + +def test_sanitize_target_plain_domain(): + assert svc.sanitize_target("example.com") == ("example.com", "domain") + + +def test_sanitize_target_strips_scheme_path_and_case(): + assert svc.sanitize_target("https://Example.COM/path?q=1#frag") == ("example.com", "domain") + + +def test_sanitize_target_strips_trailing_dot_and_port(): + assert svc.sanitize_target("example.com.") == ("example.com", "domain") + assert svc.sanitize_target("example.com:8080") == ("example.com", "domain") + + +def test_sanitize_target_ipv4(): + assert svc.sanitize_target("8.8.8.8") == ("8.8.8.8", "ip") + + +def test_sanitize_target_ipv6(): + assert svc.sanitize_target("2001:4860:4860::8888") == ("2001:4860:4860::8888", "ip") + assert svc.sanitize_target("[2001:4860:4860::8888]") == ("2001:4860:4860::8888", "ip") + + +@pytest.mark.parametrize("bad", ["", " ", "not a domain", "no-tld", "-bad-.com", "a" * 300 + ".com"]) +def test_sanitize_target_rejects_invalid(bad): + with pytest.raises(HTTPException) as exc: + svc.sanitize_target(bad) + assert exc.value.status_code == 400 + + +# --------------------------------------------------------------------------- +# lookup_whois normalization (fetch mocked) +# --------------------------------------------------------------------------- + +def test_lookup_whois_normalizes_domain(monkeypatch): + async def fake_fetch(*, kind, target): + assert kind == "domain" + assert target == "example.com" + return RDAP_DOMAIN + + monkeypatch.setattr(svc, "fetch_rdap", fake_fetch) + out = asyncio.run(svc.lookup_whois("https://Example.com/whatever")) + + assert out.target == "example.com" + assert out.type == "domain" + assert out.registrar == "RESERVED-Internet Assigned Numbers Authority" + assert out.status == ["client delete prohibited", "client transfer prohibited"] + assert out.created == "1995-08-14T04:00:00Z" + assert out.updated == "2025-08-14T07:01:44Z" + assert out.expires == "2026-08-13T04:00:00Z" + # lowercased, trailing dot stripped, deduped + assert out.nameservers == ["a.iana-servers.net", "b.iana-servers.net"] + assert out.raw == RDAP_DOMAIN + + +def test_lookup_whois_contacts_include_nested_and_redacted(monkeypatch): + async def fake_fetch(*, kind, target): + return RDAP_DOMAIN + + monkeypatch.setattr(svc, "fetch_rdap", fake_fetch) + out = asyncio.run(svc.lookup_whois("example.com")) + + by_role = {tuple(c.roles): c for c in out.contacts} + registrar = by_role[("registrar",)] + assert registrar.name == "RESERVED-Internet Assigned Numbers Authority" + assert registrar.handle == "376" + + registrant = by_role[("registrant",)] + assert registrant.name == "REDACTED FOR PRIVACY" # kept as the registry exposed it + assert registrant.organization == "Example Org" + + abuse = by_role[("abuse",)] # nested inside the registrar entity + assert abuse.name == "Abuse Desk" + assert abuse.handle == "ABUSE-1" + + +def test_lookup_whois_ip(monkeypatch): + async def fake_fetch(*, kind, target): + assert kind == "ip" + assert target == "8.8.8.8" + return RDAP_IP + + monkeypatch.setattr(svc, "fetch_rdap", fake_fetch) + out = asyncio.run(svc.lookup_whois("8.8.8.8")) + + assert out.type == "ip" + assert out.registrar == "LVLT-GOGL-8-8-8" # falls back to network name + assert out.created == "2014-03-14T00:00:00Z" + assert out.nameservers == [] + assert any(c.name == "Google LLC" for c in out.contacts) + + +def test_lookup_whois_unknown_domain_returns_404(monkeypatch): + async def fake_fetch(*, kind, target): + return None + + monkeypatch.setattr(svc, "fetch_rdap", fake_fetch) + with pytest.raises(HTTPException) as exc: + asyncio.run(svc.lookup_whois("definitely-not-registered-zzz.com")) + assert exc.value.status_code == 404 + + +def test_lookup_whois_invalid_target_short_circuits(monkeypatch): + called = False + + async def fake_fetch(*, kind, target): + nonlocal called + called = True + return RDAP_DOMAIN + + monkeypatch.setattr(svc, "fetch_rdap", fake_fetch) + with pytest.raises(HTTPException) as exc: + asyncio.run(svc.lookup_whois("!!not valid!!")) + assert exc.value.status_code == 400 + assert called is False + + +# --------------------------------------------------------------------------- +# fetch_rdap (httpx mocked) +# --------------------------------------------------------------------------- + +class _FakeResponse: + def __init__(self, status_code=200, json_data=None): + self.status_code = status_code + self._json = json_data + + def json(self): + if self._json is None: + raise ValueError("no json") + return self._json + + +class _FakeAsyncClient: + init_kwargs = None + last_url = None + response = None + error = None + + def __init__(self, **kwargs): + _FakeAsyncClient.init_kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def get(self, url, **kwargs): + _FakeAsyncClient.last_url = url + if _FakeAsyncClient.error is not None: + raise _FakeAsyncClient.error + return _FakeAsyncClient.response + + +@pytest.fixture +def fake_http(monkeypatch): + _FakeAsyncClient.init_kwargs = None + _FakeAsyncClient.last_url = None + _FakeAsyncClient.response = None + _FakeAsyncClient.error = None + monkeypatch.setattr(svc.httpx, "AsyncClient", _FakeAsyncClient) + return _FakeAsyncClient + + +def test_fetch_rdap_domain_url_and_redirects(fake_http): + fake_http.response = _FakeResponse(200, RDAP_DOMAIN) + out = asyncio.run(svc.fetch_rdap(kind="domain", target="example.com")) + assert out == RDAP_DOMAIN + assert fake_http.last_url == "https://rdap.org/domain/example.com" + assert fake_http.init_kwargs.get("follow_redirects") is True + + +def test_fetch_rdap_ip_url(fake_http): + fake_http.response = _FakeResponse(200, RDAP_IP) + out = asyncio.run(svc.fetch_rdap(kind="ip", target="8.8.8.8")) + assert out == RDAP_IP + assert fake_http.last_url == "https://rdap.org/ip/8.8.8.8" + + +def test_fetch_rdap_404_returns_none(fake_http): + fake_http.response = _FakeResponse(404) + assert asyncio.run(svc.fetch_rdap(kind="domain", target="nope.example")) is None + + +def test_fetch_rdap_upstream_error_maps_to_502(fake_http): + fake_http.response = _FakeResponse(500) + with pytest.raises(HTTPException) as exc: + asyncio.run(svc.fetch_rdap(kind="domain", target="example.com")) + assert exc.value.status_code == 502 + + +def test_fetch_rdap_rate_limit_maps_to_503(fake_http): + fake_http.response = _FakeResponse(429) + with pytest.raises(HTTPException) as exc: + asyncio.run(svc.fetch_rdap(kind="domain", target="example.com")) + assert exc.value.status_code == 503 + + +def test_fetch_rdap_network_error_maps_to_502(fake_http): + fake_http.error = httpx.ConnectError("boom") + with pytest.raises(HTTPException) as exc: + asyncio.run(svc.fetch_rdap(kind="domain", target="example.com")) + assert exc.value.status_code == 502 + + +def test_fetch_rdap_invalid_json_maps_to_502(fake_http): + fake_http.response = _FakeResponse(200, None) + with pytest.raises(HTTPException) as exc: + asyncio.run(svc.fetch_rdap(kind="domain", target="example.com")) + assert exc.value.status_code == 502 diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 9e303a1b..104437e6 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -18,7 +18,6 @@ os.environ.setdefault("WEBAUTHN_ORIGINS", "http://localhost:3000") os.environ.setdefault("WEBAUTHN_CHALLENGE_TTL_SECONDS", "300") -from app.api.routes.workspaces.seed import ensure_system_org from app.api.routes.workspaces.services import ensure_user_workspace_setup from app.database import db_manager from app.utils.collection_name import ( @@ -35,8 +34,6 @@ NOSQL_CONNECTIONS, NOSQL_QUERY_HISTORY, NOTES, - ORGANIZATIONS, - ORG_MEMBERSHIPS, PASSWORD_ENTRIES, PASSWORD_VAULTS, PROJECTS, @@ -50,7 +47,6 @@ WORKSPACE_MEMBERSHIPS, URL_LINKS, URL_CLICK_EVENTS, - INVITATIONS, ) pytest_plugins = ("pytest_asyncio",) @@ -59,7 +55,7 @@ @pytest.fixture async def clean_db(): """Drop workspace-related collections and USERS before and after test.""" - collections = [API_CLIENT_COLLECTIONS, API_CLIENT_ENVIRONMENTS, API_CLIENT_HISTORY, API_CLIENT_WORKSPACES, API_KEY_VAULT_ENTRIES, ORGANIZATIONS, ORG_MEMBERSHIPS, WORKSPACES, WORKSPACE_MEMBERSHIPS, USERS, PASSWORD_ENTRIES, PASSWORD_VAULTS, NOTES, USER_PREFERENCES, BOOKMARKS, BOOKMARK_FOLDERS, TASKS, PROJECTS, ENV_MANAGER_ENTRIES, CODE_SNIPPETS, NOSQL_CONNECTIONS, NOSQL_QUERY_HISTORY, SQL_CONNECTIONS, S3_CONNECTIONS, REDIS_CONNECTIONS, URL_LINKS, URL_CLICK_EVENTS, JSON_FORMATTER_DOCUMENTS, INVITATIONS] + collections = [API_CLIENT_COLLECTIONS, API_CLIENT_ENVIRONMENTS, API_CLIENT_HISTORY, API_CLIENT_WORKSPACES, API_KEY_VAULT_ENTRIES, WORKSPACES, WORKSPACE_MEMBERSHIPS, USERS, PASSWORD_ENTRIES, PASSWORD_VAULTS, NOTES, USER_PREFERENCES, BOOKMARKS, BOOKMARK_FOLDERS, TASKS, PROJECTS, ENV_MANAGER_ENTRIES, CODE_SNIPPETS, NOSQL_CONNECTIONS, NOSQL_QUERY_HISTORY, SQL_CONNECTIONS, S3_CONNECTIONS, REDIS_CONNECTIONS, URL_LINKS, URL_CLICK_EVENTS, JSON_FORMATTER_DOCUMENTS] # Clean before test for coll in collections: @@ -166,12 +162,6 @@ async def seed_legacy_user_data(): yield -@pytest.fixture -async def system_org_id(): - """Idempotently create the system org and return its id.""" - return await ensure_system_org() - - @pytest.fixture def personal_ws_for(): """Return an async callable that creates (idempotently) a Personal workspace diff --git a/apps/backend/tests/test_api_client_workspace_isolation.py b/apps/backend/tests/test_api_client_workspace_isolation.py index a7d3105a..ca4685ce 100644 --- a/apps/backend/tests/test_api_client_workspace_isolation.py +++ b/apps/backend/tests/test_api_client_workspace_isolation.py @@ -15,10 +15,9 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( uid=uid, - org_id=org_id, workspace_id=ws_id, ws_role="admin", is_personal=True, @@ -28,15 +27,14 @@ def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: @pytest.mark.asyncio async def test_api_client_collections_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """Collections created by u1 must not appear in u2's list.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await api_client_svc.create_collection(ctx_u1, ApiClientCollectionCreate(name="u1-collection")) @@ -49,32 +47,30 @@ async def test_api_client_collections_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_cannot_read_api_client_collections( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """u2 forging u1's workspace_id still cannot see u1's collections due to owner_uid.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await api_client_svc.create_collection(ctx_u1, ApiClientCollectionCreate(name="secret-collection")) - forged_ctx = _ctx("u2", ws_u1, org_id) # u2 claims u1's workspace_id + forged_ctx = _ctx("u2", ws_u1) # u2 claims u1's workspace_id cols = await api_client_svc.list_collections(ctx=forged_ctx) assert cols == [] # owner_uid filter prevents cross-user bleed @pytest.mark.asyncio async def test_api_client_environments_and_history_isolated( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """Environments and history created by u1 must not appear for u2.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await api_client_svc.create_environment(ctx_u1, ApiClientEnvironmentCreate(name="prod")) await api_client_svc.create_history( diff --git a/apps/backend/tests/test_api_key_vault_workspace_isolation.py b/apps/backend/tests/test_api_key_vault_workspace_isolation.py index b6470095..017807e1 100644 --- a/apps/backend/tests/test_api_key_vault_workspace_isolation.py +++ b/apps/backend/tests/test_api_key_vault_workspace_isolation.py @@ -4,23 +4,22 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( - uid=uid, org_id=org_id, workspace_id=ws_id, ws_role="admin", + uid=uid, workspace_id=ws_id, ws_role="admin", is_personal=True, owner_uid=uid, ) @pytest.mark.asyncio async def test_api_key_vault_entries_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await vault_svc.create_entry(ctx_u1, ApiKeyEntryCreate(encryptedData="enc-u1", iv="iv-u1")) @@ -33,15 +32,14 @@ async def test_api_key_vault_entries_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_api_key_vault_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await vault_svc.create_entry(ctx_u1, ApiKeyEntryCreate(encryptedData="enc-u1", iv="iv-u1")) - forged_ctx = _ctx("u2", ws_u1, org_id) # u2 forges u1's workspace_id + forged_ctx = _ctx("u2", ws_u1) # u2 forges u1's workspace_id entries = await vault_svc.list_entries(forged_ctx) assert entries == [] # owner_uid filter saves us diff --git a/apps/backend/tests/test_audit_middleware.py b/apps/backend/tests/test_audit_middleware.py index 71415eee..fadbd61d 100644 --- a/apps/backend/tests/test_audit_middleware.py +++ b/apps/backend/tests/test_audit_middleware.py @@ -101,7 +101,7 @@ async def fake_insert_one(collection_name, data): monkeypatch.setattr("app.api.routes.bookmarks.services.db_manager.insert_one", fake_insert_one) _bm_ctx = WorkspaceContext( - uid="uid1", org_id="org1", workspace_id="ws1", ws_role="admin", + uid="uid1", workspace_id="ws1", ws_role="admin", is_personal=True, owner_uid="uid1", ) diff --git a/apps/backend/tests/test_backfill.py b/apps/backend/tests/test_backfill.py index e1a13a06..41e54505 100644 --- a/apps/backend/tests/test_backfill.py +++ b/apps/backend/tests/test_backfill.py @@ -1,26 +1,22 @@ import pytest from app.api.routes.auth import users_repo -from app.api.routes.workspaces.backfill import BACKFILL_COLLECTIONS, run_user_backfill -from app.api.routes.workspaces.seed import ensure_system_org +from app.api.routes.workspaces.backfill import run_user_backfill from app.database import db_manager from app.utils.collection_name import PASSWORD_ENTRIES, NOTES, USER_PREFERENCES @pytest.mark.asyncio async def test_backfill_stamps_legacy_rows(clean_db, seed_legacy_user_data): - org_id = await ensure_system_org() # seed_legacy_user_data inserts a few rows in PASSWORD_ENTRIES + NOTES # for created_by="u1" with no workspace_id ws_id = "ws-1" - await run_user_backfill("u1", ws_id, org_id) + await run_user_backfill("u1", ws_id) for entry in await db_manager.find(PASSWORD_ENTRIES, {"created_by": "u1"}, limit=100): - assert entry["org_id"] == org_id assert entry["workspace_id"] == ws_id for note in await db_manager.find(NOTES, {"created_by": "u1"}, limit=100): - assert note["org_id"] == org_id assert note["workspace_id"] == ws_id assert await users_repo.get_migrated_at("u1") is not None @@ -28,25 +24,23 @@ async def test_backfill_stamps_legacy_rows(clean_db, seed_legacy_user_data): @pytest.mark.asyncio async def test_backfill_is_idempotent(clean_db, seed_legacy_user_data): - org_id = await ensure_system_org() ws_id = "ws-1" - await run_user_backfill("u1", ws_id, org_id) + await run_user_backfill("u1", ws_id) migrated_first = await users_repo.get_migrated_at("u1") - await run_user_backfill("u1", ws_id, org_id) + await run_user_backfill("u1", ws_id) migrated_second = await users_repo.get_migrated_at("u1") assert migrated_first == migrated_second @pytest.mark.asyncio async def test_backfill_rewrites_pinned_tools(clean_db): - org_id = await ensure_system_org() ws_id = "ws-1" await db_manager.insert_one( USER_PREFERENCES, {"_id": "u1", "uid": "u1", "toolFavorites": ["/app/passwords", "/app/notes"]}, ) - await run_user_backfill("u1", ws_id, org_id) + await run_user_backfill("u1", ws_id) pref = await db_manager.find_one(USER_PREFERENCES, {"_id": "u1"}) assert pref["pinnedToolsByWorkspace"] == {ws_id: ["/app/passwords", "/app/notes"]} diff --git a/apps/backend/tests/test_bookmarks_workspace_isolation.py b/apps/backend/tests/test_bookmarks_workspace_isolation.py index b96cf227..5437cf4a 100644 --- a/apps/backend/tests/test_bookmarks_workspace_isolation.py +++ b/apps/backend/tests/test_bookmarks_workspace_isolation.py @@ -4,23 +4,22 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( - uid=uid, org_id=org_id, workspace_id=ws_id, ws_role="admin", + uid=uid, workspace_id=ws_id, ws_role="admin", is_personal=True, owner_uid=uid, ) @pytest.mark.asyncio async def test_bookmarks_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await bm_svc.create_bookmark(ctx_u1, BookmarkCreate(title="u1 bookmark", url="https://u1.example")) @@ -33,30 +32,28 @@ async def test_bookmarks_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_bookmark_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await bm_svc.create_bookmark(ctx_u1, BookmarkCreate(title="u1 secret", url="https://u1.example")) - forged_ctx = _ctx("u2", ws_u1, org_id) # u2 forges u1's workspace_id + forged_ctx = _ctx("u2", ws_u1) # u2 forges u1's workspace_id bookmarks = await bm_svc.list_bookmarks(ctx=forged_ctx) assert bookmarks == [] # owner_uid filter saves us @pytest.mark.asyncio async def test_folders_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await bm_svc.create_folder(ctx_u1, BookmarkFolderCreate(name="u1 folder")) diff --git a/apps/backend/tests/test_code_snippets_workspace_isolation.py b/apps/backend/tests/test_code_snippets_workspace_isolation.py index b1d26c9d..989138d6 100644 --- a/apps/backend/tests/test_code_snippets_workspace_isolation.py +++ b/apps/backend/tests/test_code_snippets_workspace_isolation.py @@ -4,23 +4,22 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( - uid=uid, org_id=org_id, workspace_id=ws_id, ws_role="admin", + uid=uid, workspace_id=ws_id, ws_role="admin", is_personal=True, owner_uid=uid, ) @pytest.mark.asyncio async def test_code_snippets_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await snippet_svc.create_code_snippet(ctx_u1, CodeSnippetCreate(title="snippet-u1", code="code-u1")) @@ -33,15 +32,14 @@ async def test_code_snippets_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_code_snippets_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await snippet_svc.create_code_snippet(ctx_u1, CodeSnippetCreate(title="snippet-u1", code="code-u1")) - forged_ctx = _ctx("u2", ws_u1, org_id) # u2 forges u1's workspace_id + forged_ctx = _ctx("u2", ws_u1) # u2 forges u1's workspace_id snippets = await snippet_svc.list_code_snippets(forged_ctx) assert snippets == [] # owner_uid filter saves us diff --git a/apps/backend/tests/test_dek_wrap.py b/apps/backend/tests/test_dek_wrap.py deleted file mode 100644 index a31dec65..00000000 --- a/apps/backend/tests/test_dek_wrap.py +++ /dev/null @@ -1,64 +0,0 @@ -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_get_dek_wrap_returns_null_initially(authed_client: AsyncClient, personal_ws_id: str): - res = await authed_client.get(f"/api/v1/workspaces-api/workspaces/{personal_ws_id}/dek-wrap") - assert res.status_code == 200 - body = res.json() - assert body["wrappedDek"] is None - assert body["wrappedDekVersion"] == 0 - - -@pytest.mark.asyncio -async def test_post_then_get_wrap_roundtrip(authed_client: AsyncClient, personal_ws_id: str): - # The authed_client uid is "test-uid" (from conftest), and "test-uid" is the - # sole member of the personal workspace with ws_role="admin", so it can - # wrap for itself. - payload = { - "target_uid": "test-uid", - "wrapped": {"encrypted": "enc", "iv": "iv", "senderPublicKey": "pk"}, - } - res = await authed_client.post( - f"/api/v1/workspaces-api/workspaces/{personal_ws_id}/dek-wrap", json=payload, - ) - assert res.status_code == 204 - - get_res = await authed_client.get( - f"/api/v1/workspaces-api/workspaces/{personal_ws_id}/dek-wrap", - ) - assert get_res.status_code == 200 - body = get_res.json() - assert body["wrappedDek"]["encrypted"] == "enc" - assert body["wrappedDek"]["iv"] == "iv" - assert body["wrappedDek"]["senderPublicKey"] == "pk" - assert body["wrappedDekVersion"] == 1 - - -@pytest.mark.asyncio -async def test_post_wrap_bumps_version_on_second_write(authed_client: AsyncClient, personal_ws_id: str): - payload = { - "target_uid": "test-uid", - "wrapped": {"encrypted": "enc1", "iv": "iv1", "senderPublicKey": "pk1"}, - } - res = await authed_client.post( - f"/api/v1/workspaces-api/workspaces/{personal_ws_id}/dek-wrap", json=payload, - ) - assert res.status_code == 204 - - payload2 = { - "target_uid": "test-uid", - "wrapped": {"encrypted": "enc2", "iv": "iv2", "senderPublicKey": "pk2"}, - } - res2 = await authed_client.post( - f"/api/v1/workspaces-api/workspaces/{personal_ws_id}/dek-wrap", json=payload2, - ) - assert res2.status_code == 204 - - get_res = await authed_client.get( - f"/api/v1/workspaces-api/workspaces/{personal_ws_id}/dek-wrap", - ) - body = get_res.json() - assert body["wrappedDek"]["encrypted"] == "enc2" - assert body["wrappedDekVersion"] == 2 diff --git a/apps/backend/tests/test_email.py b/apps/backend/tests/test_email.py deleted file mode 100644 index 2f66c9ea..00000000 --- a/apps/backend/tests/test_email.py +++ /dev/null @@ -1,18 +0,0 @@ -import logging -import pytest -from app.core.email import send_invitation_email - - -@pytest.mark.asyncio -async def test_dev_mode_logs_when_key_missing(monkeypatch, caplog): - monkeypatch.delenv("RESEND_API_KEY", raising=False) - caplog.set_level(logging.INFO) - await send_invitation_email( - to="alice@example.com", - token="tok-1", - inviter_name="Bob", - org_name="Acme", - workspace_name="Prod", - ) - assert any("alice@example.com" in r.message for r in caplog.records) - assert any("tok-1" in r.message for r in caplog.records) diff --git a/apps/backend/tests/test_environment_manager_workspace_isolation.py b/apps/backend/tests/test_environment_manager_workspace_isolation.py index 69584a91..f4973f3d 100644 --- a/apps/backend/tests/test_environment_manager_workspace_isolation.py +++ b/apps/backend/tests/test_environment_manager_workspace_isolation.py @@ -4,23 +4,22 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( - uid=uid, org_id=org_id, workspace_id=ws_id, ws_role="admin", + uid=uid, workspace_id=ws_id, ws_role="admin", is_personal=True, owner_uid=uid, ) @pytest.mark.asyncio async def test_env_entries_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await env_svc.create_entry(ctx_u1, EnvSetEntryCreate(encryptedData="enc-u1", iv="iv-u1")) @@ -33,15 +32,14 @@ async def test_env_entries_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_env_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await env_svc.create_entry(ctx_u1, EnvSetEntryCreate(encryptedData="enc-u1", iv="iv-u1")) - forged_ctx = _ctx("u2", ws_u1, org_id) # u2 forges u1's workspace_id + forged_ctx = _ctx("u2", ws_u1) # u2 forges u1's workspace_id entries = await env_svc.list_entries(forged_ctx) assert entries == [] # owner_uid filter saves us diff --git a/apps/backend/tests/test_invitations.py b/apps/backend/tests/test_invitations.py deleted file mode 100644 index d81342bf..00000000 --- a/apps/backend/tests/test_invitations.py +++ /dev/null @@ -1,84 +0,0 @@ -import pytest -from httpx import AsyncClient -from app.utils.collection_name import INVITATIONS -from app.api.routes.workspaces.invitations_repo import ( - create_invitation, find_invitation_by_token, -) - - -@pytest.mark.asyncio -async def test_invitations_constant_exported(): - assert INVITATIONS == "invitations" - - -@pytest.mark.asyncio -async def test_create_and_find_invitation_roundtrip(clean_db): - inv_id = await create_invitation({ - "_id": "inv-1", - "org_id": "org-1", - "workspace_id": None, - "invited_email": "test@example.com", - "invited_uid": None, - "invited_role_org": "member", - "invited_role_ws": None, - "token": "tok-abc", - "status": "pending", - "invited_by": "u1", - "created_at": 1000, - "expires_at": 2000, - }) - assert inv_id == "inv-1" - doc = await find_invitation_by_token("tok-abc") - assert doc is not None - assert doc["status"] == "pending" - - -@pytest.fixture -async def authed_client_other_uid(clean_db) -> AsyncClient: - """Authenticated HTTP client for a second test user (uid=other-uid).""" - from app.api.routes.auth.services import get_current_uid - from app.api.routes.workspaces.services import ensure_user_workspace_setup - from app.main import app - - other_uid = "other-uid" - - def override_get_current_uid(): - return other_uid - - app.dependency_overrides[get_current_uid] = override_get_current_uid - - await ensure_user_workspace_setup(other_uid) - - async with AsyncClient( - transport=__import__("httpx").ASGITransport(app=app), - base_url="http://test", - ) as client: - yield client - - app.dependency_overrides.clear() - - -@pytest.mark.asyncio -async def test_invite_to_org_sends_email_and_creates_pending(authed_client, caplog): - import logging - caplog.set_level(logging.INFO) - org_id = (await authed_client.post( - "/api/v1/workspaces-api/orgs", json={"name": "InvOrg"} - )).json()["id"] - res = await authed_client.post( - f"/api/v1/workspaces-api/orgs/{org_id}/members", - json={"email": "alice@example.com", "role": "member"}, - ) - assert res.status_code == 201 - body = res.json() - assert body["invited_email"] == "alice@example.com" - assert body["status"] == "pending" - # Dev-mode logger emits a line containing the recipient - assert any("alice@example.com" in r.message for r in caplog.records) - - -@pytest.mark.asyncio -async def test_accept_invitation_creates_membership(authed_client_other_uid): - # Setup: original user creates org + invite for the second user's email - # then second user accepts. Implement via a separate authed_client for uid=alice. - pass diff --git a/apps/backend/tests/test_json_formatter_workspace_isolation.py b/apps/backend/tests/test_json_formatter_workspace_isolation.py index 63d3c7cd..f486d55f 100644 --- a/apps/backend/tests/test_json_formatter_workspace_isolation.py +++ b/apps/backend/tests/test_json_formatter_workspace_isolation.py @@ -11,10 +11,9 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( uid=uid, - org_id=org_id, workspace_id=ws_id, ws_role="admin", is_personal=True, @@ -24,15 +23,14 @@ def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: @pytest.mark.asyncio async def test_json_formatter_documents_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """Documents created by u1 must not appear in u2's list.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) # u1 creates a document await jf_svc.create_document(ctx_u1, JsonFormatterDocumentCreate(title="test", pane="left", content="{}")) @@ -49,17 +47,16 @@ async def test_json_formatter_documents_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_cannot_read_json_formatter_documents( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """u2 forging u1's workspace_id still cannot see u1's documents due to owner_uid.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await jf_svc.create_document(ctx_u1, JsonFormatterDocumentCreate(title="secret", pane="left", content="{}")) # u2 forges u1's workspace_id - forged_ctx = _ctx("u2", ws_u1, org_id) + forged_ctx = _ctx("u2", ws_u1) docs = await jf_svc.list_documents(forged_ctx) assert docs == [] # owner_uid filter prevents cross-user bleed diff --git a/apps/backend/tests/test_member_publickeys.py b/apps/backend/tests/test_member_publickeys.py deleted file mode 100644 index 8de7c81a..00000000 --- a/apps/backend/tests/test_member_publickeys.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_list_member_publickeys_for_admin(authed_client: AsyncClient, personal_ws_id: str): - res = await authed_client.get( - f"/api/v1/workspaces-api/workspaces/{personal_ws_id}/member-publickeys", - ) - assert res.status_code == 200 - members = res.json() - assert len(members) >= 1 - assert members[0]["uid"] == "test-uid" - # publicKey is None initially (keypair not generated) - assert members[0]["publicKey"] is None diff --git a/apps/backend/tests/test_members.py b/apps/backend/tests/test_members.py deleted file mode 100644 index d304bcfe..00000000 --- a/apps/backend/tests/test_members.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_list_org_members_after_create(authed_client: AsyncClient): - org_id = (await authed_client.post( - "/api/v1/workspaces-api/orgs", json={"name": "Acme"} - )).json()["id"] - res = await authed_client.get(f"/api/v1/workspaces-api/orgs/{org_id}/members") - assert res.status_code == 200 - members = res.json() - assert len(members) == 1 - assert members[0]["role"] == "owner" - - -@pytest.mark.asyncio -async def test_cannot_demote_sole_owner(authed_client: AsyncClient): - org_id = (await authed_client.post( - "/api/v1/workspaces-api/orgs", json={"name": "SoloOrg"} - )).json()["id"] - me_uid = "test-uid" # the authed_client fixture's uid - res = await authed_client.patch( - f"/api/v1/workspaces-api/orgs/{org_id}/members/{me_uid}", - json={"role": "member"}, - ) - assert res.status_code == 400 - assert "sole owner" in res.json()["detail"].lower() - - -@pytest.mark.asyncio -async def test_remove_org_member_cascades_to_workspaces(authed_client: AsyncClient): - # Multi-member tests need a second test uid — set up via fixtures. - # Verifies that removing a user from the org also drops their ws memberships. - # (Skip skeleton — implement once test fixtures support multi-user clients.) - pass diff --git a/apps/backend/tests/test_nosql_workspace_isolation.py b/apps/backend/tests/test_nosql_workspace_isolation.py index 0acd025d..ade00d4e 100644 --- a/apps/backend/tests/test_nosql_workspace_isolation.py +++ b/apps/backend/tests/test_nosql_workspace_isolation.py @@ -4,23 +4,22 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( - uid=uid, org_id=org_id, workspace_id=ws_id, ws_role="admin", + uid=uid, workspace_id=ws_id, ws_role="admin", is_personal=True, owner_uid=uid, ) @pytest.mark.asyncio async def test_nosql_connections_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await nosql_svc.create_connection(ctx_u1, ConnectionCreate(encryptedData="encrypted-u1", iv="iv-u1", name="conn-u1")) @@ -33,15 +32,14 @@ async def test_nosql_connections_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_nosql_connections_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await nosql_svc.create_connection(ctx_u1, ConnectionCreate(encryptedData="encrypted-u1", iv="iv-u1", name="conn-u1")) - forged_ctx = _ctx("u2", ws_u1, org_id) # u2 forges u1's workspace_id + forged_ctx = _ctx("u2", ws_u1) # u2 forges u1's workspace_id connections = await nosql_svc.list_connections(forged_ctx) assert connections == [] # owner_uid filter saves us diff --git a/apps/backend/tests/test_notes_workspace_isolation.py b/apps/backend/tests/test_notes_workspace_isolation.py index 563dc486..55d8d651 100644 --- a/apps/backend/tests/test_notes_workspace_isolation.py +++ b/apps/backend/tests/test_notes_workspace_isolation.py @@ -4,23 +4,22 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( - uid=uid, org_id=org_id, workspace_id=ws_id, ws_role="admin", + uid=uid, workspace_id=ws_id, ws_role="admin", is_personal=True, owner_uid=uid, ) @pytest.mark.asyncio async def test_notes_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await note_svc.create_note(ctx_u1, NoteCreate(title="u1 note")) @@ -33,16 +32,15 @@ async def test_notes_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_note_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await note_svc.create_note(ctx_u1, NoteCreate(title="u1 secret note")) # u2 forges u1's workspace_id but has different uid -> owner_uid filter blocks them - forged_ctx = _ctx("u2", ws_u1, org_id) + forged_ctx = _ctx("u2", ws_u1) notes = await note_svc.list_notes(ctx=forged_ctx) assert notes == [] diff --git a/apps/backend/tests/test_orgs_crud.py b/apps/backend/tests/test_orgs_crud.py deleted file mode 100644 index 090277f9..00000000 --- a/apps/backend/tests/test_orgs_crud.py +++ /dev/null @@ -1,48 +0,0 @@ -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_create_org_grants_owner_role(authed_client: AsyncClient): - res = await authed_client.post( - "/api/v1/workspaces-api/orgs", - json={"name": "Acme Inc"}, - ) - assert res.status_code == 201 - body = res.json() - assert body["name"] == "Acme Inc" - assert body["slug"] == "acme-inc" - assert body["kind"] == "user" - assert body["org_role"] == "owner" - - -@pytest.mark.asyncio -async def test_rename_org_owner_only(authed_client: AsyncClient): - create = await authed_client.post( - "/api/v1/workspaces-api/orgs", json={"name": "Beta"} - ) - org_id = create.json()["id"] - res = await authed_client.patch( - f"/api/v1/workspaces-api/orgs/{org_id}", - json={"name": "Beta Renamed"}, - ) - assert res.status_code == 200 - assert res.json()["name"] == "Beta Renamed" - - -@pytest.mark.asyncio -async def test_delete_org_soft_deletes(authed_client: AsyncClient): - create = await authed_client.post( - "/api/v1/workspaces-api/orgs", json={"name": "Doomed"} - ) - org_id = create.json()["id"] - res = await authed_client.delete(f"/api/v1/workspaces-api/orgs/{org_id}") - assert res.status_code == 204 - after = await authed_client.get("/api/v1/workspaces-api/orgs") - assert all(o["id"] != org_id for o in after.json()) - - -@pytest.mark.asyncio -async def test_cannot_delete_system_org(authed_client: AsyncClient, system_org_id: str): - res = await authed_client.delete(f"/api/v1/workspaces-api/orgs/{system_org_id}") - assert res.status_code == 403 diff --git a/apps/backend/tests/test_pending_wraps.py b/apps/backend/tests/test_pending_wraps.py deleted file mode 100644 index 02df07a2..00000000 --- a/apps/backend/tests/test_pending_wraps.py +++ /dev/null @@ -1,40 +0,0 @@ -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_pending_wraps_empty_initially(authed_client: AsyncClient, personal_ws_id: str): - res = await authed_client.get( - f"/api/v1/workspaces-api/workspaces/{personal_ws_id}/pending-wraps", - ) - assert res.status_code == 200 - # The sole member (test-uid) has wrappedDek=None initially, so they appear - # in the pending list. The personal workspace is bootstrapped with one - # membership doc but no wrappedDek set yet. - body = res.json() - assert isinstance(body, list) - # The bootstrapped member has no wrap yet, so they should appear. - uids = [item["uid"] for item in body] - assert "test-uid" in uids - - -@pytest.mark.asyncio -async def test_pending_wraps_disappears_after_wrap_posted(authed_client: AsyncClient, personal_ws_id: str): - # Post a wrap for the sole member (test-uid). - payload = { - "target_uid": "test-uid", - "wrapped": {"encrypted": "enc", "iv": "iv", "senderPublicKey": "pk"}, - } - post_res = await authed_client.post( - f"/api/v1/workspaces-api/workspaces/{personal_ws_id}/dek-wrap", json=payload, - ) - assert post_res.status_code == 204 - - # Now pending-wraps should not include test-uid. - res = await authed_client.get( - f"/api/v1/workspaces-api/workspaces/{personal_ws_id}/pending-wraps", - ) - assert res.status_code == 200 - body = res.json() - uids = [item["uid"] for item in body] - assert "test-uid" not in uids diff --git a/apps/backend/tests/test_rbac_dependency.py b/apps/backend/tests/test_rbac_dependency.py index 215f5e7b..7f87df18 100644 --- a/apps/backend/tests/test_rbac_dependency.py +++ b/apps/backend/tests/test_rbac_dependency.py @@ -6,7 +6,7 @@ def _ctx(role: str, is_personal: bool = False) -> WorkspaceContext: return WorkspaceContext( - uid="u1", org_id="o1", workspace_id="w1", + uid="u1", workspace_id="w1", ws_role=role, is_personal=is_personal, owner_uid="u1" if is_personal else None, ) diff --git a/apps/backend/tests/test_redis_commander_workspace_isolation.py b/apps/backend/tests/test_redis_commander_workspace_isolation.py index 8618188f..fb71e02a 100644 --- a/apps/backend/tests/test_redis_commander_workspace_isolation.py +++ b/apps/backend/tests/test_redis_commander_workspace_isolation.py @@ -8,10 +8,9 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( uid=uid, - org_id=org_id, workspace_id=ws_id, ws_role="admin", is_personal=True, @@ -21,15 +20,14 @@ def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: @pytest.mark.asyncio async def test_redis_connections_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """Connections created by u1 must not appear in u2's list.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await redis_svc.create_connection( ctx_u1, RedisConnectionCreate(encryptedData="encrypted-u1", iv="iv-u1", name="conn-u1") @@ -44,18 +42,17 @@ async def test_redis_connections_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_redis_connections_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """u2 forging u1's workspace_id still cannot see u1's connections due to owner_uid.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await redis_svc.create_connection( ctx_u1, RedisConnectionCreate(encryptedData="encrypted-u1", iv="iv-u1", name="conn-u1") ) - forged_ctx = _ctx("u2", ws_u1, org_id) # u2 forges u1's workspace_id + forged_ctx = _ctx("u2", ws_u1) # u2 forges u1's workspace_id connections = await redis_svc.list_connections(forged_ctx) assert connections == [] # owner_uid filter saves us diff --git a/apps/backend/tests/test_s3_drive_workspace_isolation.py b/apps/backend/tests/test_s3_drive_workspace_isolation.py index 5b7f6ec1..c7d4bb3a 100644 --- a/apps/backend/tests/test_s3_drive_workspace_isolation.py +++ b/apps/backend/tests/test_s3_drive_workspace_isolation.py @@ -8,10 +8,9 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( uid=uid, - org_id=org_id, workspace_id=ws_id, ws_role="admin", is_personal=True, @@ -21,15 +20,14 @@ def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: @pytest.mark.asyncio async def test_s3_drive_connections_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """Connections created by u1 must not appear in u2's list.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await s3_drive_svc.create_connection( ctx_u1, S3ConnectionCreate(encryptedData="encrypted-u1", iv="iv-u1", name="conn-u1", provider="aws") @@ -44,18 +42,17 @@ async def test_s3_drive_connections_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_s3_drive_connections_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """u2 forging u1's workspace_id still cannot see u1's connections due to owner_uid.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await s3_drive_svc.create_connection( ctx_u1, S3ConnectionCreate(encryptedData="encrypted-u1", iv="iv-u1", name="conn-u1", provider="aws") ) - forged_ctx = _ctx("u2", ws_u1, org_id) # u2 forges u1's workspace_id + forged_ctx = _ctx("u2", ws_u1) # u2 forges u1's workspace_id connections = await s3_drive_svc.list_connections(forged_ctx) assert connections == [] # owner_uid filter saves us diff --git a/apps/backend/tests/test_sql_client_workspace_isolation.py b/apps/backend/tests/test_sql_client_workspace_isolation.py index c680980c..f4055697 100644 --- a/apps/backend/tests/test_sql_client_workspace_isolation.py +++ b/apps/backend/tests/test_sql_client_workspace_isolation.py @@ -8,10 +8,9 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( uid=uid, - org_id=org_id, workspace_id=ws_id, ws_role="admin", is_personal=True, @@ -21,15 +20,14 @@ def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: @pytest.mark.asyncio async def test_sql_client_connections_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """Connections created by u1 must not appear in u2's list.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await sql_client_svc.create_connection( ctx_u1, SqlConnectionCreate(encryptedData="encrypted-u1", iv="iv-u1", name="conn-u1", type="postgresql") @@ -44,18 +42,17 @@ async def test_sql_client_connections_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_sql_client_connections_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """u2 forging u1's workspace_id still cannot see u1's connections due to owner_uid.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await sql_client_svc.create_connection( ctx_u1, SqlConnectionCreate(encryptedData="encrypted-u1", iv="iv-u1", name="conn-u1", type="postgresql") ) - forged_ctx = _ctx("u2", ws_u1, org_id) # u2 forges u1's workspace_id + forged_ctx = _ctx("u2", ws_u1) # u2 forges u1's workspace_id connections = await sql_client_svc.list_connections(forged_ctx) assert connections == [] # owner_uid filter saves us diff --git a/apps/backend/tests/test_sweeper.py b/apps/backend/tests/test_sweeper.py deleted file mode 100644 index 266c8ad8..00000000 --- a/apps/backend/tests/test_sweeper.py +++ /dev/null @@ -1,39 +0,0 @@ -import pytest -from app.api.routes.workspaces.sweeper import ( - SOFT_DELETE_RETENTION_MS, run_sweeper_once, -) -from app.api.routes.workspaces.repo import upsert_org, set_org_deleted, find_org -from app.api.routes.workspaces.invitations_repo import create_invitation, find_invitation_by_token -from app.utils.utils import create_timestamp - - -@pytest.mark.asyncio -async def test_sweeper_hard_deletes_old_orgs(clean_db): - org_id = await upsert_org("Old", "old", "user", "u1") - old_ts = create_timestamp() - SOFT_DELETE_RETENTION_MS - 1000 - await set_org_deleted(org_id, old_ts) - - fresh_id = await upsert_org("Fresh", "fresh", "user", "u1") - await set_org_deleted(fresh_id, create_timestamp()) - - stats = await run_sweeper_once() - assert stats["orgs_hard_deleted"] == 1 - assert await find_org(org_id) is None - assert await find_org(fresh_id) is not None # still in grace - - -@pytest.mark.asyncio -async def test_sweeper_expires_old_invitations(clean_db): - old_ts = create_timestamp() - 1000 - await create_invitation({ - "_id": "inv-old", "org_id": "o1", "workspace_id": None, - "invited_email": "x@y.com", "invited_uid": None, - "invited_role_org": "member", "invited_role_ws": None, - "token": "tok-old", "status": "pending", - "invited_by": "u1", - "created_at": old_ts - 10000, "expires_at": old_ts, - }) - stats = await run_sweeper_once() - assert stats["invitations_expired"] == 1 - doc = await find_invitation_by_token("tok-old") - assert doc["status"] == "expired" diff --git a/apps/backend/tests/test_tasks_workspace_isolation.py b/apps/backend/tests/test_tasks_workspace_isolation.py index 6992eb87..ea29c2f4 100644 --- a/apps/backend/tests/test_tasks_workspace_isolation.py +++ b/apps/backend/tests/test_tasks_workspace_isolation.py @@ -4,32 +4,31 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( - uid=uid, org_id=org_id, workspace_id=ws_id, ws_role="admin", + uid=uid, workspace_id=ws_id, ws_role="admin", is_personal=True, owner_uid=uid, ) -def _shared_ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _shared_ctx(uid: str, ws_id: str) -> WorkspaceContext: """A shared (non-personal) workspace context — no owner_uid lock, so any member with a writer role can mutate any task in the workspace.""" return WorkspaceContext( - uid=uid, org_id=org_id, workspace_id=ws_id, ws_role="admin", + uid=uid, workspace_id=ws_id, ws_role="admin", is_personal=False, owner_uid=None, ) @pytest.mark.asyncio async def test_tasks_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await task_svc.create_task(ctx_u1, TaskCreate(text="u1 task")) @@ -42,14 +41,13 @@ async def test_tasks_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_projects_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await task_svc.create_project(ctx_u1, ProjectCreate(name="u1 project", color="#ff0000")) @@ -62,31 +60,29 @@ async def test_projects_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_task_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await task_svc.create_task(ctx_u1, TaskCreate(text="u1 secret task")) # u2 forges u1's workspace_id but has different uid → owner_uid filter blocks them - forged_ctx = _ctx("u2", ws_u1, org_id) + forged_ctx = _ctx("u2", ws_u1) result = await task_svc.list_tasks(ctx=forged_ctx) assert result.items == [] @pytest.mark.asyncio async def test_shared_workspace_member_can_edit_anothers_task( - clean_db, system_org_id, + clean_db, ): """Collaboration unlock: in a shared workspace, member B can edit / move / delete a task member A created (was blocked by the old created_by filter).""" - org_id = system_org_id ws = "ws-shared-1" - ctx_a = _shared_ctx("u1", ws, org_id) - ctx_b = _shared_ctx("u2", ws, org_id) + ctx_a = _shared_ctx("u1", ws) + ctx_b = _shared_ctx("u2", ws) created = await task_svc.create_task(ctx_a, TaskCreate(text="shared task")) @@ -114,22 +110,20 @@ async def test_shared_workspace_member_can_edit_anothers_task( @pytest.mark.asyncio async def test_shared_workspace_isolated_from_other_shared_workspace( - clean_db, system_org_id, + clean_db, ): """A shared workspace's tasks never leak into a different workspace.""" - org_id = system_org_id - ctx_a = _shared_ctx("u1", "ws-shared-A", org_id) - ctx_other = _shared_ctx("u1", "ws-shared-B", org_id) + ctx_a = _shared_ctx("u1", "ws-shared-A") + ctx_other = _shared_ctx("u1", "ws-shared-B") await task_svc.create_task(ctx_a, TaskCreate(text="A-only")) assert (await task_svc.list_tasks(ctx=ctx_other)).items == [] @pytest.mark.asyncio -async def test_assignee_filter(clean_db, system_org_id): +async def test_assignee_filter(clean_db): """list_tasks assignee filter supports me / unassigned / specific uid.""" - org_id = system_org_id - ctx = _shared_ctx("u1", "ws-assignee", org_id) + ctx = _shared_ctx("u1", "ws-assignee") await task_svc.create_task(ctx, TaskCreate(text="assigned to u2", assigneeUid="u2")) await task_svc.create_task(ctx, TaskCreate(text="unassigned")) diff --git a/apps/backend/tests/test_url_shortener_workspace_isolation.py b/apps/backend/tests/test_url_shortener_workspace_isolation.py index 1ef937d3..cbcd18b0 100644 --- a/apps/backend/tests/test_url_shortener_workspace_isolation.py +++ b/apps/backend/tests/test_url_shortener_workspace_isolation.py @@ -12,10 +12,9 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( uid=uid, - org_id=org_id, workspace_id=ws_id, ws_role="admin", is_personal=True, @@ -25,15 +24,14 @@ def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: @pytest.mark.asyncio async def test_url_shortener_links_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """Links created by u1 must not appear in u2's list.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) # u1 creates a link await url_svc.create_link(ctx_u1, ShortLinkCreate(original_url="https://example.com")) @@ -50,31 +48,29 @@ async def test_url_shortener_links_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_cannot_read_url_shortener_links( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): """u2 forging u1's workspace_id still cannot see u1's links due to owner_uid.""" - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) await url_svc.create_link(ctx_u1, ShortLinkCreate(original_url="https://secret.com")) # u2 forges u1's workspace_id - forged_ctx = _ctx("u2", ws_u1, org_id) + forged_ctx = _ctx("u2", ws_u1) links = await url_svc.list_my_short_urls(ctx=forged_ctx) assert links == [] # owner_uid filter prevents cross-user bleed @pytest.mark.asyncio async def test_click_events_inherit_link_workspace_stamps( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - """Click events must inherit the link's org_id, workspace_id, owner_uid stamps.""" - org_id = system_org_id + """Click events must inherit the link's workspace_id, owner_uid stamps.""" ws_u1 = await personal_ws_for("u1") - ctx_u1 = _ctx("u1", ws_u1, org_id) + ctx_u1 = _ctx("u1", ws_u1) # u1 creates a link link = await url_svc.create_link(ctx_u1, ShortLinkCreate(original_url="https://example.com")) @@ -90,7 +86,6 @@ async def test_click_events_inherit_link_workspace_stamps( # Verify the link has the correct stamps link_doc = await db_manager.find_one(COLLECTION, {"_id": code}) assert link_doc is not None - assert link_doc["org_id"] == org_id assert link_doc["workspace_id"] == ws_u1 assert link_doc["owner_uid"] == "u1" @@ -98,6 +93,5 @@ async def test_click_events_inherit_link_workspace_stamps( click_events = await db_manager.find(CLICKS_COLLECTION, {"code": code}, limit=10) assert len(click_events) > 0 for event in click_events: - assert event.get("org_id") == org_id assert event.get("workspace_id") == ws_u1 assert event.get("owner_uid") == "u1" diff --git a/apps/backend/tests/test_workspace_crud_b.py b/apps/backend/tests/test_workspace_crud_b.py deleted file mode 100644 index e247f257..00000000 --- a/apps/backend/tests/test_workspace_crud_b.py +++ /dev/null @@ -1,64 +0,0 @@ -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_create_shared_workspace(authed_client: AsyncClient): - org_create = await authed_client.post( - "/api/v1/workspaces-api/orgs", json={"name": "Acme"} - ) - org_id = org_create.json()["id"] - res = await authed_client.post( - f"/api/v1/workspaces-api/orgs/{org_id}/workspaces", - json={"name": "Production"}, - ) - assert res.status_code == 201 - body = res.json() - assert body["name"] == "Production" - assert body["is_personal"] is False - assert body["kind"] == "shared" - assert body["ws_role"] == "admin" # cascade from org owner - - -@pytest.mark.asyncio -async def test_personal_workspace_cannot_be_deleted( - authed_client: AsyncClient, personal_ws_id: str, -): - res = await authed_client.delete(f"/api/v1/workspaces-api/workspaces/{personal_ws_id}") - assert res.status_code == 403 - - -@pytest.mark.asyncio -async def test_delete_shared_workspace(authed_client: AsyncClient): - org_create = await authed_client.post( - "/api/v1/workspaces-api/orgs", json={"name": "DoomedOrg"} - ) - org_id = org_create.json()["id"] - ws_create = await authed_client.post( - f"/api/v1/workspaces-api/orgs/{org_id}/workspaces", - json={"name": "Doomed"}, - ) - ws_id = ws_create.json()["id"] - res = await authed_client.delete(f"/api/v1/workspaces-api/workspaces/{ws_id}") - assert res.status_code == 204 - listing = await authed_client.get("/api/v1/workspaces-api/workspaces") - assert all(w["id"] != ws_id for w in listing.json()) - - -@pytest.mark.asyncio -async def test_rename_workspace(authed_client: AsyncClient): - org_create = await authed_client.post( - "/api/v1/workspaces-api/orgs", json={"name": "RenameOrg"} - ) - org_id = org_create.json()["id"] - ws_create = await authed_client.post( - f"/api/v1/workspaces-api/orgs/{org_id}/workspaces", - json={"name": "First"}, - ) - ws_id = ws_create.json()["id"] - res = await authed_client.patch( - f"/api/v1/workspaces-api/workspaces/{ws_id}", - json={"name": "Second"}, - ) - assert res.status_code == 200 - assert res.json()["name"] == "Second" diff --git a/apps/backend/tests/test_workspace_ctx.py b/apps/backend/tests/test_workspace_ctx.py index 55dc5332..951e744b 100644 --- a/apps/backend/tests/test_workspace_ctx.py +++ b/apps/backend/tests/test_workspace_ctx.py @@ -8,8 +8,6 @@ get_workspace_ctx, ) from app.api.routes.workspaces.repo import ( - upsert_org, - upsert_org_membership, upsert_personal_workspace, upsert_ws_membership, ) @@ -17,10 +15,8 @@ @pytest.mark.asyncio async def test_get_workspace_ctx_rejects_non_member(make_request, clean_db): - org_id = await upsert_org("Acme", "acme", "user", "owner-uid") - await upsert_org_membership(org_id, "owner-uid", "owner") - ws_id = await upsert_personal_workspace(org_id, "owner-uid") - await upsert_ws_membership(ws_id, org_id, "owner-uid", "admin") + ws_id = await upsert_personal_workspace("owner-uid") + await upsert_ws_membership(ws_id, "owner-uid", "admin") req = make_request(cookies={"active_workspace": ws_id}) with pytest.raises(HTTPException) as exc: @@ -30,10 +26,8 @@ async def test_get_workspace_ctx_rejects_non_member(make_request, clean_db): @pytest.mark.asyncio async def test_default_personal_ws_id_returns_personal_ws(clean_db): - org_id = await upsert_org("MyDevTools Cloud", "mydevtools-cloud", "system", None) - await upsert_org_membership(org_id, "u1", "member") - ws_id = await upsert_personal_workspace(org_id, "u1") - await upsert_ws_membership(ws_id, org_id, "u1", "admin") + ws_id = await upsert_personal_workspace("u1") + await upsert_ws_membership(ws_id, "u1", "admin") assert await default_personal_ws_id("u1") == ws_id @@ -41,7 +35,6 @@ async def test_default_personal_ws_id_returns_personal_ws(clean_db): def test_apply_workspace_filter_personal_adds_owner_uid(): ctx = WorkspaceContext( uid="u1", - org_id="o1", workspace_id="w1", ws_role="admin", is_personal=True, @@ -50,7 +43,6 @@ def test_apply_workspace_filter_personal_adds_owner_uid(): out = apply_workspace_filter(ctx, {"foo": "bar"}) assert out == { "foo": "bar", - "org_id": "o1", "workspace_id": "w1", "owner_uid": "u1", } @@ -59,7 +51,6 @@ def test_apply_workspace_filter_personal_adds_owner_uid(): def test_apply_legacy_or_filter_bounds_by_user_field(): ctx = WorkspaceContext( uid="u1", - org_id="o1", workspace_id="w1", ws_role="admin", is_personal=True, @@ -67,7 +58,7 @@ def test_apply_legacy_or_filter_bounds_by_user_field(): ) out = apply_legacy_or_filter(ctx, {"foo": "bar"}, user_field="created_by") assert out["$or"] == [ - {"org_id": "o1", "workspace_id": "w1", "owner_uid": "u1"}, + {"workspace_id": "w1", "owner_uid": "u1"}, {"workspace_id": {"$exists": False}, "created_by": "u1"}, ] assert out["foo"] == "bar" @@ -76,7 +67,6 @@ def test_apply_legacy_or_filter_bounds_by_user_field(): def test_apply_legacy_or_filter_preserves_caller_or(): ctx = WorkspaceContext( uid="u1", - org_id="o1", workspace_id="w1", ws_role="admin", is_personal=True, @@ -88,21 +78,15 @@ def test_apply_legacy_or_filter_preserves_caller_or(): assert "$and" in out assert out["$and"][0] == base assert out["$and"][1]["$or"] == [ - {"org_id": "o1", "workspace_id": "w1", "owner_uid": "u1"}, + {"workspace_id": "w1", "owner_uid": "u1"}, {"workspace_id": {"$exists": False}, "created_by": "u1"}, ] @pytest.mark.asyncio -async def test_org_owner_gets_implicit_workspace_admin(clean_db, make_request): - org_id = await upsert_org("Acme", "acme", "user", "owner-uid") - await upsert_org_membership(org_id, "owner-uid", "owner") - # Shared workspace (Personal for now, since shared CRUD lands in B5). - ws_id = await upsert_personal_workspace(org_id, "owner-uid") - # ONLY org membership exists. NO workspace membership for the owner. - # ws membership exists for someone else. - await upsert_org_membership(org_id, "member-uid", "member") - await upsert_ws_membership(ws_id, org_id, "member-uid", "admin") +async def test_admin_member_gets_workspace_ctx(clean_db, make_request): + ws_id = await upsert_personal_workspace("owner-uid") + await upsert_ws_membership(ws_id, "owner-uid", "admin") req = make_request(cookies={"active_workspace": ws_id}) ctx = await get_workspace_ctx(req, uid="owner-uid") @@ -111,12 +95,9 @@ async def test_org_owner_gets_implicit_workspace_admin(clean_db, make_request): @pytest.mark.asyncio -async def test_org_member_without_ws_membership_is_rejected(clean_db, make_request): - org_id = await upsert_org("Acme2", "acme2", "user", "owner-uid") - await upsert_org_membership(org_id, "owner-uid", "owner") - await upsert_org_membership(org_id, "plain-member", "member") - ws_id = await upsert_personal_workspace(org_id, "owner-uid") - await upsert_ws_membership(ws_id, org_id, "owner-uid", "admin") +async def test_non_member_without_ws_membership_is_rejected(clean_db, make_request): + ws_id = await upsert_personal_workspace("owner-uid") + await upsert_ws_membership(ws_id, "owner-uid", "admin") req = make_request(cookies={"active_workspace": ws_id}) with pytest.raises(HTTPException) as exc: diff --git a/apps/backend/tests/test_workspace_invariant.py b/apps/backend/tests/test_workspace_invariant.py index fce4a555..37d1a12b 100644 --- a/apps/backend/tests/test_workspace_invariant.py +++ b/apps/backend/tests/test_workspace_invariant.py @@ -4,23 +4,22 @@ from app.api.routes.workspaces.middleware import WorkspaceContext -def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: +def _ctx(uid: str, ws_id: str) -> WorkspaceContext: return WorkspaceContext( - uid=uid, org_id=org_id, workspace_id=ws_id, ws_role="admin", + uid=uid, workspace_id=ws_id, ws_role="admin", is_personal=True, owner_uid=uid, ) @pytest.mark.asyncio async def test_password_entries_are_isolated_across_personal_workspaces( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") ws_u2 = await personal_ws_for("u2") - ctx_u1 = _ctx("u1", ws_u1, org_id) - ctx_u2 = _ctx("u2", ws_u2, org_id) + ctx_u1 = _ctx("u1", ws_u1) + ctx_u2 = _ctx("u2", ws_u2) await pw_svc.create_entry( ctx_u1, PasswordEntryCreate(encryptedData="enc-u1", iv="iv-u1"), @@ -35,17 +34,16 @@ async def test_password_entries_are_isolated_across_personal_workspaces( @pytest.mark.asyncio async def test_forged_workspace_id_cannot_cross_user_data( - clean_db, system_org_id, personal_ws_for, + clean_db, personal_ws_for, ): - org_id = system_org_id ws_u1 = await personal_ws_for("u1") await personal_ws_for("u2") await pw_svc.create_entry( - _ctx("u1", ws_u1, org_id), + _ctx("u1", ws_u1), PasswordEntryCreate(encryptedData="enc-u1", iv="iv-u1"), ) - forged_ctx = _ctx("u2", ws_u1, org_id) # u2 forges u1's workspace_id + forged_ctx = _ctx("u2", ws_u1) # u2 forges u1's workspace_id entries = await pw_svc.list_entries(ctx=forged_ctx) assert entries == [] # owner_uid filter saves us diff --git a/apps/backend/tests/test_workspace_routes.py b/apps/backend/tests/test_workspace_routes.py index c70f7baf..1275534a 100644 --- a/apps/backend/tests/test_workspace_routes.py +++ b/apps/backend/tests/test_workspace_routes.py @@ -2,16 +2,6 @@ from httpx import AsyncClient -@pytest.mark.asyncio -async def test_list_orgs_returns_mydevtools_cloud(authed_client: AsyncClient): - res = await authed_client.get("/api/v1/workspaces-api/orgs") - assert res.status_code == 200 - orgs = res.json() - assert len(orgs) == 1 - assert orgs[0]["slug"] == "mydevtools-cloud" - assert orgs[0]["org_role"] == "member" - - @pytest.mark.asyncio async def test_list_workspaces_returns_personal(authed_client: AsyncClient): res = await authed_client.get("/api/v1/workspaces-api/workspaces") diff --git a/apps/backend/tests/test_workspace_setup.py b/apps/backend/tests/test_workspace_setup.py deleted file mode 100644 index 837ac1b0..00000000 --- a/apps/backend/tests/test_workspace_setup.py +++ /dev/null @@ -1,33 +0,0 @@ -import pytest -from app.api.routes.workspaces.repo import ( - find_org_membership, - find_ws_membership, - find_user_workspaces, -) -from app.api.routes.workspaces.seed import ensure_system_org -from app.api.routes.workspaces.services import ensure_user_workspace_setup - - -@pytest.mark.asyncio -async def test_first_call_creates_membership_and_personal_workspace(clean_db): - org_id = await ensure_system_org() - ws_id = await ensure_user_workspace_setup("u1") - - assert await find_org_membership(org_id, "u1") is not None - assert await find_ws_membership(ws_id, "u1") is not None - - workspaces = await find_user_workspaces("u1") - assert len(workspaces) == 1 - assert workspaces[0]["is_personal"] is True - assert workspaces[0]["owner_uid"] == "u1" - assert workspaces[0]["name"] == "Personal" - - -@pytest.mark.asyncio -async def test_second_call_is_no_op(clean_db, count_inserts): - await ensure_system_org() - await ensure_user_workspace_setup("u1") - before = count_inserts() - await ensure_user_workspace_setup("u1") - after = count_inserts() - assert after == before diff --git a/apps/backend/tests/test_workspaces_seed.py b/apps/backend/tests/test_workspaces_seed.py deleted file mode 100644 index 76b4c7a3..00000000 --- a/apps/backend/tests/test_workspaces_seed.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest -from app.api.routes.workspaces.seed import ( - SYSTEM_ORG_NAME, - SYSTEM_ORG_SLUG, - ensure_system_org, - get_system_org_id, -) -from app.database import db_manager -from app.utils.collection_name import ORGANIZATIONS - - -@pytest.mark.asyncio -async def test_ensure_system_org_creates_singleton(clean_db): - org_id_first = await ensure_system_org() - org_id_second = await ensure_system_org() - - assert org_id_first == org_id_second - - docs = await db_manager.find( - ORGANIZATIONS, {"slug": SYSTEM_ORG_SLUG}, limit=10 - ) - assert len(docs) == 1 - assert docs[0]["name"] == SYSTEM_ORG_NAME - assert docs[0]["kind"] == "system" - assert docs[0].get("owner_uid") is None - - -@pytest.mark.asyncio -async def test_get_system_org_id_returns_none_when_not_seeded(clean_db): - assert await get_system_org_id() is None - - -@pytest.mark.asyncio -async def test_get_system_org_id_returns_id_after_seed(clean_db): - seeded = await ensure_system_org() - fetched = await get_system_org_id() - assert fetched == seeded diff --git a/apps/desktop/src-tauri/src/router/workspaces.rs b/apps/desktop/src-tauri/src/router/workspaces.rs index b0a8e078..a719ba63 100644 --- a/apps/desktop/src-tauri/src/router/workspaces.rs +++ b/apps/desktop/src-tauri/src/router/workspaces.rs @@ -1,7 +1,7 @@ //! Local mirror of FastAPI `/api/v1/workspaces-api/*`. //! -//! Offline there is exactly one org and one personal workspace, synthesized to -//! match the shapes in apps/web/src/lib/workspace-api.ts. +//! Offline there is exactly one personal workspace, synthesized to match the +//! shapes in apps/web/src/lib/workspace-api.ts. use serde_json::json; @@ -9,23 +9,11 @@ use crate::error::Result; use crate::router::ApiResponse; use crate::state::AppState; -pub const LOCAL_ORG_ID: &str = "local-org"; pub const LOCAL_WORKSPACE_ID: &str = "local-personal"; -fn local_org() -> serde_json::Value { - json!({ - "id": LOCAL_ORG_ID, - "name": "This Mac", - "slug": "local", - "kind": "user", - "org_role": "owner", - }) -} - fn local_workspace() -> serde_json::Value { json!({ "id": LOCAL_WORKSPACE_ID, - "org_id": LOCAL_ORG_ID, "name": "Personal", "slug": "personal-local", "is_personal": true, @@ -37,7 +25,6 @@ fn local_workspace() -> serde_json::Value { pub fn handle(state: &AppState, method: &str, path: &str, body: Option<&str>) -> Result { match (method, path) { - ("GET", "/api/v1/workspaces-api/orgs") => ApiResponse::ok(&json!([local_org()])), ("GET", "/api/v1/workspaces-api/workspaces") => { ApiResponse::ok(&json!([local_workspace()])) } diff --git a/apps/web/messages/af.json b/apps/web/messages/af.json index 31e18388..404053fe 100644 --- a/apps/web/messages/af.json +++ b/apps/web/messages/af.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod-sakrekenaar", + "codeScreenshot": "Kode-skermkiekie", + "cssGenerators": "CSS-genereerders", + "exifViewer": "EXIF-kyker", + "faviconGenerator": "Favicon-genereerder", + "jsonDiff": "JSON-verskil", + "jsonpathPlayground": "JSONPath-speelperk", + "keycodeInspector": "Sleutelkode-inspekteur", + "markdownTableGenerator": "Markdown-tabel", + "timezoneConverter": "Tydsone-omskakelaar", + "tokenCounter": "Token-teller", + "webhookTester": "Webhook-toetser", + "websocketTester": "WebSocket-toetser", + "whoisLookup": "Whois-opsoek" }, "Help": { "title": "Hulp en dokumentasie", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt-genereerder en -toetser", + "description": "Hash wagwoorde met bcrypt en verifieer bestaande hashe." + }, + "chmodCalculator": { + "title": "Chmod-sakrekenaar", + "description": "Skakel Unix-regte om tussen oktaal, simbolies en merkblokkies." + }, + "codeScreenshot": { + "title": "Kode-skermkiekie", + "description": "Verander bronkode in pragtige, deelbare beelde." + }, + "cssGenerators": { + "title": "CSS-genereerders", + "description": "box-shadow-, border-radius- en vloeibare clamp()-genereerders." + }, + "exifViewer": { + "title": "EXIF-kyker en -verwyderaar", + "description": "Inspekteer en verwyder foto-EXIF-metadata plaaslik." + }, + "faviconGenerator": { + "title": "Favicon-genereerder", + "description": "Genereer favicons, app-ikone en manifest-snippers." + }, + "jsonDiff": { + "title": "JSON-verskil", + "description": "Semantiese, struktuurbewuste verskil van twee JSON-dokumente." + }, + "jsonpathPlayground": { + "title": "JSONPath-speelperk", + "description": "Bevraag JSON met JSONPath of JMESPath en sien lewendige resultate." + }, + "keycodeInspector": { + "title": "Sleutelkode-inspekteur", + "description": "Druk 'n sleutel om sy JavaScript-gebeurteniskodes en wysigers te sien." + }, + "markdownTableGenerator": { + "title": "Markdown-tabelgenereerder", + "description": "Bou Markdown- en HTML-tabelle in 'n sigblad-agtige roosterredigeerder." + }, + "timezoneConverter": { + "title": "Tydsone-omskakelaar", + "description": "Skakel 'n tyd oor baie sones om met dagverskuiwing- en werksure-leidrade." + }, + "tokenCounter": { + "title": "LLM-token-teller", + "description": "Tel LLM-tokens, visualiseer brokke en skat API-koste." + }, + "webhookTester": { + "title": "Webhook-toetser", + "description": "Vang en inspekteer inkomende webhook-versoeke intyds." + }, + "websocketTester": { + "title": "WebSocket-toetser", + "description": "Koppel aan WebSocket-eindpunte en ruil boodskappe intyds uit." + }, + "whoisLookup": { + "title": "Whois-opsoek", + "description": "Soek domein- en IP-registrasiebesonderhede op via RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt-genereerder en -toetser", + "subtitle": "Hash wagwoorde met bcrypt en verifieer hashe — alles bly in jou blaaier", + "tabs": { + "hash": "Hash", + "verify": "Verifieer" + }, + "showPassword": "Wys wagwoord", + "hidePassword": "Versteek wagwoord", + "copy": "Kopieer", + "copied": "Gekopieer", + "hash": { + "passwordLabel": "Wagwoord", + "passwordPlaceholder": "Voer 'n wagwoord in om te hash…", + "costLabel": "Kostefaktor", + "costHint": "Hoër koste verdubbel die werk per stap. 10-12 is 'n algemene keuse vir produksie.", + "slowWarning": "Kostefaktore van 13 en hoër kan 'n paar sekondes neem om te bereken.", + "generate": "Genereer hash", + "generating": "Besig om te hash…", + "resultLabel": "Bcrypt-hash", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Hash-anatomie", + "algorithm": "algoritme", + "cost": "koste", + "salt": "salt (22 karakters)", + "hash": "hash (31 karakters)" + } + }, + "verify": { + "passwordLabel": "Wagwoord", + "passwordPlaceholder": "Voer die wagwoord in om te kontroleer…", + "hashLabel": "Bcrypt-hash", + "hashPlaceholder": "$2b$10$…", + "idle": "Voer 'n wagwoord en 'n bcrypt-hash in om hulle te vergelyk.", + "invalidHash": "Dit lyk nie na 'n geldige bcrypt-hash nie.", + "checking": "Besig om te vergelyk…", + "match": "Pas — die wagwoord lewer hierdie hash.", + "noMatch": "Pas nie — die wagwoord lewer nie hierdie hash nie." + } + }, + "ChmodCalculator": { + "title": "Chmod-sakrekenaar", + "subtitle": "Skakel Unix-lêerregte om tussen merkblokkies, oktaal en simboliese notasie", + "grid": { + "title": "Regte", + "read": "Lees", + "write": "Skryf", + "execute": "Voer uit", + "owner": "Eienaar", + "group": "Groep", + "others": "Ander" + }, + "special": { + "title": "Spesiale bisse", + "setuid": "setuid", + "setgid": "setgid", + "sticky": "sticky" + }, + "octal": { + "label": "Oktaal", + "placeholder": "755 of 4755", + "invalid": "Voer 3 of 4 oktale syfers in (0-7)." + }, + "symbolic": { + "label": "Simbolies", + "placeholder": "rwxr-xr-x", + "invalid": "Voer 9 karakters in soos rwxr-xr-x (s/S, t/T toegelaat)." + }, + "output": { + "title": "Opdrag", + "copy": "Kopieer", + "copied": "Gekopieer" + }, + "explain": { + "line": "{cls} kan {perms}.", + "none": "{cls} het geen regte nie.", + "setuid": "setuid: die lêer loop met sy eienaar se voorregte.", + "setgid": "setgid: die lêer loop met sy groep se voorregte (nuwe lêers in 'n gids erf sy groep).", + "sticky": "sticky: net 'n lêer se eienaar kan dit binne hierdie gids uitvee of hernoem." + }, + "presets": { + "title": "Algemene voorafinstellings", + "octal": "Oktaal", + "permissions": "Regte", + "description": "Tipiese gebruik", + "p644": "Gewone lêers: eienaar skryf, almal lees.", + "p755": "Uitvoerbares en gidse: almal kan lees en ingaan.", + "p600": "Privaat lêers: net die eienaar lees en skryf.", + "p700": "Privaat uitvoerbares en gidse: net eienaar.", + "p664": "Groep-skryfbare lêers.", + "p775": "Groep-skryfbare gidse en uitvoerbares.", + "p400": "Leesalleen vir die eienaar (bv. SSH-privaatsleutels)." + } + }, + "CodeScreenshot": { + "title": "Kode-skermkiekie", + "subtitle": "Verander kode in pragtige, deelbare beelde", + "languageLabel": "Taal", + "autoDetect": "Outo-bespeur", + "backgroundLabel": "Agtergrond", + "paddingLabel": "Opvulling", + "filenameLabel": "Lêernaam", + "filenamePlaceholder": "example.ts", + "windowLabel": "Vensterkolletjies", + "themeLabel": "Venstertema", + "themeDark": "Donker", + "themeLight": "Lig", + "scaleLabel": "Uitvoerskaal", + "copyImage": "Kopieer beeld", + "copied": "Gekopieer", + "exportPng": "Voer PNG uit", + "inputLabel": "Kode", + "detected": "Bespeur: {language}", + "clear": "Vee uit", + "placeholder": "Plak jou kode hier…", + "previewLabel": "Voorskou", + "emptyPreview": "Jou kodevoorskou sal hier verskyn", + "exportFailed": "Kon nie beeld uitvoer nie", + "copyUnsupported": "Die kopiëring van beelde word nie in hierdie blaaier ondersteun nie", + "copyFailed": "Kon nie beeld kopieer nie", + "backgrounds": { + "ocean": "Oseaan", + "sunset": "Sonsondergang", + "candy": "Lekkers", + "forest": "Woud", + "midnight": "Middernag", + "gold": "Goud", + "aurora": "Aurora", + "slate": "Leiklip", + "transparent": "Deursigtig" + } + }, + "CssGenerators": { + "title": "CSS-genereerders", + "subtitle": "box-shadow, border-radius en vloeibare clamp() — kopieer die CSS.", + "preview": "Voorskou", + "output": "CSS", + "copy": "Kopieer", + "copied": "Gekopieer", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Vloeibare clamp()" + }, + "boxShadow": { + "presets": "Voorafinstellings", + "layer": "Laag", + "addLayer": "Voeg laag by", + "removeLayer": "Verwyder laag", + "x": "X", + "y": "Y", + "blur": "Vervaag", + "spread": "Verspreiding", + "color": "Kleur", + "alpha": "Alfa", + "inset": "Inset", + "presetNames": { + "subtleCard": "Subtiele kaart", + "elevated": "Verhewe", + "hardOffset": "Harde verskuiwing", + "neonGlow": "Neon-gloed", + "inner": "Binne", + "layeredSmooth": "Gelaagd glad" + } + }, + "borderRadius": { + "modeLabel": "Modus", + "modes": { + "simple": "Per hoek", + "blob": "Organiese klont" + }, + "unit": "Eenheid", + "linkCorners": "Koppel hoeke", + "randomize": "Verewekansig", + "horizontal": "Horisontale radiusse", + "vertical": "Vertikale radiusse", + "corners": { + "tl": "Bo links", + "tr": "Bo regs", + "br": "Onder regs", + "bl": "Onder links" + } + }, + "clamp": { + "minFont": "Min lettertipe (px)", + "maxFont": "Maks lettertipe (px)", + "minViewport": "Min viewport (px)", + "maxViewport": "Maks viewport (px)", + "rootFont": "Wortel-lettertipe (px)", + "viewportWidth": "Gesimuleerde viewport", + "computedSize": "Berekende grootte", + "slope": "Helling", + "intercept": "Afsnit", + "previewText": "Voorbeeldteks vir voorskou", + "invalid": "Voer geldige, verskillende viewport-grense in." + } + }, + "ExifViewer": { + "title": "EXIF-kyker en -verwyderaar", + "subtitle": "Inspekteer foto-metadata en verwyder dit in jou blaaier", + "dropTitle": "Los 'n beeld hier", + "dropHint": "of klik om te blaai — jou beeld verlaat nooit jou toestel nie", + "browse": "Kies beeld", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Besig om metadata te lees…", + "parseFailed": "Kon nie hierdie beeld lees nie", + "clear": "Verwyder beeld", + "gpsWarning": "Bevat GPS-ligging", + "openMap": "Bekyk op OpenStreetMap", + "noMetadata": "Geen ingebedde metadata is in hierdie beeld gevind nie.", + "facts": { + "name": "Naam", + "type": "Tipe", + "dimensions": "Afmetings", + "size": "Grootte" + }, + "groups": { + "camera": "Kamera", + "exposure": "Blootstelling", + "timestamps": "Tydstempels", + "gps": "GPS-ligging", + "software": "Sagteware en Ander" + }, + "gpsCard": { + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "altitude": "Hoogte" + }, + "raw": { + "title": "Alle metadata", + "copyJson": "Kopieer JSON", + "copied": "Gekopieer" + }, + "remove": { + "title": "Verwyder metadata", + "jpegDescription": "Verwyder EXIF, XMP, IPTC en kommentaar sonder verlies en sonder om die beeld te herkomprimeer.", + "reencodeDescription": "Herkodeer die beeld om ingebedde metadata te verwyder. Dit herkomprimeer die pixels.", + "keepIcc": "Behou kleurprofiel (ICC)", + "stripButton": "Verwyder metadata", + "reencodeButton": "Herkodeer en verwyder", + "unsupported": "Metadata-verwydering word nie vir hierdie lêertipe ondersteun nie.", + "failed": "Kon nie metadata verwyder nie", + "before": "Voor", + "after": "Na", + "download": "Laai skoongemaakte beeld af" + } + }, + "FaviconGenerator": { + "title": "Favicon-genereerder", + "subtitle": "Skep favicons en app-ikone uit een beeld", + "dropTitle": "Los 'n beeld hier", + "dropHint": "PNG, JPEG of SVG — klik om te blaai", + "browse": "Kies beeld", + "clear": "Verwyder beeld", + "backgroundLabel": "Agtergrond", + "transparent": "Deursigtig", + "solidColor": "Soliede kleur", + "cornerLabel": "Hoekvorm", + "cornerNone": "Vierkant", + "cornerRounded": "Afgerond", + "cornerCircle": "Sirkel", + "maskPaddingLabel": "Maskeerbare opvulling", + "maskable": "maskable", + "previewLabel": "Voorskou", + "copy": "Kopieer", + "headSnippet": "HTML head-snipper", + "downloadIco": "Laai favicon.ico af", + "downloadZip": "Laai alles af (.zip)" + }, + "JsonDiff": { + "title": "JSON-verskil", + "subtitle": "Semantiese, struktuurbewuste vergelyking van twee JSON-dokumente — sleutelvolgorde en formatering word geïgnoreer", + "options": { + "ignoreArrayOrder": "Ignoreer skikkingsvolgorde" + }, + "loadBothSamples": "Laai voorbeelde", + "sample": "Voorbeeld", + "format": "Formaat", + "clear": "Vee uit", + "formatError": "Kan nie formateer nie: invoer is nie geldige JSON nie", + "placeholder": "Plak of tik JSON hier…", + "panels": { + "left": "Oorspronklik (links)", + "right": "Gewysig (regs)", + "results": "Verskille" + }, + "summary": { + "added": "{count} bygevoeg", + "removed": "{count} verwyder", + "changed": "{count} verander" + }, + "types": { + "added": "Bygevoeg", + "removed": "Verwyder", + "changed": "Verander" + }, + "copyChanges": "Kopieer as JSON", + "copied": "Gekopieer", + "empty": "Plak JSON in albei panele om hul verskille te sien.", + "identical": "Die twee dokumente is semanties identies." + }, + "JsonpathPlayground": { + "title": "JSONPath-speelperk", + "subtitle": "Bevraag JSON-dokumente met JSONPath of JMESPath en sien lewendige resultate", + "engineLabel": "Enjin", + "queryLabel": "Navraag", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Kits-verwysing", + "cheats": { + "jpAllTitles": "Alle boektitels", + "jpAllAuthors": "Elke outeur, enige plek in die dokument", + "jpFirstBook": "Eerste boek", + "jpLastBook": "Laaste boek", + "jpSlice": "Eerste twee boeke (snit)", + "jpFilterPrice": "Boeke goedkoper as 40", + "jpFilterInStock": "Titels van boeke in voorraad", + "jpRecursive": "Alle waardes, rekursief", + "jmAllTitles": "Alle boektitels", + "jmFirstAuthor": "Outeur van die eerste boek", + "jmLastBook": "Laaste boek", + "jmSlice": "Eerste twee boeke (snit)", + "jmFilterPrice": "Titels van boeke goedkoper as 40", + "jmCount": "Tel boeke in voorraad", + "jmProjection": "Hervorm elke boek na 'n nuwe voorwerp", + "jmSort": "Titels gesorteer volgens prys" + }, + "panels": { + "input": "JSON-invoer", + "results": "Resultate" + }, + "sample": "Voorbeeld", + "format": "Formaat", + "clear": "Vee uit", + "formatError": "Kan nie formateer nie: invoer is nie geldige JSON nie", + "placeholder": "Plak of tik JSON hier…", + "outputPlaceholder": "Passings sal hier verskyn…", + "matchCount": "{count, plural, one {# passing} other {# passings}}", + "copy": "Kopieer", + "copied": "Gekopieer" + }, + "KeycodeInspector": { + "title": "Sleutelkode-inspekteur", + "subtitle": "Druk enige sleutel om sy JavaScript-sleutelbordgebeurtenis te inspekteer", + "capture": { + "prompt": "Druk enige sleutel…", + "ready": "Luister — druk enige sleutel op jou sleutelbord", + "clickToFocus": "Klik hier om te fokus, druk dan enige sleutel" + }, + "hero": { + "spaceKey": "(Spasie)", + "keyLabel": "event.key", + "repeat": "herhaal" + }, + "copyJson": "Kopieer JSON", + "copiedJson": "Gebeurtenis-JSON na knipbord gekopieer", + "deprecated": "verouderd", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standaard", + "left": "Links", + "right": "Regs", + "numpad": "Syferblok", + "unknown": "Onbekend" + }, + "history": { + "title": "Geskiedenis", + "clear": "Vee geskiedenis uit", + "empty": "Sleuteldrukke sal hier verskyn." + } + }, + "MarkdownTableGenerator": { + "title": "Markdown-tabelgenereerder", + "subtitle": "Redigeer 'n rooster, plak uit 'n sigblad en kopieer 'n netjies uitgelynde Markdown- of HTML-tabel", + "addRow": "Voeg ry by", + "addColumn": "Voeg kolom by", + "importLabel": "Voer in", + "importPlaceholder": "Plak TSV (uit Excel / Google Sheets) of CSV hier…", + "importButton": "Voer in rooster in", + "pasteClipboard": "Plak uit knipbord", + "importEmpty": "Niks om in te voer nie — plak eers 'n paar rye", + "clipboardError": "Kon nie uit die knipbord lees nie", + "clear": "Vee uit", + "panels": { + "editor": "Tabelredigeerder", + "output": "Uitvoer" + }, + "align": { + "left": "Lyn links", + "center": "Lyn middel", + "right": "Lyn regs" + }, + "headerPlaceholder": "Opskrif", + "cellPlaceholder": "Sel", + "removeRow": "Verwyder ry", + "removeColumn": "Verwyder kolom", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Kopieer", + "copied": "Gekopieer" + }, + "TimezoneConverter": { + "title": "Tydsone-omskakelaar", + "subtitle": "Skakel een oomblik oor die wêreld om vir vergaderingbeplanning.", + "dateTimeLabel": "Datum en tyd", + "now": "Nou", + "sourceZoneLabel": "Brontydsone", + "selectZone": "Kies sone", + "searchZone": "Soek tydsones...", + "noZone": "Geen tydsone gevind nie.", + "scrubLabel": "Verskuif tyd", + "reset": "Herstel", + "targetsLabel": "Teikensones", + "addZone": "Voeg sone by", + "remove": "Verwyder", + "localBadge": "Plaaslik", + "invalidDate": "Voer 'n geldige datum en tyd in.", + "bands": { + "work": "Werksure (09:00–18:00)", + "edge": "Randure (vroeg / aand)", + "night": "Nag" + } + }, + "TokenCounter": { + "title": "LLM-token-teller", + "subtitle": "Tel tokens, visualiseer brokke en skat API-koste.", + "inputLabel": "Teks", + "sample": "Voorbeeld", + "clear": "Vee uit", + "placeholder": "Plak teks, kode of JSON om tokens te tel...", + "encoding": "Enkodering", + "loading": "Besig om tokeniseerder te laai...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-series)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Karakters", + "words": "Woorde", + "ratio": "Tokens/woord", + "ratioSub": "ongeveer" + }, + "claudeRow": "Claude (ongeveer)", + "estimate": "skatting", + "viz": { + "title": "Token-visualisering", + "toggle": "Wys", + "hint": "Wissel om elke token as 'n brok te kleur.", + "empty": "Voer teks in om tokens te visualiseer.", + "truncated": "Wys die eerste {limit} van {total} tokens." + }, + "cost": { + "title": "Kosteberekenaar", + "disclaimer": "redigeerbare verstekwaardes — verifieer huidige pryse", + "model": "Model", + "expectedOutput": "Verwagte uitvoer-tokens", + "customInput": "Invoer $/MTok", + "customOutput": "Uitvoer $/MTok", + "inputCost": "Invoerkoste", + "outputCost": "Uitvoerkoste", + "total": "Totaal", + "per1000": "Per 1000 oproepe" + } + }, + "WebhookTester": { + "title": "Webhook-toetser", + "subtitle": "Skep 'n tydelike URL en inspekteer inkomende webhook-versoeke lewendig.", + "intro": { + "title": "Skep 'n webhook-URL om versoeke te begin vang", + "hint": "Jy sal 'n onraaibare URL kry wat elke inkomende versoek opneem. Dit verval na 24 uur." + }, + "createBtn": "Skep webhook-URL", + "creating": "Besig om te skep…", + "urlLabel": "Jou webhook-URL", + "expiresIn": "Verval oor {countdown}", + "copyUrlBtn": "Kopieer URL", + "copied": "Gekopieer", + "clearBtn": "Vee uit", + "newBinBtn": "Nuwe URL", + "requestsTitle": "Versoeke", + "selectPrompt": "Kies 'n versoek om sy besonderhede te inspekteer.", + "expiredNotice": "Hierdie webhook-URL het verval. Skep 'n nuwe een om aan te hou toets.", + "base64Badge": "base64", + "truncatedBadge": "afgekap", + "empty": { + "title": "Wag vir versoeke…", + "hint": "Stuur 'n versoek na jou URL en dit sal onmiddellik hier verskyn. Probeer dit:" + }, + "detail": { + "receivedAt": "Ontvang", + "source": "Bron-IP", + "contentType": "Content-Type", + "query": "Navraagparameters", + "headers": "Kopstukke", + "body": "Liggaam", + "copyBody": "Kopieer liggaam", + "emptyBody": "Geen liggaam" + }, + "errors": { + "createFailed": "Kon nie webhook-URL skep nie. Probeer asseblief weer.", + "clearFailed": "Kon nie versoeke uitvee nie. Probeer asseblief weer." + } + }, + "WebsocketTester": { + "title": "WebSocket-toetser", + "subtitle": "Koppel aan 'n WebSocket-eindpunt en stuur, ontvang en inspekteer boodskappe lewendig", + "urlLabel": "Eindpunt-URL", + "protocolsLabel": "Subprotokolle", + "protocolsPlaceholder": "opsioneel, met kommas geskei", + "connect": "Koppel", + "disconnect": "Ontkoppel", + "status": { + "disconnected": "Ontkoppel", + "connecting": "Besig om te koppel", + "connected": "Gekoppel", + "error": "Fout" + }, + "messages": { + "connecting": "Besig om aan {url} te koppel…", + "connected": "Verbinding gevestig", + "connectedWithProtocol": "Verbinding gevestig (protokol: {protocol})", + "invalidUrl": "Ongeldige URL — moet met ws:// of wss:// begin", + "connectFailed": "Kon nie verbinding oopmaak nie", + "socketError": "'n Verbindingsfout het voorgekom", + "notConnected": "Nie gekoppel nie — koppel eers", + "binaryMessage": "'n Binêre boodskap ontvang" + }, + "log": { + "title": "Boodskaplog", + "empty": "Nog geen boodskappe nie. Koppel en begin stuur.", + "copyAll": "Kopieer alles", + "download": "Laai af as .txt", + "clear": "Vee log uit", + "prettyPrint": "Formateer JSON", + "showRaw": "Wys rou teks" + }, + "composer": { + "placeholder": "Tik 'n boodskap om te stuur…", + "send": "Stuur", + "formatJson": "Formateer as JSON", + "invalidJson": "Boodskap is nie geldige JSON nie", + "hint": "Cmd/Ctrl+Enter om te stuur" + }, + "ping": { + "label": "Outo-ping", + "seconds": "sek", + "intervalTitle": "Ping-interval in sekondes", + "payloadPlaceholder": "ping-loonvrag" + } + }, + "WhoisLookup": { + "title": "Whois-opsoek", + "subtitle": "Soek registrasiebesonderhede vir enige domein of IP-adres op via RDAP", + "placeholder": "example.com of 8.8.8.8", + "lookupBtn": "Soek op", + "looking": "Besig om op te soek…", + "refreshBtn": "Verfris", + "tryLabel": "Probeer:", + "copy": "Kopieer", + "copied": "Gekopieer", + "notAvailable": "Nie beskikbaar nie", + "summary": { + "registrar": "Registrateur", + "network": "Netwerk" + }, + "dates": { + "created": "Geskep", + "updated": "Bygewerk", + "expires": "Verval" + }, + "sections": { + "status": "Status", + "nameservers": "Naambedieners", + "contacts": "Kontakte", + "rawRdap": "Rou RDAP" + }, + "contacts": { + "roles": "Rolle", + "name": "Naam", + "organization": "Organisasie" + }, + "errors": { + "invalidTarget": "Voer 'n geldige domeinnaam of IP-adres in", + "lookupFailed": "Opsoek het misluk (status {status})" + } } } diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index 92258f15..4fe066b8 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "حاسبة Chmod", + "codeScreenshot": "لقطة شاشة للكود", + "cssGenerators": "مولّدات CSS", + "exifViewer": "عارض EXIF", + "faviconGenerator": "مولّد Favicon", + "jsonDiff": "مقارنة JSON", + "jsonpathPlayground": "ساحة JSONPath", + "keycodeInspector": "فاحص أكواد المفاتيح", + "markdownTableGenerator": "جدول Markdown", + "timezoneConverter": "محوّل المناطق الزمنية", + "tokenCounter": "عدّاد الرموز", + "webhookTester": "مختبِر Webhook", + "websocketTester": "مختبِر WebSocket", + "whoisLookup": "بحث Whois" }, "Help": { "title": "المساعدة والتوثيق", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "مولّد ومدقّق Bcrypt", + "description": "شفّر كلمات المرور باستخدام bcrypt وتحقّق من التجزئات الموجودة." + }, + "chmodCalculator": { + "title": "حاسبة Chmod", + "description": "حوّل أذونات يونكس بين الثماني والرمزي ومربّعات الاختيار." + }, + "codeScreenshot": { + "title": "لقطة شاشة للكود", + "description": "حوّل الكود المصدري إلى صور جميلة قابلة للمشاركة." + }, + "cssGenerators": { + "title": "مولّدات CSS", + "description": "مولّدات box-shadow وborder-radius وclamp() المرن." + }, + "exifViewer": { + "title": "عارض ومزيل EXIF", + "description": "افحص بيانات EXIF الوصفية للصور واحذفها محليًا." + }, + "faviconGenerator": { + "title": "مولّد Favicon", + "description": "أنشئ أيقونات المفضلة وأيقونات التطبيق ومقتطفات manifest." + }, + "jsonDiff": { + "title": "مقارنة JSON", + "description": "مقارنة دلالية واعية بالبنية بين مستندَي JSON." + }, + "jsonpathPlayground": { + "title": "ساحة JSONPath", + "description": "استعلم عن JSON باستخدام JSONPath أو JMESPath وشاهد النتائج مباشرة." + }, + "keycodeInspector": { + "title": "فاحص أكواد المفاتيح", + "description": "اضغط مفتاحًا لرؤية أكواد أحداث JavaScript ومفاتيح التعديل." + }, + "markdownTableGenerator": { + "title": "مولّد جداول Markdown", + "description": "أنشئ جداول Markdown وHTML في محرّر شبكي يشبه جداول البيانات." + }, + "timezoneConverter": { + "title": "محوّل المناطق الزمنية", + "description": "حوّل وقتًا عبر مناطق عديدة مع مؤشرات إزاحة اليوم وساعات العمل." + }, + "tokenCounter": { + "title": "عدّاد رموز LLM", + "description": "احسب رموز LLM، وتصوّر الأجزاء، وقدّر تكلفة الـ API." + }, + "webhookTester": { + "title": "مختبِر Webhook", + "description": "التقط وافحص طلبات webhook الواردة في الوقت الفعلي." + }, + "websocketTester": { + "title": "مختبِر WebSocket", + "description": "اتصل بنقاط نهاية WebSocket وتبادل الرسائل في الوقت الفعلي." + }, + "whoisLookup": { + "title": "بحث Whois", + "description": "ابحث عن تفاصيل تسجيل النطاق وعنوان IP عبر RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "مولّد ومدقّق Bcrypt", + "subtitle": "شفّر كلمات المرور باستخدام bcrypt وتحقّق من التجزئات — كل شيء يبقى في متصفّحك", + "tabs": { + "hash": "تجزئة", + "verify": "تحقّق" + }, + "showPassword": "إظهار كلمة المرور", + "hidePassword": "إخفاء كلمة المرور", + "copy": "نسخ", + "copied": "تم النسخ", + "hash": { + "passwordLabel": "كلمة المرور", + "passwordPlaceholder": "أدخل كلمة مرور لتجزئتها…", + "costLabel": "عامل التكلفة", + "costHint": "التكلفة الأعلى تضاعف العمل في كل خطوة. عادةً يُستخدم 10-12 في الإنتاج.", + "slowWarning": "عوامل التكلفة 13 فأعلى قد تستغرق عدة ثوانٍ للحساب.", + "generate": "توليد التجزئة", + "generating": "جارٍ التجزئة…", + "resultLabel": "تجزئة Bcrypt", + "timeTaken": "{ms} مللي ثانية", + "anatomy": { + "title": "بنية التجزئة", + "algorithm": "الخوارزمية", + "cost": "التكلفة", + "salt": "الملح (22 حرفًا)", + "hash": "التجزئة (31 حرفًا)" + } + }, + "verify": { + "passwordLabel": "كلمة المرور", + "passwordPlaceholder": "أدخل كلمة المرور للتحقّق…", + "hashLabel": "تجزئة Bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "أدخل كلمة مرور وتجزئة bcrypt لمقارنتهما.", + "invalidHash": "لا يبدو هذا تجزئة bcrypt صالحة.", + "checking": "جارٍ المقارنة…", + "match": "تطابق — كلمة المرور تنتج هذه التجزئة.", + "noMatch": "لا تطابق — كلمة المرور لا تنتج هذه التجزئة." + } + }, + "ChmodCalculator": { + "title": "حاسبة Chmod", + "subtitle": "حوّل أذونات ملفات يونكس بين مربّعات الاختيار والترميز الثماني والرمزي", + "grid": { + "title": "الأذونات", + "read": "قراءة", + "write": "كتابة", + "execute": "تنفيذ", + "owner": "المالك", + "group": "المجموعة", + "others": "الآخرون" + }, + "special": { + "title": "البتات الخاصة", + "setuid": "setuid", + "setgid": "setgid", + "sticky": "sticky" + }, + "octal": { + "label": "ثماني", + "placeholder": "755 أو 4755", + "invalid": "أدخل 3 أو 4 أرقام ثمانية (0-7)." + }, + "symbolic": { + "label": "رمزي", + "placeholder": "rwxr-xr-x", + "invalid": "أدخل 9 أحرف مثل rwxr-xr-x (يُسمح بـ s/S وt/T)." + }, + "output": { + "title": "الأمر", + "copy": "نسخ", + "copied": "تم النسخ" + }, + "explain": { + "line": "{cls} يمكنه {perms}.", + "none": "{cls} ليس لديه أي أذونات.", + "setuid": "setuid: يعمل الملف بصلاحيات مالكه.", + "setgid": "setgid: يعمل الملف بصلاحيات مجموعته (ترث الملفات الجديدة في مجلد مجموعته).", + "sticky": "sticky: يمكن فقط لمالك الملف حذفه أو إعادة تسميته داخل هذا المجلد." + }, + "presets": { + "title": "الإعدادات الشائعة", + "octal": "ثماني", + "permissions": "الأذونات", + "description": "الاستخدام النموذجي", + "p644": "الملفات العادية: المالك يكتب، والجميع يقرأ.", + "p755": "الملفات القابلة للتنفيذ والمجلدات: يمكن للجميع القراءة والدخول.", + "p600": "الملفات الخاصة: المالك فقط يقرأ ويكتب.", + "p700": "الملفات القابلة للتنفيذ والمجلدات الخاصة: المالك فقط.", + "p664": "ملفات قابلة للكتابة من المجموعة.", + "p775": "مجلدات وملفات قابلة للتنفيذ وقابلة للكتابة من المجموعة.", + "p400": "قراءة فقط للمالك (مثل مفاتيح SSH الخاصة)." + } + }, + "CodeScreenshot": { + "title": "لقطة شاشة للكود", + "subtitle": "حوّل الكود إلى صور جميلة قابلة للمشاركة", + "languageLabel": "اللغة", + "autoDetect": "كشف تلقائي", + "backgroundLabel": "الخلفية", + "paddingLabel": "الحشوة", + "filenameLabel": "اسم الملف", + "filenamePlaceholder": "example.ts", + "windowLabel": "نقاط النافذة", + "themeLabel": "سمة النافذة", + "themeDark": "داكن", + "themeLight": "فاتح", + "scaleLabel": "مقياس التصدير", + "copyImage": "نسخ الصورة", + "copied": "تم النسخ", + "exportPng": "تصدير PNG", + "inputLabel": "الكود", + "detected": "المكتشَف: {language}", + "clear": "مسح", + "placeholder": "الصق الكود هنا…", + "previewLabel": "معاينة", + "emptyPreview": "ستظهر معاينة الكود هنا", + "exportFailed": "فشل تصدير الصورة", + "copyUnsupported": "نسخ الصور غير مدعوم في هذا المتصفّح", + "copyFailed": "فشل نسخ الصورة", + "backgrounds": { + "ocean": "محيط", + "sunset": "غروب", + "candy": "حلوى", + "forest": "غابة", + "midnight": "منتصف الليل", + "gold": "ذهبي", + "aurora": "شفق", + "slate": "رمادي", + "transparent": "شفاف" + } + }, + "CssGenerators": { + "title": "مولّدات CSS", + "subtitle": "box-shadow وborder-radius وclamp() المرن — انسخ الـ CSS.", + "preview": "معاينة", + "output": "CSS", + "copy": "نسخ", + "copied": "تم النسخ", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() المرن" + }, + "boxShadow": { + "presets": "الإعدادات المسبقة", + "layer": "طبقة", + "addLayer": "إضافة طبقة", + "removeLayer": "إزالة الطبقة", + "x": "X", + "y": "Y", + "blur": "تمويه", + "spread": "امتداد", + "color": "اللون", + "alpha": "ألفا", + "inset": "داخلي", + "presetNames": { + "subtleCard": "بطاقة خفيفة", + "elevated": "مرتفع", + "hardOffset": "إزاحة حادة", + "neonGlow": "توهّج نيون", + "inner": "داخلي", + "layeredSmooth": "متعدّد الطبقات ناعم" + } + }, + "borderRadius": { + "modeLabel": "الوضع", + "modes": { + "simple": "لكل زاوية", + "blob": "شكل عضوي" + }, + "unit": "الوحدة", + "linkCorners": "ربط الزوايا", + "randomize": "عشوائي", + "horizontal": "أنصاف الأقطار الأفقية", + "vertical": "أنصاف الأقطار الرأسية", + "corners": { + "tl": "أعلى اليسار", + "tr": "أعلى اليمين", + "br": "أسفل اليمين", + "bl": "أسفل اليسار" + } + }, + "clamp": { + "minFont": "أدنى خط (px)", + "maxFont": "أقصى خط (px)", + "minViewport": "أدنى منفذ عرض (px)", + "maxViewport": "أقصى منفذ عرض (px)", + "rootFont": "خط الجذر (px)", + "viewportWidth": "منفذ عرض محاكى", + "computedSize": "الحجم المحسوب", + "slope": "الميل", + "intercept": "التقاطع", + "previewText": "نصّ معاينة للخط", + "invalid": "أدخل حدود منفذ عرض صالحة ومختلفة." + } + }, + "ExifViewer": { + "title": "عارض ومزيل EXIF", + "subtitle": "افحص بيانات الصور الوصفية واحذفها في متصفّحك", + "dropTitle": "أفلِت صورة هنا", + "dropHint": "أو انقر للتصفّح — صورتك لا تغادر جهازك أبدًا", + "browse": "اختر صورة", + "formats": "JPEG وPNG وWebP وHEIC وTIFF", + "parsing": "جارٍ قراءة البيانات الوصفية…", + "parseFailed": "فشل قراءة هذه الصورة", + "clear": "إزالة الصورة", + "gpsWarning": "يحتوي على موقع GPS", + "openMap": "عرض على OpenStreetMap", + "noMetadata": "لم يُعثر على بيانات وصفية مضمّنة في هذه الصورة.", + "facts": { + "name": "الاسم", + "type": "النوع", + "dimensions": "الأبعاد", + "size": "الحجم" + }, + "groups": { + "camera": "الكاميرا", + "exposure": "التعريض", + "timestamps": "الطوابع الزمنية", + "gps": "موقع GPS", + "software": "البرامج وأخرى" + }, + "gpsCard": { + "latitude": "خط العرض", + "longitude": "خط الطول", + "altitude": "الارتفاع" + }, + "raw": { + "title": "كل البيانات الوصفية", + "copyJson": "نسخ JSON", + "copied": "تم النسخ" + }, + "remove": { + "title": "إزالة البيانات الوصفية", + "jpegDescription": "أزِل EXIF وXMP وIPTC والتعليقات بلا فقدان دون إعادة ضغط الصورة.", + "reencodeDescription": "أعِد ترميز الصورة لإزالة البيانات الوصفية المضمّنة. هذا يعيد ضغط البكسلات.", + "keepIcc": "الاحتفاظ بملف الألوان (ICC)", + "stripButton": "إزالة البيانات الوصفية", + "reencodeButton": "إعادة الترميز والإزالة", + "unsupported": "إزالة البيانات الوصفية غير مدعومة لهذا النوع من الملفات.", + "failed": "فشلت إزالة البيانات الوصفية", + "before": "قبل", + "after": "بعد", + "download": "تنزيل الصورة المنظّفة" + } + }, + "FaviconGenerator": { + "title": "مولّد Favicon", + "subtitle": "أنشئ أيقونات المفضلة وأيقونات التطبيق من صورة واحدة", + "dropTitle": "أفلِت صورة هنا", + "dropHint": "PNG أو JPEG أو SVG — انقر للتصفّح", + "browse": "اختر صورة", + "clear": "إزالة الصورة", + "backgroundLabel": "الخلفية", + "transparent": "شفاف", + "solidColor": "لون خالص", + "cornerLabel": "شكل الزاوية", + "cornerNone": "مربّع", + "cornerRounded": "مستدير", + "cornerCircle": "دائرة", + "maskPaddingLabel": "حشوة قابلة للإخفاء", + "maskable": "قابلة للإخفاء", + "previewLabel": "معاينة", + "copy": "نسخ", + "headSnippet": "مقتطف HTML head", + "downloadIco": "تنزيل favicon.ico", + "downloadZip": "تنزيل الكل (.zip)" + }, + "JsonDiff": { + "title": "مقارنة JSON", + "subtitle": "مقارنة دلالية واعية بالبنية بين مستندَي JSON — يُتجاهل ترتيب المفاتيح والتنسيق", + "options": { + "ignoreArrayOrder": "تجاهل ترتيب المصفوفة" + }, + "loadBothSamples": "تحميل الأمثلة", + "sample": "مثال", + "format": "تنسيق", + "clear": "مسح", + "formatError": "تعذّر التنسيق: الإدخال ليس JSON صالحًا", + "placeholder": "الصق أو اكتب JSON هنا…", + "panels": { + "left": "الأصلي (يسار)", + "right": "المعدّل (يمين)", + "results": "الاختلافات" + }, + "summary": { + "added": "{count} مُضاف", + "removed": "{count} محذوف", + "changed": "{count} متغيّر" + }, + "types": { + "added": "مُضاف", + "removed": "محذوف", + "changed": "متغيّر" + }, + "copyChanges": "نسخ كـ JSON", + "copied": "تم النسخ", + "empty": "الصق JSON في كلا اللوحين لرؤية اختلافاتهما.", + "identical": "المستندان متطابقان دلاليًا." + }, + "JsonpathPlayground": { + "title": "ساحة JSONPath", + "subtitle": "استعلم عن مستندات JSON باستخدام JSONPath أو JMESPath وشاهد النتائج مباشرة", + "engineLabel": "المحرّك", + "queryLabel": "الاستعلام", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "ورقة مرجعية", + "cheats": { + "jpAllTitles": "كل عناوين الكتب", + "jpAllAuthors": "كل مؤلّف في أي مكان بالمستند", + "jpFirstBook": "أول كتاب", + "jpLastBook": "آخر كتاب", + "jpSlice": "أول كتابين (شريحة)", + "jpFilterPrice": "الكتب الأرخص من 40", + "jpFilterInStock": "عناوين الكتب المتوفّرة", + "jpRecursive": "كل القيم، تعاوديًا", + "jmAllTitles": "كل عناوين الكتب", + "jmFirstAuthor": "مؤلّف أول كتاب", + "jmLastBook": "آخر كتاب", + "jmSlice": "أول كتابين (شريحة)", + "jmFilterPrice": "عناوين الكتب الأرخص من 40", + "jmCount": "عدّ الكتب المتوفّرة", + "jmProjection": "أعِد تشكيل كل كتاب إلى كائن جديد", + "jmSort": "العناوين مرتّبة حسب السعر" + }, + "panels": { + "input": "إدخال JSON", + "results": "النتائج" + }, + "sample": "مثال", + "format": "تنسيق", + "clear": "مسح", + "formatError": "تعذّر التنسيق: الإدخال ليس JSON صالحًا", + "placeholder": "الصق أو اكتب JSON هنا…", + "outputPlaceholder": "ستظهر التطابقات هنا…", + "matchCount": "{count, plural, zero {# تطابق} one {# تطابق} two {# تطابقان} few {# تطابقات} many {# تطابقًا} other {# تطابق}}", + "copy": "نسخ", + "copied": "تم النسخ" + }, + "KeycodeInspector": { + "title": "فاحص أكواد المفاتيح", + "subtitle": "اضغط أي مفتاح لفحص حدث لوحة مفاتيح JavaScript الخاص به", + "capture": { + "prompt": "اضغط أي مفتاح…", + "ready": "يستمع — اضغط أي مفتاح على لوحة مفاتيحك", + "clickToFocus": "انقر هنا للتركيز، ثم اضغط أي مفتاح" + }, + "hero": { + "spaceKey": "(مسافة)", + "keyLabel": "event.key", + "repeat": "تكرار" + }, + "copyJson": "نسخ JSON", + "copiedJson": "تم نسخ JSON الحدث إلى الحافظة", + "deprecated": "مهمَل", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "قياسي", + "left": "يسار", + "right": "يمين", + "numpad": "لوحة أرقام", + "unknown": "غير معروف" + }, + "history": { + "title": "السجل", + "clear": "مسح السجل", + "empty": "ستظهر ضغطات المفاتيح هنا." + } + }, + "MarkdownTableGenerator": { + "title": "مولّد جداول Markdown", + "subtitle": "حرّر شبكة، والصق من جدول بيانات، وانسخ جدول Markdown أو HTML بمحاذاة أنيقة", + "addRow": "إضافة صف", + "addColumn": "إضافة عمود", + "importLabel": "استيراد", + "importPlaceholder": "الصق TSV (من Excel / Google Sheets) أو CSV هنا…", + "importButton": "استيراد إلى الشبكة", + "pasteClipboard": "لصق من الحافظة", + "importEmpty": "لا شيء للاستيراد — الصق بعض الصفوف أولًا", + "clipboardError": "تعذّرت القراءة من الحافظة", + "clear": "مسح", + "panels": { + "editor": "محرّر الجدول", + "output": "الناتج" + }, + "align": { + "left": "محاذاة لليسار", + "center": "محاذاة للوسط", + "right": "محاذاة لليمين" + }, + "headerPlaceholder": "رأس", + "cellPlaceholder": "خلية", + "removeRow": "إزالة الصف", + "removeColumn": "إزالة العمود", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "نسخ", + "copied": "تم النسخ" + }, + "TimezoneConverter": { + "title": "محوّل المناطق الزمنية", + "subtitle": "حوّل لحظة واحدة عبر العالم لتخطيط الاجتماعات.", + "dateTimeLabel": "التاريخ والوقت", + "now": "الآن", + "sourceZoneLabel": "المنطقة الزمنية المصدر", + "selectZone": "اختر منطقة", + "searchZone": "ابحث في المناطق الزمنية...", + "noZone": "لم يُعثر على منطقة زمنية.", + "scrubLabel": "إزاحة الوقت", + "reset": "إعادة تعيين", + "targetsLabel": "المناطق الهدف", + "addZone": "إضافة منطقة", + "remove": "إزالة", + "localBadge": "محلي", + "invalidDate": "أدخل تاريخًا ووقتًا صالحين.", + "bands": { + "work": "ساعات العمل (09:00–18:00)", + "edge": "الساعات الهامشية (الصباح الباكر / المساء)", + "night": "الليل" + } + }, + "TokenCounter": { + "title": "عدّاد رموز LLM", + "subtitle": "احسب الرموز، وتصوّر الأجزاء، وقدّر تكلفة الـ API.", + "inputLabel": "النص", + "sample": "مثال", + "clear": "مسح", + "placeholder": "الصق نصًا أو كودًا أو JSON لعدّ الرموز...", + "encoding": "الترميز", + "loading": "جارٍ تحميل المُرمِّز...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-series)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "الرموز", + "characters": "الأحرف", + "words": "الكلمات", + "ratio": "رموز/كلمة", + "ratioSub": "تقريبي" + }, + "claudeRow": "Claude (تقريبي)", + "estimate": "تقدير", + "viz": { + "title": "تصوّر الرموز", + "toggle": "إظهار", + "hint": "بدّل لتلوين كل رمز كجزء.", + "empty": "أدخل نصًا لتصوّر الرموز.", + "truncated": "عرض أول {limit} من {total} رمزًا." + }, + "cost": { + "title": "حاسبة التكلفة", + "disclaimer": "قيم افتراضية قابلة للتعديل — تحقّق من الأسعار الحالية", + "model": "النموذج", + "expectedOutput": "رموز الإخراج المتوقّعة", + "customInput": "إدخال $/MTok", + "customOutput": "إخراج $/MTok", + "inputCost": "تكلفة الإدخال", + "outputCost": "تكلفة الإخراج", + "total": "الإجمالي", + "per1000": "لكل 1000 طلب" + } + }, + "WebhookTester": { + "title": "مختبِر Webhook", + "subtitle": "أنشئ عنوان URL مؤقّتًا وافحص طلبات webhook الواردة مباشرة.", + "intro": { + "title": "أنشئ عنوان webhook لبدء التقاط الطلبات", + "hint": "ستحصل على عنوان URL يصعب تخمينه يسجّل كل طلب وارد. تنتهي صلاحيته بعد 24 ساعة." + }, + "createBtn": "إنشاء عنوان webhook", + "creating": "جارٍ الإنشاء…", + "urlLabel": "عنوان webhook الخاص بك", + "expiresIn": "تنتهي الصلاحية خلال {countdown}", + "copyUrlBtn": "نسخ العنوان", + "copied": "تم النسخ", + "clearBtn": "مسح", + "newBinBtn": "عنوان جديد", + "requestsTitle": "الطلبات", + "selectPrompt": "اختر طلبًا لفحص تفاصيله.", + "expiredNotice": "انتهت صلاحية عنوان webhook هذا. أنشئ عنوانًا جديدًا لمواصلة الاختبار.", + "base64Badge": "base64", + "truncatedBadge": "مقتطع", + "empty": { + "title": "بانتظار الطلبات…", + "hint": "أرسل طلبًا إلى عنوانك وسيظهر هنا فورًا. جرّب هذا:" + }, + "detail": { + "receivedAt": "استُلم", + "source": "IP المصدر", + "contentType": "Content-Type", + "query": "معاملات الاستعلام", + "headers": "الترويسات", + "body": "المحتوى", + "copyBody": "نسخ المحتوى", + "emptyBody": "لا يوجد محتوى" + }, + "errors": { + "createFailed": "فشل إنشاء عنوان webhook. يُرجى المحاولة مجددًا.", + "clearFailed": "فشل مسح الطلبات. يُرجى المحاولة مجددًا." + } + }, + "WebsocketTester": { + "title": "مختبِر WebSocket", + "subtitle": "اتصل بنقطة نهاية WebSocket وأرسِل واستقبِل وافحص الرسائل مباشرة", + "urlLabel": "عنوان نقطة النهاية", + "protocolsLabel": "البروتوكولات الفرعية", + "protocolsPlaceholder": "اختياري، مفصولة بفواصل", + "connect": "اتصال", + "disconnect": "قطع الاتصال", + "status": { + "disconnected": "غير متصل", + "connecting": "جارٍ الاتصال", + "connected": "متصل", + "error": "خطأ" + }, + "messages": { + "connecting": "جارٍ الاتصال بـ {url}…", + "connected": "تم تأسيس الاتصال", + "connectedWithProtocol": "تم تأسيس الاتصال (البروتوكول: {protocol})", + "invalidUrl": "عنوان URL غير صالح — يجب أن يبدأ بـ ws:// أو wss://", + "connectFailed": "فشل فتح الاتصال", + "socketError": "حدث خطأ في الاتصال", + "notConnected": "غير متصل — اتصل أولًا", + "binaryMessage": "تم استلام رسالة ثنائية" + }, + "log": { + "title": "سجل الرسائل", + "empty": "لا رسائل بعد. اتصل وابدأ الإرسال.", + "copyAll": "نسخ الكل", + "download": "تنزيل كـ .txt", + "clear": "مسح السجل", + "prettyPrint": "تنسيق JSON", + "showRaw": "إظهار النص الخام" + }, + "composer": { + "placeholder": "اكتب رسالة للإرسال…", + "send": "إرسال", + "formatJson": "تنسيق كـ JSON", + "invalidJson": "الرسالة ليست JSON صالحًا", + "hint": "Cmd/Ctrl+Enter للإرسال" + }, + "ping": { + "label": "ping تلقائي", + "seconds": "ثانية", + "intervalTitle": "فترة ping بالثواني", + "payloadPlaceholder": "حمولة ping" + } + }, + "WhoisLookup": { + "title": "بحث Whois", + "subtitle": "ابحث عن تفاصيل التسجيل لأي نطاق أو عنوان IP عبر RDAP", + "placeholder": "example.com أو 8.8.8.8", + "lookupBtn": "بحث", + "looking": "جارٍ البحث…", + "refreshBtn": "تحديث", + "tryLabel": "جرّب:", + "copy": "نسخ", + "copied": "تم النسخ", + "notAvailable": "غير متوفّر", + "summary": { + "registrar": "المسجِّل", + "network": "الشبكة" + }, + "dates": { + "created": "أُنشئ", + "updated": "حُدّث", + "expires": "ينتهي" + }, + "sections": { + "status": "الحالة", + "nameservers": "خوادم الأسماء", + "contacts": "جهات الاتصال", + "rawRdap": "RDAP الخام" + }, + "contacts": { + "roles": "الأدوار", + "name": "الاسم", + "organization": "المؤسسة" + }, + "errors": { + "invalidTarget": "أدخل اسم نطاق أو عنوان IP صالحًا", + "lookupFailed": "فشل البحث (الحالة {status})" + } } } diff --git a/apps/web/messages/ca.json b/apps/web/messages/ca.json index b31c412d..6395b095 100644 --- a/apps/web/messages/ca.json +++ b/apps/web/messages/ca.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Calculadora Chmod", + "codeScreenshot": "Captura de codi", + "cssGenerators": "Generadors CSS", + "exifViewer": "Visualitzador EXIF", + "faviconGenerator": "Generador de favicon", + "jsonDiff": "Diff JSON", + "jsonpathPlayground": "Playground JSONPath", + "keycodeInspector": "Inspector de keycode", + "markdownTableGenerator": "Taula Markdown", + "timezoneConverter": "Convertidor de zona horària", + "tokenCounter": "Comptador de tokens", + "webhookTester": "Provador de webhooks", + "websocketTester": "Provador de WebSocket", + "whoisLookup": "Consulta Whois" }, "Help": { "title": "Ajuda i documentació", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Generador i verificador Bcrypt", + "description": "Genera hash de contrasenyes amb bcrypt i verifica hashes existents." + }, + "chmodCalculator": { + "title": "Calculadora Chmod", + "description": "Converteix permisos Unix entre octal, simbòlic i caselles de selecció." + }, + "codeScreenshot": { + "title": "Captura de codi", + "description": "Converteix codi font en imatges boniques i compartibles." + }, + "cssGenerators": { + "title": "Generadors CSS", + "description": "Generadors de box-shadow, border-radius i clamp() fluid." + }, + "exifViewer": { + "title": "Visualitzador i eliminador EXIF", + "description": "Inspecciona i elimina les metadades EXIF de les fotos localment." + }, + "faviconGenerator": { + "title": "Generador de favicon", + "description": "Genera favicons, icones d'app i fragments de manifest." + }, + "jsonDiff": { + "title": "Diff JSON", + "description": "Diff semàntic i conscient de l'estructura de dos documents JSON." + }, + "jsonpathPlayground": { + "title": "Playground JSONPath", + "description": "Consulta JSON amb JSONPath o JMESPath i mira els resultats en directe." + }, + "keycodeInspector": { + "title": "Inspector de keycode", + "description": "Prem una tecla per veure els seus codis d'esdeveniment JavaScript i modificadors." + }, + "markdownTableGenerator": { + "title": "Generador de taules Markdown", + "description": "Crea taules Markdown i HTML en un editor de graella tipus full de càlcul." + }, + "timezoneConverter": { + "title": "Convertidor de zona horària", + "description": "Converteix una hora entre moltes zones amb indicacions de canvi de dia i horari laboral." + }, + "tokenCounter": { + "title": "Comptador de tokens LLM", + "description": "Compta tokens LLM, visualitza chunks i estima el cost de l'API." + }, + "webhookTester": { + "title": "Provador de webhooks", + "description": "Captura i inspecciona les peticions de webhook entrants en temps real." + }, + "websocketTester": { + "title": "Provador de WebSocket", + "description": "Connecta't a endpoints WebSocket i intercanvia missatges en temps real." + }, + "whoisLookup": { + "title": "Consulta Whois", + "description": "Consulta les dades de registre de dominis i IP mitjançant RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Generador i verificador Bcrypt", + "subtitle": "Genera hash de contrasenyes amb bcrypt i verifica hashes — tot es queda al teu navegador", + "tabs": { + "hash": "Hash", + "verify": "Verifica" + }, + "showPassword": "Mostra la contrasenya", + "hidePassword": "Amaga la contrasenya", + "copy": "Copia", + "copied": "Copiat", + "hash": { + "passwordLabel": "Contrasenya", + "passwordPlaceholder": "Introdueix una contrasenya per generar el hash…", + "costLabel": "Factor de cost", + "costHint": "Un cost més alt duplica la feina per pas. 10-12 és una opció habitual per a producció.", + "slowWarning": "Els factors de cost de 13 o més poden trigar diversos segons a calcular-se.", + "generate": "Genera el hash", + "generating": "Generant el hash…", + "resultLabel": "Hash bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Anatomia del hash", + "algorithm": "algoritme", + "cost": "cost", + "salt": "salt (22 caràcters)", + "hash": "hash (31 caràcters)" + } + }, + "verify": { + "passwordLabel": "Contrasenya", + "passwordPlaceholder": "Introdueix la contrasenya per comprovar-la…", + "hashLabel": "Hash bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Introdueix una contrasenya i un hash bcrypt per comparar-los.", + "invalidHash": "Això no sembla un hash bcrypt vàlid.", + "checking": "Comparant…", + "match": "Coincideix — la contrasenya produeix aquest hash.", + "noMatch": "No coincideix — la contrasenya no produeix aquest hash." + } + }, + "ChmodCalculator": { + "title": "Calculadora Chmod", + "subtitle": "Converteix permisos de fitxer Unix entre caselles de selecció, octal i notació simbòlica", + "grid": { + "title": "Permisos", + "read": "Lectura", + "write": "Escriptura", + "execute": "Execució", + "owner": "Propietari", + "group": "Grup", + "others": "Altres" + }, + "special": { + "title": "Bits especials", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Octal", + "placeholder": "755 o 4755", + "invalid": "Introdueix 3 o 4 dígits octals (0-7)." + }, + "symbolic": { + "label": "Simbòlic", + "placeholder": "rwxr-xr-x", + "invalid": "Introdueix 9 caràcters com rwxr-xr-x (s/S, t/T permesos)." + }, + "output": { + "title": "Ordre", + "copy": "Copia", + "copied": "Copiat" + }, + "explain": { + "line": "{cls} pot {perms}.", + "none": "{cls} no té permisos.", + "setuid": "Setuid: el fitxer s'executa amb els privilegis del seu propietari.", + "setgid": "Setgid: el fitxer s'executa amb els privilegis del seu grup (els fitxers nous d'un directori n'hereten el grup).", + "sticky": "Sticky: només el propietari d'un fitxer pot eliminar-lo o canviar-ne el nom dins d'aquest directori." + }, + "presets": { + "title": "Predefinits habituals", + "octal": "Octal", + "permissions": "Permisos", + "description": "Ús típic", + "p644": "Fitxers normals: el propietari escriu, tothom llegeix.", + "p755": "Executables i directoris: tothom pot llegir i entrar-hi.", + "p600": "Fitxers privats: només el propietari llegeix i escriu.", + "p700": "Executables i directoris privats: només el propietari.", + "p664": "Fitxers on el grup pot escriure.", + "p775": "Directoris i executables on el grup pot escriure.", + "p400": "Només de lectura per al propietari (p. ex. claus privades SSH)." + } + }, + "CodeScreenshot": { + "title": "Captura de codi", + "subtitle": "Converteix codi en imatges boniques i compartibles", + "languageLabel": "Llenguatge", + "autoDetect": "Detecció automàtica", + "backgroundLabel": "Fons", + "paddingLabel": "Farciment", + "filenameLabel": "Nom del fitxer", + "filenamePlaceholder": "exemple.ts", + "windowLabel": "Punts de la finestra", + "themeLabel": "Tema de la finestra", + "themeDark": "Fosc", + "themeLight": "Clar", + "scaleLabel": "Escala d'exportació", + "copyImage": "Copia la imatge", + "copied": "Copiat", + "exportPng": "Exporta PNG", + "inputLabel": "Codi", + "detected": "Detectat: {language}", + "clear": "Netejar", + "placeholder": "Enganxa el teu codi aquí…", + "previewLabel": "Vista prèvia", + "emptyPreview": "La vista prèvia del teu codi apareixerà aquí", + "exportFailed": "No s'ha pogut exportar la imatge", + "copyUnsupported": "La còpia d'imatges no és compatible amb aquest navegador", + "copyFailed": "No s'ha pogut copiar la imatge", + "backgrounds": { + "ocean": "Oceà", + "sunset": "Posta de sol", + "candy": "Caramel", + "forest": "Bosc", + "midnight": "Mitjanit", + "gold": "Or", + "aurora": "Aurora", + "slate": "Pissarra", + "transparent": "Transparent" + } + }, + "CssGenerators": { + "title": "Generadors CSS", + "subtitle": "Box-shadow, border-radius i clamp() fluid — copia el CSS.", + "preview": "Vista prèvia", + "output": "CSS", + "copy": "Copia", + "copied": "Copiat", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() fluid" + }, + "boxShadow": { + "presets": "Predefinits", + "layer": "Capa", + "addLayer": "Afegeix una capa", + "removeLayer": "Elimina la capa", + "x": "X", + "y": "Y", + "blur": "Difuminat", + "spread": "Extensió", + "color": "Color", + "alpha": "Alfa", + "inset": "Interior", + "presetNames": { + "subtleCard": "Targeta subtil", + "elevated": "Elevat", + "hardOffset": "Desplaçament dur", + "neonGlow": "Resplendor neó", + "inner": "Interior", + "layeredSmooth": "Capes suaus" + } + }, + "borderRadius": { + "modeLabel": "Mode", + "modes": { + "simple": "Per cantonada", + "blob": "Bombolla orgànica" + }, + "unit": "Unitat", + "linkCorners": "Enllaça les cantonades", + "randomize": "Aleatoritza", + "horizontal": "Radis horitzontals", + "vertical": "Radis verticals", + "corners": { + "tl": "Superior esquerra", + "tr": "Superior dreta", + "br": "Inferior dreta", + "bl": "Inferior esquerra" + } + }, + "clamp": { + "minFont": "Font mínima (px)", + "maxFont": "Font màxima (px)", + "minViewport": "Viewport mínim (px)", + "maxViewport": "Viewport màxim (px)", + "rootFont": "Font arrel (px)", + "viewportWidth": "Viewport simulat", + "computedSize": "Mida calculada", + "slope": "Pendent", + "intercept": "Intercepció", + "previewText": "La ràpida guineu marró", + "invalid": "Introdueix límits de viewport vàlids i diferents." + } + }, + "ExifViewer": { + "title": "Visualitzador i eliminador EXIF", + "subtitle": "Inspecciona les metadades de les fotos i elimina-les al teu navegador", + "dropTitle": "Deixa anar una imatge aquí", + "dropHint": "o fes clic per navegar — la teva imatge no surt mai del teu dispositiu", + "browse": "Tria una imatge", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Llegint metadades…", + "parseFailed": "No s'ha pogut llegir aquesta imatge", + "clear": "Elimina la imatge", + "gpsWarning": "Conté ubicació GPS", + "openMap": "Mostra a OpenStreetMap", + "noMetadata": "No s'han trobat metadades incrustades en aquesta imatge.", + "facts": { + "name": "Nom", + "type": "Tipus", + "dimensions": "Dimensions", + "size": "Mida" + }, + "groups": { + "camera": "Càmera", + "exposure": "Exposició", + "timestamps": "Marques de temps", + "gps": "Ubicació GPS", + "software": "Programari i altres" + }, + "gpsCard": { + "latitude": "Latitud", + "longitude": "Longitud", + "altitude": "Altitud" + }, + "raw": { + "title": "Totes les metadades", + "copyJson": "Copia el JSON", + "copied": "Copiat" + }, + "remove": { + "title": "Elimina les metadades", + "jpegDescription": "Elimina sense pèrdua EXIF, XMP, IPTC i comentaris sense recomprimir la imatge.", + "reencodeDescription": "Recodifica la imatge per eliminar les metadades incrustades. Això recomprimeix els píxels.", + "keepIcc": "Conserva el perfil de color (ICC)", + "stripButton": "Elimina les metadades", + "reencodeButton": "Recodifica i elimina", + "unsupported": "L'eliminació de metadades no és compatible amb aquest tipus de fitxer.", + "failed": "No s'han pogut eliminar les metadades", + "before": "Abans", + "after": "Després", + "download": "Descarrega la imatge neta" + } + }, + "FaviconGenerator": { + "title": "Generador de favicon", + "subtitle": "Crea favicons i icones d'app a partir d'una imatge", + "dropTitle": "Deixa anar una imatge aquí", + "dropHint": "PNG, JPEG o SVG — fes clic per navegar", + "browse": "Tria una imatge", + "clear": "Elimina la imatge", + "backgroundLabel": "Fons", + "transparent": "Transparent", + "solidColor": "Sòlid", + "cornerLabel": "Forma de la cantonada", + "cornerNone": "Quadrat", + "cornerRounded": "Arrodonit", + "cornerCircle": "Cercle", + "maskPaddingLabel": "Farciment maskable", + "maskable": "maskable", + "previewLabel": "Vista prèvia", + "copy": "Copia", + "headSnippet": "Fragment del head HTML", + "downloadIco": "Descarrega favicon.ico", + "downloadZip": "Descarrega-ho tot (.zip)" + }, + "JsonDiff": { + "title": "Diff JSON", + "subtitle": "Comparació semàntica i conscient de l'estructura de dos documents JSON — s'ignoren l'ordre de les claus i el format", + "options": { + "ignoreArrayOrder": "Ignora l'ordre dels arrays" + }, + "loadBothSamples": "Carrega exemples", + "sample": "Exemple", + "format": "Formata", + "clear": "Netejar", + "formatError": "No es pot formatar: l'entrada no és un JSON vàlid", + "placeholder": "Enganxa o escriu JSON aquí…", + "panels": { + "left": "Original (esquerra)", + "right": "Modificat (dreta)", + "results": "Diferències" + }, + "summary": { + "added": "{count} afegit(s)", + "removed": "{count} eliminat(s)", + "changed": "{count} canviat(s)" + }, + "types": { + "added": "Afegit", + "removed": "Eliminat", + "changed": "Canviat" + }, + "copyChanges": "Copia com a JSON", + "copied": "Copiat", + "empty": "Enganxa JSON als dos plafons per veure'n les diferències.", + "identical": "Els dos documents són semànticament idèntics." + }, + "JsonpathPlayground": { + "title": "Playground JSONPath", + "subtitle": "Consulta documents JSON amb JSONPath o JMESPath i mira els resultats en directe", + "engineLabel": "Motor", + "queryLabel": "Consulta", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Referència ràpida", + "cheats": { + "jpAllTitles": "Tots els títols de llibres", + "jpAllAuthors": "Tots els autors, a qualsevol lloc del document", + "jpFirstBook": "Primer llibre", + "jpLastBook": "Últim llibre", + "jpSlice": "Els dos primers llibres (slice)", + "jpFilterPrice": "Llibres més barats que 40", + "jpFilterInStock": "Títols dels llibres en estoc", + "jpRecursive": "Tots els valors, recursivament", + "jmAllTitles": "Tots els títols de llibres", + "jmFirstAuthor": "Autor del primer llibre", + "jmLastBook": "Últim llibre", + "jmSlice": "Els dos primers llibres (slice)", + "jmFilterPrice": "Títols dels llibres més barats que 40", + "jmCount": "Compta els llibres en estoc", + "jmProjection": "Remodela cada llibre en un objecte nou", + "jmSort": "Títols ordenats per preu" + }, + "panels": { + "input": "Entrada JSON", + "results": "Resultats" + }, + "sample": "Exemple", + "format": "Formata", + "clear": "Netejar", + "formatError": "No es pot formatar: l'entrada no és un JSON vàlid", + "placeholder": "Enganxa o escriu JSON aquí…", + "outputPlaceholder": "Les coincidències apareixeran aquí…", + "matchCount": "{count, plural, one {# coincidència} other {# coincidències}}", + "copy": "Copia", + "copied": "Copiat" + }, + "KeycodeInspector": { + "title": "Inspector de keycode", + "subtitle": "Prem qualsevol tecla per inspeccionar el seu esdeveniment de teclat JavaScript", + "capture": { + "prompt": "Prem qualsevol tecla…", + "ready": "Escoltant — prem qualsevol tecla del teu teclat", + "clickToFocus": "Fes clic aquí per enfocar i després prem qualsevol tecla" + }, + "hero": { + "spaceKey": "(Espai)", + "keyLabel": "event.key", + "repeat": "repetició" + }, + "copyJson": "Copia el JSON", + "copiedJson": "JSON de l'esdeveniment copiat al porta-retalls", + "deprecated": "obsolet", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Estàndard", + "left": "Esquerra", + "right": "Dreta", + "numpad": "Teclat numèric", + "unknown": "Desconegut" + }, + "history": { + "title": "Historial", + "clear": "Neteja l'historial", + "empty": "Les tecles premudes apareixeran aquí." + } + }, + "MarkdownTableGenerator": { + "title": "Generador de taules Markdown", + "subtitle": "Edita una graella, enganxa des d'un full de càlcul i copia una taula Markdown o HTML ben alineada", + "addRow": "Afegeix una fila", + "addColumn": "Afegeix una columna", + "importLabel": "Importa", + "importPlaceholder": "Enganxa TSV (d'Excel / Google Sheets) o CSV aquí…", + "importButton": "Importa a la graella", + "pasteClipboard": "Enganxa des del porta-retalls", + "importEmpty": "Res per importar — enganxa primer algunes files", + "clipboardError": "No s'ha pogut llegir del porta-retalls", + "clear": "Netejar", + "panels": { + "editor": "Editor de taules", + "output": "Resultat" + }, + "align": { + "left": "Alinea a l'esquerra", + "center": "Centra", + "right": "Alinea a la dreta" + }, + "headerPlaceholder": "Capçalera", + "cellPlaceholder": "Cel·la", + "removeRow": "Elimina la fila", + "removeColumn": "Elimina la columna", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Copia", + "copied": "Copiat" + }, + "TimezoneConverter": { + "title": "Convertidor de zona horària", + "subtitle": "Converteix un moment arreu del món per planificar reunions.", + "dateTimeLabel": "Data i hora", + "now": "Ara", + "sourceZoneLabel": "Zona horària d'origen", + "selectZone": "Selecciona una zona", + "searchZone": "Cerca zones horàries...", + "noZone": "No s'ha trobat cap zona horària.", + "scrubLabel": "Desplaça l'hora", + "reset": "Restableix", + "targetsLabel": "Zones de destinació", + "addZone": "Afegeix una zona", + "remove": "Elimina", + "localBadge": "Local", + "invalidDate": "Introdueix una data i hora vàlides.", + "bands": { + "work": "Horari laboral (09:00–18:00)", + "edge": "Hores límit (matí / vespre)", + "night": "Nit" + } + }, + "TokenCounter": { + "title": "Comptador de tokens LLM", + "subtitle": "Compta tokens, visualitza chunks i estima el cost de l'API.", + "inputLabel": "Text", + "sample": "Exemple", + "clear": "Netejar", + "placeholder": "Enganxa text, codi o JSON per comptar tokens...", + "encoding": "Codificació", + "loading": "Carregant el tokenitzador...", + "encodings": { + "o200k": "o200k_base (GPT-4o / sèrie o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Caràcters", + "words": "Paraules", + "ratio": "Tokens/paraula", + "ratioSub": "aprox." + }, + "claudeRow": "Claude (aproximat)", + "estimate": "estimació", + "viz": { + "title": "Visualització de tokens", + "toggle": "Mostra", + "hint": "Activa-ho per acolorir cada token com un chunk.", + "empty": "Introdueix text per visualitzar els tokens.", + "truncated": "Mostrant els primers {limit} de {total} tokens." + }, + "cost": { + "title": "Calculadora de cost", + "disclaimer": "valors per defecte editables — verifica els preus actuals", + "model": "Model", + "expectedOutput": "Tokens de sortida esperats", + "customInput": "Entrada $/MTok", + "customOutput": "Sortida $/MTok", + "inputCost": "Cost d'entrada", + "outputCost": "Cost de sortida", + "total": "Total", + "per1000": "Per 1000 crides" + } + }, + "WebhookTester": { + "title": "Provador de webhooks", + "subtitle": "Crea una URL temporal i inspecciona les peticions de webhook entrants en directe.", + "intro": { + "title": "Crea una URL de webhook per començar a capturar peticions", + "hint": "Rebràs una URL impossible d'endevinar que registra cada petició entrant. Caduca al cap de 24 hores." + }, + "createBtn": "Crea una URL de webhook", + "creating": "Creant…", + "urlLabel": "La teva URL de webhook", + "expiresIn": "Caduca en {countdown}", + "copyUrlBtn": "Copia la URL", + "copied": "Copiat", + "clearBtn": "Netejar", + "newBinBtn": "URL nova", + "requestsTitle": "Peticions", + "selectPrompt": "Selecciona una petició per inspeccionar-ne els detalls.", + "expiredNotice": "Aquesta URL de webhook ha caducat. Crea'n una de nova per continuar provant.", + "base64Badge": "base64", + "truncatedBadge": "truncat", + "empty": { + "title": "Esperant peticions…", + "hint": "Envia una petició a la teva URL i apareixerà aquí a l'instant. Prova això:" + }, + "detail": { + "receivedAt": "Rebut", + "source": "IP d'origen", + "contentType": "Content-Type", + "query": "Paràmetres de query", + "headers": "Capçaleres", + "body": "Cos", + "copyBody": "Copia el cos", + "emptyBody": "Sense cos" + }, + "errors": { + "createFailed": "No s'ha pogut crear la URL de webhook. Torna-ho a provar.", + "clearFailed": "No s'han pogut netejar les peticions. Torna-ho a provar." + } + }, + "WebsocketTester": { + "title": "Provador de WebSocket", + "subtitle": "Connecta't a un endpoint WebSocket i envia, rep i inspecciona missatges en directe", + "urlLabel": "URL de l'endpoint", + "protocolsLabel": "Subprotocols", + "protocolsPlaceholder": "opcional, separats per comes", + "connect": "Connecta", + "disconnect": "Desconnecta", + "status": { + "disconnected": "Desconnectat", + "connecting": "Connectant", + "connected": "Connectat", + "error": "Error" + }, + "messages": { + "connecting": "Connectant a {url}…", + "connected": "Connexió establerta", + "connectedWithProtocol": "Connexió establerta (protocol: {protocol})", + "invalidUrl": "URL no vàlida — ha de començar amb ws:// o wss://", + "connectFailed": "No s'ha pogut obrir la connexió", + "socketError": "S'ha produït un error de connexió", + "notConnected": "No connectat — connecta't primer", + "binaryMessage": "S'ha rebut un missatge binari" + }, + "log": { + "title": "Registre de missatges", + "empty": "Encara no hi ha missatges. Connecta't i comença a enviar.", + "copyAll": "Copia-ho tot", + "download": "Descarrega com a .txt", + "clear": "Neteja el registre", + "prettyPrint": "Formata el JSON", + "showRaw": "Mostra el text en brut" + }, + "composer": { + "placeholder": "Escriu un missatge per enviar…", + "send": "Envia", + "formatJson": "Formata com a JSON", + "invalidJson": "El missatge no és un JSON vàlid", + "hint": "Cmd/Ctrl+Enter per enviar" + }, + "ping": { + "label": "Auto-ping", + "seconds": "s", + "intervalTitle": "Interval de ping en segons", + "payloadPlaceholder": "payload del ping" + } + }, + "WhoisLookup": { + "title": "Consulta Whois", + "subtitle": "Consulta les dades de registre de qualsevol domini o adreça IP mitjançant RDAP", + "placeholder": "example.com o 8.8.8.8", + "lookupBtn": "Consulta", + "looking": "Consultant…", + "refreshBtn": "Actualitza", + "tryLabel": "Prova:", + "copy": "Copia", + "copied": "Copiat", + "notAvailable": "No disponible", + "summary": { + "registrar": "Registrador", + "network": "Xarxa" + }, + "dates": { + "created": "Creat", + "updated": "Actualitzat", + "expires": "Caduca" + }, + "sections": { + "status": "Estat", + "nameservers": "Servidors de noms", + "contacts": "Contactes", + "rawRdap": "RDAP en brut" + }, + "contacts": { + "roles": "Rols", + "name": "Nom", + "organization": "Organització" + }, + "errors": { + "invalidTarget": "Introdueix un nom de domini o adreça IP vàlids", + "lookupFailed": "La consulta ha fallat (estat {status})" + } } } diff --git a/apps/web/messages/cs.json b/apps/web/messages/cs.json index 176e845c..3080645c 100644 --- a/apps/web/messages/cs.json +++ b/apps/web/messages/cs.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Kalkulačka chmod", + "codeScreenshot": "Snímek kódu", + "cssGenerators": "CSS generátory", + "exifViewer": "EXIF prohlížeč", + "faviconGenerator": "Generátor favicon", + "jsonDiff": "JSON diff", + "jsonpathPlayground": "JSONPath hřiště", + "keycodeInspector": "Inspektor kláves", + "markdownTableGenerator": "Markdown tabulka", + "timezoneConverter": "Převodník časových pásem", + "tokenCounter": "Počítadlo tokenů", + "webhookTester": "Tester webhooků", + "websocketTester": "Tester WebSocket", + "whoisLookup": "Whois vyhledávání" }, "Help": { "title": "Nápověda a dokumentace", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt generátor a ověřovač", + "description": "Hashujte hesla pomocí bcrypt a ověřujte stávající hashe." + }, + "chmodCalculator": { + "title": "Kalkulačka chmod", + "description": "Převádějte unixová oprávnění mezi osmičkovým, symbolickým zápisem a zaškrtávacími políčky." + }, + "codeScreenshot": { + "title": "Snímek kódu", + "description": "Proměňte zdrojový kód v krásné sdílitelné obrázky." + }, + "cssGenerators": { + "title": "CSS generátory", + "description": "Generátory box-shadow, border-radius a plynulého clamp()." + }, + "exifViewer": { + "title": "EXIF prohlížeč a odstraňovač", + "description": "Prohlížejte a odstraňujte EXIF metadata fotek lokálně." + }, + "faviconGenerator": { + "title": "Generátor favicon", + "description": "Generujte favicony, ikony aplikací a úryvky manifestu." + }, + "jsonDiff": { + "title": "JSON diff", + "description": "Sémantické porovnání dvou JSON dokumentů zohledňující strukturu." + }, + "jsonpathPlayground": { + "title": "JSONPath hřiště", + "description": "Dotazujte se na JSON pomocí JSONPath nebo JMESPath a sledujte živé výsledky." + }, + "keycodeInspector": { + "title": "Inspektor kláves", + "description": "Stiskněte klávesu a zobrazte její JavaScriptové kódy událostí a modifikátory." + }, + "markdownTableGenerator": { + "title": "Generátor Markdown tabulek", + "description": "Vytvářejte Markdown a HTML tabulky v tabulkovém editoru připomínajícím sešit." + }, + "timezoneConverter": { + "title": "Převodník časových pásem", + "description": "Převádějte čas mezi mnoha pásmy s posunem dne a náznaky pracovní doby." + }, + "tokenCounter": { + "title": "Počítadlo LLM tokenů", + "description": "Počítejte LLM tokeny, vizualizujte bloky a odhadněte náklady na API." + }, + "webhookTester": { + "title": "Tester webhooků", + "description": "Zachytávejte a prohlížejte příchozí webhook požadavky v reálném čase." + }, + "websocketTester": { + "title": "Tester WebSocket", + "description": "Připojte se k WebSocket koncovým bodům a vyměňujte zprávy v reálném čase." + }, + "whoisLookup": { + "title": "Whois vyhledávání", + "description": "Vyhledejte registrační údaje domény a IP adresy přes RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt generátor a ověřovač", + "subtitle": "Hashujte hesla pomocí bcrypt a ověřujte hashe — vše zůstává ve vašem prohlížeči", + "tabs": { + "hash": "Hash", + "verify": "Ověřit" + }, + "showPassword": "Zobrazit heslo", + "hidePassword": "Skrýt heslo", + "copy": "Kopírovat", + "copied": "Zkopírováno", + "hash": { + "passwordLabel": "Heslo", + "passwordPlaceholder": "Zadejte heslo k zahashování…", + "costLabel": "Faktor náročnosti", + "costHint": "Vyšší náročnost zdvojnásobuje práci na každý krok. 10–12 je běžná volba pro produkci.", + "slowWarning": "Faktory náročnosti 13 a vyšší mohou výpočet trvat několik sekund.", + "generate": "Vygenerovat hash", + "generating": "Hashuji…", + "resultLabel": "Bcrypt hash", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Anatomie hashe", + "algorithm": "algoritmus", + "cost": "náročnost", + "salt": "sůl (22 znaků)", + "hash": "hash (31 znaků)" + } + }, + "verify": { + "passwordLabel": "Heslo", + "passwordPlaceholder": "Zadejte heslo k ověření…", + "hashLabel": "Bcrypt hash", + "hashPlaceholder": "$2b$10$…", + "idle": "Zadejte heslo a bcrypt hash k jejich porovnání.", + "invalidHash": "Toto nevypadá jako platný bcrypt hash.", + "checking": "Porovnávám…", + "match": "Shoda — heslo vytváří tento hash.", + "noMatch": "Neshoda — heslo nevytváří tento hash." + } + }, + "ChmodCalculator": { + "title": "Kalkulačka chmod", + "subtitle": "Převádějte unixová oprávnění souborů mezi zaškrtávacími políčky, osmičkovým a symbolickým zápisem", + "grid": { + "title": "Oprávnění", + "read": "Čtení", + "write": "Zápis", + "execute": "Spouštění", + "owner": "Vlastník", + "group": "Skupina", + "others": "Ostatní" + }, + "special": { + "title": "Speciální bity", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Osmičkově", + "placeholder": "755 nebo 4755", + "invalid": "Zadejte 3 nebo 4 osmičkové číslice (0–7)." + }, + "symbolic": { + "label": "Symbolicky", + "placeholder": "rwxr-xr-x", + "invalid": "Zadejte 9 znaků jako rwxr-xr-x (povoleno s/S, t/T)." + }, + "output": { + "title": "Příkaz", + "copy": "Kopírovat", + "copied": "Zkopírováno" + }, + "explain": { + "line": "{cls} může {perms}.", + "none": "{cls} nemá žádná oprávnění.", + "setuid": "Setuid: soubor běží s právy svého vlastníka.", + "setgid": "Setgid: soubor běží s právy své skupiny (nové soubory v adresáři dědí jeho skupinu).", + "sticky": "Sticky: soubor v tomto adresáři může smazat nebo přejmenovat pouze jeho vlastník." + }, + "presets": { + "title": "Běžné předvolby", + "octal": "Osmičkově", + "permissions": "Oprávnění", + "description": "Typické použití", + "p644": "Běžné soubory: vlastník zapisuje, všichni čtou.", + "p755": "Spustitelné soubory a adresáře: všichni mohou číst a vstoupit.", + "p600": "Soukromé soubory: čte a zapisuje pouze vlastník.", + "p700": "Soukromé spustitelné soubory a adresáře: pouze vlastník.", + "p664": "Soubory zapisovatelné skupinou.", + "p775": "Adresáře a spustitelné soubory zapisovatelné skupinou.", + "p400": "Pouze pro čtení vlastníkem (např. soukromé SSH klíče)." + } + }, + "CodeScreenshot": { + "title": "Snímek kódu", + "subtitle": "Proměňte kód v krásné sdílitelné obrázky", + "languageLabel": "Jazyk", + "autoDetect": "Automatická detekce", + "backgroundLabel": "Pozadí", + "paddingLabel": "Odsazení", + "filenameLabel": "Název souboru", + "filenamePlaceholder": "example.ts", + "windowLabel": "Tečky okna", + "themeLabel": "Motiv okna", + "themeDark": "Tmavý", + "themeLight": "Světlý", + "scaleLabel": "Měřítko exportu", + "copyImage": "Kopírovat obrázek", + "copied": "Zkopírováno", + "exportPng": "Exportovat PNG", + "inputLabel": "Kód", + "detected": "Detekováno: {language}", + "clear": "Vymazat", + "placeholder": "Vložte sem svůj kód…", + "previewLabel": "Náhled", + "emptyPreview": "Náhled vašeho kódu se zobrazí zde", + "exportFailed": "Export obrázku se nezdařil", + "copyUnsupported": "Kopírování obrázků není v tomto prohlížeči podporováno", + "copyFailed": "Kopírování obrázku se nezdařilo", + "backgrounds": { + "ocean": "Oceán", + "sunset": "Západ slunce", + "candy": "Bonbon", + "forest": "Les", + "midnight": "Půlnoc", + "gold": "Zlatá", + "aurora": "Polární záře", + "slate": "Břidlice", + "transparent": "Průhledné" + } + }, + "CssGenerators": { + "title": "CSS generátory", + "subtitle": "Box-shadow, border-radius a plynulý clamp() — zkopírujte CSS.", + "preview": "Náhled", + "output": "CSS", + "copy": "Kopírovat", + "copied": "Zkopírováno", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Plynulý clamp()" + }, + "boxShadow": { + "presets": "Předvolby", + "layer": "Vrstva", + "addLayer": "Přidat vrstvu", + "removeLayer": "Odebrat vrstvu", + "x": "X", + "y": "Y", + "blur": "Rozmazání", + "spread": "Rozšíření", + "color": "Barva", + "alpha": "Alfa", + "inset": "Vnitřní", + "presetNames": { + "subtleCard": "Jemná karta", + "elevated": "Vyvýšená", + "hardOffset": "Ostrý posun", + "neonGlow": "Neonová záře", + "inner": "Vnitřní", + "layeredSmooth": "Vrstvená hladká" + } + }, + "borderRadius": { + "modeLabel": "Režim", + "modes": { + "simple": "Po rozích", + "blob": "Organická skvrna" + }, + "unit": "Jednotka", + "linkCorners": "Propojit rohy", + "randomize": "Náhodně", + "horizontal": "Vodorovné poloměry", + "vertical": "Svislé poloměry", + "corners": { + "tl": "Vlevo nahoře", + "tr": "Vpravo nahoře", + "br": "Vpravo dole", + "bl": "Vlevo dole" + } + }, + "clamp": { + "minFont": "Min. písmo (px)", + "maxFont": "Max. písmo (px)", + "minViewport": "Min. viewport (px)", + "maxViewport": "Max. viewport (px)", + "rootFont": "Kořenové písmo (px)", + "viewportWidth": "Simulovaný viewport", + "computedSize": "Vypočtená velikost", + "slope": "Sklon", + "intercept": "Průsečík", + "previewText": "Příliš žluťoučký kůň úpěl", + "invalid": "Zadejte platné, odlišné hranice viewportu." + } + }, + "ExifViewer": { + "title": "EXIF prohlížeč a odstraňovač", + "subtitle": "Prohlížejte metadata fotek a odstraňte je ve svém prohlížeči", + "dropTitle": "Sem přetáhněte obrázek", + "dropHint": "nebo klikněte pro procházení — váš obrázek nikdy neopustí vaše zařízení", + "browse": "Vybrat obrázek", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Čtu metadata…", + "parseFailed": "Čtení tohoto obrázku se nezdařilo", + "clear": "Odebrat obrázek", + "gpsWarning": "Obsahuje GPS polohu", + "openMap": "Zobrazit na OpenStreetMap", + "noMetadata": "V tomto obrázku nebyla nalezena žádná vložená metadata.", + "facts": { + "name": "Název", + "type": "Typ", + "dimensions": "Rozměry", + "size": "Velikost" + }, + "groups": { + "camera": "Fotoaparát", + "exposure": "Expozice", + "timestamps": "Časové značky", + "gps": "GPS poloha", + "software": "Software a ostatní" + }, + "gpsCard": { + "latitude": "Zeměpisná šířka", + "longitude": "Zeměpisná délka", + "altitude": "Nadmořská výška" + }, + "raw": { + "title": "Všechna metadata", + "copyJson": "Kopírovat JSON", + "copied": "Zkopírováno" + }, + "remove": { + "title": "Odstranit metadata", + "jpegDescription": "Bezeztrátově odstraňte EXIF, XMP, IPTC a komentáře bez opětovné komprese obrázku.", + "reencodeDescription": "Znovu zakódujte obrázek a odstraňte vložená metadata. Tímto se pixely znovu komprimují.", + "keepIcc": "Zachovat barevný profil (ICC)", + "stripButton": "Odstranit metadata", + "reencodeButton": "Znovu zakódovat a odstranit", + "unsupported": "Odstranění metadat není pro tento typ souboru podporováno.", + "failed": "Odstranění metadat se nezdařilo", + "before": "Před", + "after": "Po", + "download": "Stáhnout vyčištěný obrázek" + } + }, + "FaviconGenerator": { + "title": "Generátor favicon", + "subtitle": "Vytvořte favicony a ikony aplikací z jednoho obrázku", + "dropTitle": "Sem přetáhněte obrázek", + "dropHint": "PNG, JPEG nebo SVG — klikněte pro procházení", + "browse": "Vybrat obrázek", + "clear": "Odebrat obrázek", + "backgroundLabel": "Pozadí", + "transparent": "Průhledné", + "solidColor": "Plná barva", + "cornerLabel": "Tvar rohů", + "cornerNone": "Hranatý", + "cornerRounded": "Zaoblený", + "cornerCircle": "Kruh", + "maskPaddingLabel": "Odsazení pro maskování", + "maskable": "maskable", + "previewLabel": "Náhled", + "copy": "Kopírovat", + "headSnippet": "Úryvek HTML head", + "downloadIco": "Stáhnout favicon.ico", + "downloadZip": "Stáhnout vše (.zip)" + }, + "JsonDiff": { + "title": "JSON diff", + "subtitle": "Sémantické porovnání dvou JSON dokumentů zohledňující strukturu — pořadí klíčů a formátování se ignoruje", + "options": { + "ignoreArrayOrder": "Ignorovat pořadí polí" + }, + "loadBothSamples": "Načíst ukázky", + "sample": "Ukázka", + "format": "Formátovat", + "clear": "Vymazat", + "formatError": "Nelze formátovat: vstup není platný JSON", + "placeholder": "Vložte nebo napište JSON sem…", + "panels": { + "left": "Originál (vlevo)", + "right": "Upravený (vpravo)", + "results": "Rozdíly" + }, + "summary": { + "added": "{count} přidáno", + "removed": "{count} odebráno", + "changed": "{count} změněno" + }, + "types": { + "added": "Přidáno", + "removed": "Odebráno", + "changed": "Změněno" + }, + "copyChanges": "Kopírovat jako JSON", + "copied": "Zkopírováno", + "empty": "Vložte JSON do obou panelů, abyste viděli jejich rozdíly.", + "identical": "Oba dokumenty jsou sémanticky totožné." + }, + "JsonpathPlayground": { + "title": "JSONPath hřiště", + "subtitle": "Dotazujte se na JSON dokumenty pomocí JSONPath nebo JMESPath a sledujte živé výsledky", + "engineLabel": "Engine", + "queryLabel": "Dotaz", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Tahák", + "cheats": { + "jpAllTitles": "Všechny názvy knih", + "jpAllAuthors": "Každý autor, kdekoli v dokumentu", + "jpFirstBook": "První kniha", + "jpLastBook": "Poslední kniha", + "jpSlice": "První dvě knihy (výřez)", + "jpFilterPrice": "Knihy levnější než 40", + "jpFilterInStock": "Názvy skladem dostupných knih", + "jpRecursive": "Všechny hodnoty, rekurzivně", + "jmAllTitles": "Všechny názvy knih", + "jmFirstAuthor": "Autor první knihy", + "jmLastBook": "Poslední kniha", + "jmSlice": "První dvě knihy (výřez)", + "jmFilterPrice": "Názvy knih levnějších než 40", + "jmCount": "Počet skladem dostupných knih", + "jmProjection": "Přeformujte každou knihu do nového objektu", + "jmSort": "Názvy seřazené podle ceny" + }, + "panels": { + "input": "JSON vstup", + "results": "Výsledky" + }, + "sample": "Ukázka", + "format": "Formátovat", + "clear": "Vymazat", + "formatError": "Nelze formátovat: vstup není platný JSON", + "placeholder": "Vložte nebo napište JSON sem…", + "outputPlaceholder": "Shody se zobrazí zde…", + "matchCount": "{count, plural, one {# shoda} few {# shody} many {# shody} other {# shod}}", + "copy": "Kopírovat", + "copied": "Zkopírováno" + }, + "KeycodeInspector": { + "title": "Inspektor kláves", + "subtitle": "Stiskněte libovolnou klávesu a prozkoumejte její JavaScriptovou klávesovou událost", + "capture": { + "prompt": "Stiskněte libovolnou klávesu…", + "ready": "Naslouchám — stiskněte libovolnou klávesu na klávesnici", + "clickToFocus": "Klikněte sem pro zaměření, poté stiskněte libovolnou klávesu" + }, + "hero": { + "spaceKey": "(Mezerník)", + "keyLabel": "event.key", + "repeat": "opakování" + }, + "copyJson": "Kopírovat JSON", + "copiedJson": "JSON události zkopírován do schránky", + "deprecated": "zastaralé", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standardní", + "left": "Levá", + "right": "Pravá", + "numpad": "Numerická klávesnice", + "unknown": "Neznámé" + }, + "history": { + "title": "Historie", + "clear": "Vymazat historii", + "empty": "Stisknuté klávesy se zobrazí zde." + } + }, + "MarkdownTableGenerator": { + "title": "Generátor Markdown tabulek", + "subtitle": "Upravujte mřížku, vkládejte ze sešitu a kopírujte pěkně zarovnanou Markdown nebo HTML tabulku", + "addRow": "Přidat řádek", + "addColumn": "Přidat sloupec", + "importLabel": "Import", + "importPlaceholder": "Vložte sem TSV (z Excelu / Tabulek Google) nebo CSV…", + "importButton": "Importovat do mřížky", + "pasteClipboard": "Vložit ze schránky", + "importEmpty": "Není co importovat — nejprve vložte nějaké řádky", + "clipboardError": "Nepodařilo se číst ze schránky", + "clear": "Vymazat", + "panels": { + "editor": "Editor tabulky", + "output": "Výstup" + }, + "align": { + "left": "Zarovnat vlevo", + "center": "Zarovnat na střed", + "right": "Zarovnat vpravo" + }, + "headerPlaceholder": "Záhlaví", + "cellPlaceholder": "Buňka", + "removeRow": "Odebrat řádek", + "removeColumn": "Odebrat sloupec", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Kopírovat", + "copied": "Zkopírováno" + }, + "TimezoneConverter": { + "title": "Převodník časových pásem", + "subtitle": "Převeďte jeden okamžik po celém světě pro plánování schůzek.", + "dateTimeLabel": "Datum a čas", + "now": "Nyní", + "sourceZoneLabel": "Zdrojové časové pásmo", + "selectZone": "Vyberte pásmo", + "searchZone": "Hledat časová pásma...", + "noZone": "Žádné časové pásmo nenalezeno.", + "scrubLabel": "Posunout čas", + "reset": "Obnovit", + "targetsLabel": "Cílová pásma", + "addZone": "Přidat pásmo", + "remove": "Odebrat", + "localBadge": "Místní", + "invalidDate": "Zadejte platné datum a čas.", + "bands": { + "work": "Pracovní doba (09:00–18:00)", + "edge": "Okrajové hodiny (ráno / večer)", + "night": "Noc" + } + }, + "TokenCounter": { + "title": "Počítadlo LLM tokenů", + "subtitle": "Počítejte tokeny, vizualizujte bloky a odhadněte náklady na API.", + "inputLabel": "Text", + "sample": "Ukázka", + "clear": "Vymazat", + "placeholder": "Vložte text, kód nebo JSON pro počítání tokenů...", + "encoding": "Kódování", + "loading": "Načítám tokenizér...", + "encodings": { + "o200k": "o200k_base (GPT-4o / řada o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokeny", + "characters": "Znaky", + "words": "Slova", + "ratio": "Tokeny/slovo", + "ratioSub": "přibližně" + }, + "claudeRow": "Claude (přibližně)", + "estimate": "odhad", + "viz": { + "title": "Vizualizace tokenů", + "toggle": "Zobrazit", + "hint": "Přepněte pro obarvení každého tokenu jako bloku.", + "empty": "Zadejte text pro vizualizaci tokenů.", + "truncated": "Zobrazuji prvních {limit} z {total} tokenů." + }, + "cost": { + "title": "Kalkulačka nákladů", + "disclaimer": "upravitelné výchozí hodnoty — ověřte si aktuální ceny", + "model": "Model", + "expectedOutput": "Očekávané výstupní tokeny", + "customInput": "Vstup $/MTok", + "customOutput": "Výstup $/MTok", + "inputCost": "Náklady na vstup", + "outputCost": "Náklady na výstup", + "total": "Celkem", + "per1000": "Na 1000 volání" + } + }, + "WebhookTester": { + "title": "Tester webhooků", + "subtitle": "Vytvořte dočasnou URL a živě prohlížejte příchozí webhook požadavky.", + "intro": { + "title": "Vytvořte webhook URL pro zahájení zachytávání požadavků", + "hint": "Získáte neuhodnutelnou URL, která zaznamenává každý příchozí požadavek. Vyprší po 24 hodinách." + }, + "createBtn": "Vytvořit webhook URL", + "creating": "Vytvářím…", + "urlLabel": "Vaše webhook URL", + "expiresIn": "Vyprší za {countdown}", + "copyUrlBtn": "Kopírovat URL", + "copied": "Zkopírováno", + "clearBtn": "Vymazat", + "newBinBtn": "Nová URL", + "requestsTitle": "Požadavky", + "selectPrompt": "Vyberte požadavek pro zobrazení jeho detailů.", + "expiredNotice": "Tato webhook URL vypršela. Vytvořte novou pro pokračování v testování.", + "base64Badge": "base64", + "truncatedBadge": "zkráceno", + "empty": { + "title": "Čekám na požadavky…", + "hint": "Odešlete požadavek na svou URL a okamžitě se zde objeví. Zkuste toto:" + }, + "detail": { + "receivedAt": "Přijato", + "source": "Zdrojová IP", + "contentType": "Content-Type", + "query": "Parametry dotazu", + "headers": "Hlavičky", + "body": "Tělo", + "copyBody": "Kopírovat tělo", + "emptyBody": "Bez těla" + }, + "errors": { + "createFailed": "Vytvoření webhook URL se nezdařilo. Zkuste to prosím znovu.", + "clearFailed": "Vymazání požadavků se nezdařilo. Zkuste to prosím znovu." + } + }, + "WebsocketTester": { + "title": "Tester WebSocket", + "subtitle": "Připojte se k WebSocket koncovému bodu a živě odesílejte, přijímejte a prohlížejte zprávy", + "urlLabel": "URL koncového bodu", + "protocolsLabel": "Subprotokoly", + "protocolsPlaceholder": "volitelné, oddělené čárkou", + "connect": "Připojit", + "disconnect": "Odpojit", + "status": { + "disconnected": "Odpojeno", + "connecting": "Připojuji", + "connected": "Připojeno", + "error": "Chyba" + }, + "messages": { + "connecting": "Připojuji k {url}…", + "connected": "Spojení navázáno", + "connectedWithProtocol": "Spojení navázáno (protokol: {protocol})", + "invalidUrl": "Neplatná URL — musí začínat ws:// nebo wss://", + "connectFailed": "Otevření spojení se nezdařilo", + "socketError": "Došlo k chybě spojení", + "notConnected": "Nepřipojeno — nejprve se připojte", + "binaryMessage": "Přijata binární zpráva" + }, + "log": { + "title": "Protokol zpráv", + "empty": "Zatím žádné zprávy. Připojte se a začněte odesílat.", + "copyAll": "Kopírovat vše", + "download": "Stáhnout jako .txt", + "clear": "Vymazat protokol", + "prettyPrint": "Zformátovat JSON", + "showRaw": "Zobrazit surový text" + }, + "composer": { + "placeholder": "Napište zprávu k odeslání…", + "send": "Odeslat", + "formatJson": "Formátovat jako JSON", + "invalidJson": "Zpráva není platný JSON", + "hint": "Cmd/Ctrl+Enter pro odeslání" + }, + "ping": { + "label": "Automatický ping", + "seconds": "s", + "intervalTitle": "Interval pingu v sekundách", + "payloadPlaceholder": "obsah pingu" + } + }, + "WhoisLookup": { + "title": "Whois vyhledávání", + "subtitle": "Vyhledejte registrační údaje libovolné domény nebo IP adresy přes RDAP", + "placeholder": "example.com nebo 8.8.8.8", + "lookupBtn": "Vyhledat", + "looking": "Vyhledávám…", + "refreshBtn": "Obnovit", + "tryLabel": "Zkuste:", + "copy": "Kopírovat", + "copied": "Zkopírováno", + "notAvailable": "Není k dispozici", + "summary": { + "registrar": "Registrátor", + "network": "Síť" + }, + "dates": { + "created": "Vytvořeno", + "updated": "Aktualizováno", + "expires": "Vyprší" + }, + "sections": { + "status": "Stav", + "nameservers": "Jmenné servery", + "contacts": "Kontakty", + "rawRdap": "Surové RDAP" + }, + "contacts": { + "roles": "Role", + "name": "Jméno", + "organization": "Organizace" + }, + "errors": { + "invalidTarget": "Zadejte platný název domény nebo IP adresu", + "lookupFailed": "Vyhledávání se nezdařilo (stav {status})" + } } } diff --git a/apps/web/messages/da.json b/apps/web/messages/da.json index 94f1c534..6cd654ef 100644 --- a/apps/web/messages/da.json +++ b/apps/web/messages/da.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod-beregner", + "codeScreenshot": "Kodeskærmbillede", + "cssGenerators": "CSS-generatorer", + "exifViewer": "EXIF-fremviser", + "faviconGenerator": "Favicon-generator", + "jsonDiff": "JSON-diff", + "jsonpathPlayground": "JSONPath-legeplads", + "keycodeInspector": "Tastekode-inspektør", + "markdownTableGenerator": "Markdown-tabel", + "timezoneConverter": "Tidszonekonverter", + "tokenCounter": "Token-tæller", + "webhookTester": "Webhook-tester", + "websocketTester": "WebSocket-tester", + "whoisLookup": "Whois-opslag" }, "Help": { "title": "Hjælp og dokumentation", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt-generator og -kontrol", + "description": "Hash adgangskoder med bcrypt, og verificér eksisterende hashes." + }, + "chmodCalculator": { + "title": "Chmod-beregner", + "description": "Konvertér Unix-tilladelser mellem oktal, symbolsk notation og afkrydsningsfelter." + }, + "codeScreenshot": { + "title": "Kodeskærmbillede", + "description": "Forvandl kildekode til smukke, delbare billeder." + }, + "cssGenerators": { + "title": "CSS-generatorer", + "description": "Generatorer til box-shadow, border-radius og flydende clamp()." + }, + "exifViewer": { + "title": "EXIF-fremviser og -fjerner", + "description": "Inspicér og fjern EXIF-metadata fra fotos lokalt." + }, + "faviconGenerator": { + "title": "Favicon-generator", + "description": "Generér favicons, app-ikoner og manifest-uddrag." + }, + "jsonDiff": { + "title": "JSON-diff", + "description": "Semantisk, strukturbevidst sammenligning af to JSON-dokumenter." + }, + "jsonpathPlayground": { + "title": "JSONPath-legeplads", + "description": "Forespørg JSON med JSONPath eller JMESPath, og se resultater live." + }, + "keycodeInspector": { + "title": "Tastekode-inspektør", + "description": "Tryk på en tast for at se dens JavaScript-hændelseskoder og modifikatorer." + }, + "markdownTableGenerator": { + "title": "Markdown-tabelgenerator", + "description": "Byg Markdown- og HTML-tabeller i en regnearkslignende gittereditor." + }, + "timezoneConverter": { + "title": "Tidszonekonverter", + "description": "Konvertér et tidspunkt på tværs af mange zoner med dagsforskydning og arbejdstidsmarkeringer." + }, + "tokenCounter": { + "title": "LLM-token-tæller", + "description": "Tæl LLM-tokens, visualisér chunks, og estimér API-omkostninger." + }, + "webhookTester": { + "title": "Webhook-tester", + "description": "Opfang og inspicér indgående webhook-anmodninger i realtid." + }, + "websocketTester": { + "title": "WebSocket-tester", + "description": "Forbind til WebSocket-endpoints, og udveksl beskeder i realtid." + }, + "whoisLookup": { + "title": "Whois-opslag", + "description": "Slå registreringsoplysninger for domæner og IP-adresser op via RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt-generator og -kontrol", + "subtitle": "Hash adgangskoder med bcrypt, og verificér hashes — alt bliver i din browser", + "tabs": { + "hash": "Hash", + "verify": "Verificér" + }, + "showPassword": "Vis adgangskode", + "hidePassword": "Skjul adgangskode", + "copy": "Kopiér", + "copied": "Kopieret", + "hash": { + "passwordLabel": "Adgangskode", + "passwordPlaceholder": "Indtast en adgangskode, der skal hashes…", + "costLabel": "Omkostningsfaktor", + "costHint": "Højere omkostning fordobler arbejdet pr. trin. 10-12 er et almindeligt valg til produktion.", + "slowWarning": "Omkostningsfaktorer på 13 og derover kan tage flere sekunder at beregne.", + "generate": "Generér hash", + "generating": "Hasher…", + "resultLabel": "Bcrypt-hash", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Hash-anatomi", + "algorithm": "algoritme", + "cost": "omkostning", + "salt": "salt (22 tegn)", + "hash": "hash (31 tegn)" + } + }, + "verify": { + "passwordLabel": "Adgangskode", + "passwordPlaceholder": "Indtast adgangskoden, der skal kontrolleres…", + "hashLabel": "Bcrypt-hash", + "hashPlaceholder": "$2b$10$…", + "idle": "Indtast en adgangskode og en bcrypt-hash for at sammenligne dem.", + "invalidHash": "Dette ligner ikke en gyldig bcrypt-hash.", + "checking": "Sammenligner…", + "match": "Match — adgangskoden producerer denne hash.", + "noMatch": "Intet match — adgangskoden producerer ikke denne hash." + } + }, + "ChmodCalculator": { + "title": "Chmod-beregner", + "subtitle": "Konvertér Unix-filtilladelser mellem afkrydsningsfelter, oktal og symbolsk notation", + "grid": { + "title": "Tilladelser", + "read": "Læs", + "write": "Skriv", + "execute": "Kør", + "owner": "Ejer", + "group": "Gruppe", + "others": "Andre" + }, + "special": { + "title": "Specielle bits", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Oktal", + "placeholder": "755 eller 4755", + "invalid": "Indtast 3 eller 4 oktale cifre (0-7)." + }, + "symbolic": { + "label": "Symbolsk", + "placeholder": "rwxr-xr-x", + "invalid": "Indtast 9 tegn som rwxr-xr-x (s/S, t/T tilladt)." + }, + "output": { + "title": "Kommando", + "copy": "Kopiér", + "copied": "Kopieret" + }, + "explain": { + "line": "{cls} kan {perms}.", + "none": "{cls} har ingen tilladelser.", + "setuid": "Setuid: filen kører med ejerens rettigheder.", + "setgid": "Setgid: filen kører med gruppens rettigheder (nye filer i en mappe arver dens gruppe).", + "sticky": "Sticky: kun en fils ejer kan slette eller omdøbe den i denne mappe." + }, + "presets": { + "title": "Almindelige forudindstillinger", + "octal": "Oktal", + "permissions": "Tilladelser", + "description": "Typisk brug", + "p644": "Almindelige filer: ejeren skriver, alle læser.", + "p755": "Eksekverbare filer og mapper: alle kan læse og gå ind.", + "p600": "Private filer: kun ejeren læser og skriver.", + "p700": "Private eksekverbare filer og mapper: kun ejeren.", + "p664": "Gruppe-skrivbare filer.", + "p775": "Gruppe-skrivbare mapper og eksekverbare filer.", + "p400": "Skrivebeskyttet for ejeren (f.eks. private SSH-nøgler)." + } + }, + "CodeScreenshot": { + "title": "Kodeskærmbillede", + "subtitle": "Forvandl kode til smukke, delbare billeder", + "languageLabel": "Sprog", + "autoDetect": "Automatisk registrering", + "backgroundLabel": "Baggrund", + "paddingLabel": "Udfyldning", + "filenameLabel": "Filnavn", + "filenamePlaceholder": "example.ts", + "windowLabel": "Vinduesprikker", + "themeLabel": "Vinduestema", + "themeDark": "Mørkt", + "themeLight": "Lyst", + "scaleLabel": "Eksportskala", + "copyImage": "Kopiér billede", + "copied": "Kopieret", + "exportPng": "Eksportér PNG", + "inputLabel": "Kode", + "detected": "Registreret: {language}", + "clear": "Ryd", + "placeholder": "Indsæt din kode her…", + "previewLabel": "Forhåndsvisning", + "emptyPreview": "Forhåndsvisningen af din kode vises her", + "exportFailed": "Kunne ikke eksportere billede", + "copyUnsupported": "Kopiering af billeder understøttes ikke i denne browser", + "copyFailed": "Kunne ikke kopiere billede", + "backgrounds": { + "ocean": "Ocean", + "sunset": "Solnedgang", + "candy": "Slik", + "forest": "Skov", + "midnight": "Midnat", + "gold": "Guld", + "aurora": "Nordlys", + "slate": "Skifer", + "transparent": "Gennemsigtig" + } + }, + "CssGenerators": { + "title": "CSS-generatorer", + "subtitle": "Box-shadow, border-radius og flydende clamp() — kopiér CSS'en.", + "preview": "Forhåndsvisning", + "output": "CSS", + "copy": "Kopiér", + "copied": "Kopieret", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Flydende clamp()" + }, + "boxShadow": { + "presets": "Forudindstillinger", + "layer": "Lag", + "addLayer": "Tilføj lag", + "removeLayer": "Fjern lag", + "x": "X", + "y": "Y", + "blur": "Sløring", + "spread": "Spredning", + "color": "Farve", + "alpha": "Alfa", + "inset": "Indvendig", + "presetNames": { + "subtleCard": "Diskret kort", + "elevated": "Hævet", + "hardOffset": "Hård forskydning", + "neonGlow": "Neonglød", + "inner": "Indvendig", + "layeredSmooth": "Lagdelt blød" + } + }, + "borderRadius": { + "modeLabel": "Tilstand", + "modes": { + "simple": "Pr. hjørne", + "blob": "Organisk klat" + }, + "unit": "Enhed", + "linkCorners": "Sammenkæd hjørner", + "randomize": "Tilfældiggør", + "horizontal": "Vandrette radier", + "vertical": "Lodrette radier", + "corners": { + "tl": "Øverst til venstre", + "tr": "Øverst til højre", + "br": "Nederst til højre", + "bl": "Nederst til venstre" + } + }, + "clamp": { + "minFont": "Min. skrift (px)", + "maxFont": "Maks. skrift (px)", + "minViewport": "Min. viewport (px)", + "maxViewport": "Maks. viewport (px)", + "rootFont": "Rodskrift (px)", + "viewportWidth": "Simuleret viewport", + "computedSize": "Beregnet størrelse", + "slope": "Hældning", + "intercept": "Skæring", + "previewText": "Quizdeltagerne spiste jordbær", + "invalid": "Indtast gyldige, forskellige viewport-grænser." + } + }, + "ExifViewer": { + "title": "EXIF-fremviser og -fjerner", + "subtitle": "Inspicér fotos' metadata, og fjern dem i din browser", + "dropTitle": "Slip et billede her", + "dropHint": "eller klik for at gennemse — dit billede forlader aldrig din enhed", + "browse": "Vælg billede", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Læser metadata…", + "parseFailed": "Kunne ikke læse dette billede", + "clear": "Fjern billede", + "gpsWarning": "Indeholder GPS-placering", + "openMap": "Vis på OpenStreetMap", + "noMetadata": "Der blev ikke fundet nogen indlejrede metadata i dette billede.", + "facts": { + "name": "Navn", + "type": "Type", + "dimensions": "Dimensioner", + "size": "Størrelse" + }, + "groups": { + "camera": "Kamera", + "exposure": "Eksponering", + "timestamps": "Tidsstempler", + "gps": "GPS-placering", + "software": "Software og andet" + }, + "gpsCard": { + "latitude": "Breddegrad", + "longitude": "Længdegrad", + "altitude": "Højde" + }, + "raw": { + "title": "Alle metadata", + "copyJson": "Kopiér JSON", + "copied": "Kopieret" + }, + "remove": { + "title": "Fjern metadata", + "jpegDescription": "Fjern EXIF, XMP, IPTC og kommentarer tabsfrit uden at genkomprimere billedet.", + "reencodeDescription": "Genkod billedet for at fjerne indlejrede metadata. Dette genkomprimerer pixels.", + "keepIcc": "Behold farveprofil (ICC)", + "stripButton": "Fjern metadata", + "reencodeButton": "Genkod og fjern", + "unsupported": "Fjernelse af metadata understøttes ikke for denne filtype.", + "failed": "Kunne ikke fjerne metadata", + "before": "Før", + "after": "Efter", + "download": "Download renset billede" + } + }, + "FaviconGenerator": { + "title": "Favicon-generator", + "subtitle": "Opret favicons og app-ikoner ud fra ét billede", + "dropTitle": "Slip et billede her", + "dropHint": "PNG, JPEG eller SVG — klik for at gennemse", + "browse": "Vælg billede", + "clear": "Fjern billede", + "backgroundLabel": "Baggrund", + "transparent": "Gennemsigtig", + "solidColor": "Ensfarvet", + "cornerLabel": "Hjørneform", + "cornerNone": "Firkantet", + "cornerRounded": "Afrundet", + "cornerCircle": "Cirkel", + "maskPaddingLabel": "Maskerbar udfyldning", + "maskable": "maskable", + "previewLabel": "Forhåndsvisning", + "copy": "Kopiér", + "headSnippet": "HTML head-uddrag", + "downloadIco": "Download favicon.ico", + "downloadZip": "Download alle (.zip)" + }, + "JsonDiff": { + "title": "JSON-diff", + "subtitle": "Semantisk, strukturbevidst sammenligning af to JSON-dokumenter — nøglerækkefølge og formatering ignoreres", + "options": { + "ignoreArrayOrder": "Ignorér array-rækkefølge" + }, + "loadBothSamples": "Indlæs eksempler", + "sample": "Eksempel", + "format": "Format", + "clear": "Ryd", + "formatError": "Kan ikke formatere: input er ikke gyldig JSON", + "placeholder": "Indsæt eller skriv JSON her…", + "panels": { + "left": "Original (venstre)", + "right": "Ændret (højre)", + "results": "Forskelle" + }, + "summary": { + "added": "{count} tilføjet", + "removed": "{count} fjernet", + "changed": "{count} ændret" + }, + "types": { + "added": "Tilføjet", + "removed": "Fjernet", + "changed": "Ændret" + }, + "copyChanges": "Kopiér som JSON", + "copied": "Kopieret", + "empty": "Indsæt JSON i begge paneler for at se deres forskelle.", + "identical": "De to dokumenter er semantisk identiske." + }, + "JsonpathPlayground": { + "title": "JSONPath-legeplads", + "subtitle": "Forespørg JSON-dokumenter med JSONPath eller JMESPath, og se resultater live", + "engineLabel": "Motor", + "queryLabel": "Forespørgsel", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Snydeark", + "cheats": { + "jpAllTitles": "Alle bogtitler", + "jpAllAuthors": "Hver forfatter, hvor som helst i dokumentet", + "jpFirstBook": "Første bog", + "jpLastBook": "Sidste bog", + "jpSlice": "De første to bøger (udsnit)", + "jpFilterPrice": "Bøger billigere end 40", + "jpFilterInStock": "Titler på bøger på lager", + "jpRecursive": "Alle værdier, rekursivt", + "jmAllTitles": "Alle bogtitler", + "jmFirstAuthor": "Forfatter af den første bog", + "jmLastBook": "Sidste bog", + "jmSlice": "De første to bøger (udsnit)", + "jmFilterPrice": "Titler på bøger billigere end 40", + "jmCount": "Antal bøger på lager", + "jmProjection": "Omform hver bog til et nyt objekt", + "jmSort": "Titler sorteret efter pris" + }, + "panels": { + "input": "JSON-input", + "results": "Resultater" + }, + "sample": "Eksempel", + "format": "Format", + "clear": "Ryd", + "formatError": "Kan ikke formatere: input er ikke gyldig JSON", + "placeholder": "Indsæt eller skriv JSON her…", + "outputPlaceholder": "Match vises her…", + "matchCount": "{count, plural, one {# match} other {# match}}", + "copy": "Kopiér", + "copied": "Kopieret" + }, + "KeycodeInspector": { + "title": "Tastekode-inspektør", + "subtitle": "Tryk på en vilkårlig tast for at inspicere dens JavaScript-tastaturhændelse", + "capture": { + "prompt": "Tryk på en vilkårlig tast…", + "ready": "Lytter — tryk på en vilkårlig tast på dit tastatur", + "clickToFocus": "Klik her for at fokusere, og tryk derefter på en vilkårlig tast" + }, + "hero": { + "spaceKey": "(Mellemrum)", + "keyLabel": "event.key", + "repeat": "gentagelse" + }, + "copyJson": "Kopiér JSON", + "copiedJson": "Hændelses-JSON kopieret til udklipsholder", + "deprecated": "forældet", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standard", + "left": "Venstre", + "right": "Højre", + "numpad": "Numerisk tastatur", + "unknown": "Ukendt" + }, + "history": { + "title": "Historik", + "clear": "Ryd historik", + "empty": "Tastetryk vises her." + } + }, + "MarkdownTableGenerator": { + "title": "Markdown-tabelgenerator", + "subtitle": "Rediger et gitter, indsæt fra et regneark, og kopiér en pænt justeret Markdown- eller HTML-tabel", + "addRow": "Tilføj række", + "addColumn": "Tilføj kolonne", + "importLabel": "Importér", + "importPlaceholder": "Indsæt TSV (fra Excel / Google Sheets) eller CSV her…", + "importButton": "Importér til gitter", + "pasteClipboard": "Indsæt fra udklipsholder", + "importEmpty": "Intet at importere — indsæt nogle rækker først", + "clipboardError": "Kunne ikke læse fra udklipsholderen", + "clear": "Ryd", + "panels": { + "editor": "Tabeleditor", + "output": "Output" + }, + "align": { + "left": "Justér venstre", + "center": "Centrér", + "right": "Justér højre" + }, + "headerPlaceholder": "Overskrift", + "cellPlaceholder": "Celle", + "removeRow": "Fjern række", + "removeColumn": "Fjern kolonne", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Kopiér", + "copied": "Kopieret" + }, + "TimezoneConverter": { + "title": "Tidszonekonverter", + "subtitle": "Konvertér ét øjeblik på tværs af verden til mødeplanlægning.", + "dateTimeLabel": "Dato og tid", + "now": "Nu", + "sourceZoneLabel": "Kilde-tidszone", + "selectZone": "Vælg zone", + "searchZone": "Søg tidszoner...", + "noZone": "Ingen tidszone fundet.", + "scrubLabel": "Forskyd tid", + "reset": "Nulstil", + "targetsLabel": "Målzoner", + "addZone": "Tilføj zone", + "remove": "Fjern", + "localBadge": "Lokal", + "invalidDate": "Indtast en gyldig dato og tid.", + "bands": { + "work": "Arbejdstid (09:00–18:00)", + "edge": "Randtimer (tidligt / aften)", + "night": "Nat" + } + }, + "TokenCounter": { + "title": "LLM-token-tæller", + "subtitle": "Tæl tokens, visualisér chunks, og estimér API-omkostninger.", + "inputLabel": "Tekst", + "sample": "Eksempel", + "clear": "Ryd", + "placeholder": "Indsæt tekst, kode eller JSON for at tælle tokens...", + "encoding": "Kodning", + "loading": "Indlæser tokenizer...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-serien)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Tegn", + "words": "Ord", + "ratio": "Tokens/ord", + "ratioSub": "cirka" + }, + "claudeRow": "Claude (omtrentlig)", + "estimate": "estimat", + "viz": { + "title": "Token-visualisering", + "toggle": "Vis", + "hint": "Slå til for at farve hvert token som en chunk.", + "empty": "Indtast tekst for at visualisere tokens.", + "truncated": "Viser de første {limit} af {total} tokens." + }, + "cost": { + "title": "Omkostningsberegner", + "disclaimer": "redigerbare standardværdier — verificér aktuelle priser", + "model": "Model", + "expectedOutput": "Forventede output-tokens", + "customInput": "Input $/MTok", + "customOutput": "Output $/MTok", + "inputCost": "Input-omkostning", + "outputCost": "Output-omkostning", + "total": "I alt", + "per1000": "Pr. 1000 kald" + } + }, + "WebhookTester": { + "title": "Webhook-tester", + "subtitle": "Opret en midlertidig URL, og inspicér indgående webhook-anmodninger live.", + "intro": { + "title": "Opret en webhook-URL for at begynde at opfange anmodninger", + "hint": "Du får en ugættelig URL, der registrerer hver indgående anmodning. Den udløber efter 24 timer." + }, + "createBtn": "Opret webhook-URL", + "creating": "Opretter…", + "urlLabel": "Din webhook-URL", + "expiresIn": "Udløber om {countdown}", + "copyUrlBtn": "Kopiér URL", + "copied": "Kopieret", + "clearBtn": "Ryd", + "newBinBtn": "Ny URL", + "requestsTitle": "Anmodninger", + "selectPrompt": "Vælg en anmodning for at inspicere dens detaljer.", + "expiredNotice": "Denne webhook-URL er udløbet. Opret en ny for at fortsætte med at teste.", + "base64Badge": "base64", + "truncatedBadge": "afkortet", + "empty": { + "title": "Venter på anmodninger…", + "hint": "Send en anmodning til din URL, så vises den her med det samme. Prøv dette:" + }, + "detail": { + "receivedAt": "Modtaget", + "source": "Kilde-IP", + "contentType": "Content-Type", + "query": "Forespørgselsparametre", + "headers": "Headers", + "body": "Body", + "copyBody": "Kopiér body", + "emptyBody": "Ingen body" + }, + "errors": { + "createFailed": "Kunne ikke oprette webhook-URL. Prøv igen.", + "clearFailed": "Kunne ikke rydde anmodninger. Prøv igen." + } + }, + "WebsocketTester": { + "title": "WebSocket-tester", + "subtitle": "Forbind til et WebSocket-endpoint, og send, modtag og inspicér beskeder live", + "urlLabel": "Endpoint-URL", + "protocolsLabel": "Subprotokoller", + "protocolsPlaceholder": "valgfrit, kommasepareret", + "connect": "Forbind", + "disconnect": "Afbryd", + "status": { + "disconnected": "Afbrudt", + "connecting": "Forbinder", + "connected": "Forbundet", + "error": "Fejl" + }, + "messages": { + "connecting": "Forbinder til {url}…", + "connected": "Forbindelse etableret", + "connectedWithProtocol": "Forbindelse etableret (protokol: {protocol})", + "invalidUrl": "Ugyldig URL — skal begynde med ws:// eller wss://", + "connectFailed": "Kunne ikke åbne forbindelsen", + "socketError": "Der opstod en forbindelsesfejl", + "notConnected": "Ikke forbundet — forbind først", + "binaryMessage": "Modtog en binær besked" + }, + "log": { + "title": "Beskedlog", + "empty": "Ingen beskeder endnu. Forbind, og begynd at sende.", + "copyAll": "Kopiér alle", + "download": "Download som .txt", + "clear": "Ryd log", + "prettyPrint": "Formatér JSON pænt", + "showRaw": "Vis rå tekst" + }, + "composer": { + "placeholder": "Skriv en besked, der skal sendes…", + "send": "Send", + "formatJson": "Formatér som JSON", + "invalidJson": "Beskeden er ikke gyldig JSON", + "hint": "Cmd/Ctrl+Enter for at sende" + }, + "ping": { + "label": "Auto-ping", + "seconds": "sek", + "intervalTitle": "Ping-interval i sekunder", + "payloadPlaceholder": "ping-payload" + } + }, + "WhoisLookup": { + "title": "Whois-opslag", + "subtitle": "Slå registreringsoplysninger op for ethvert domæne eller IP-adresse via RDAP", + "placeholder": "example.com eller 8.8.8.8", + "lookupBtn": "Slå op", + "looking": "Slår op…", + "refreshBtn": "Opdatér", + "tryLabel": "Prøv:", + "copy": "Kopiér", + "copied": "Kopieret", + "notAvailable": "Ikke tilgængelig", + "summary": { + "registrar": "Registrator", + "network": "Netværk" + }, + "dates": { + "created": "Oprettet", + "updated": "Opdateret", + "expires": "Udløber" + }, + "sections": { + "status": "Status", + "nameservers": "Navneservere", + "contacts": "Kontakter", + "rawRdap": "Rå RDAP" + }, + "contacts": { + "roles": "Roller", + "name": "Navn", + "organization": "Organisation" + }, + "errors": { + "invalidTarget": "Indtast et gyldigt domænenavn eller IP-adresse", + "lookupFailed": "Opslag mislykkedes (status {status})" + } } } diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index ad32596e..6caa3be1 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod-Rechner", + "codeScreenshot": "Code-Screenshot", + "cssGenerators": "CSS-Generatoren", + "exifViewer": "EXIF-Viewer", + "faviconGenerator": "Favicon-Generator", + "jsonDiff": "JSON-Diff", + "jsonpathPlayground": "JSONPath-Playground", + "keycodeInspector": "Keycode-Inspektor", + "markdownTableGenerator": "Markdown-Tabelle", + "timezoneConverter": "Zeitzonen-Konverter", + "tokenCounter": "Token-Zähler", + "webhookTester": "Webhook-Tester", + "websocketTester": "WebSocket-Tester", + "whoisLookup": "Whois-Abfrage" }, "Help": { "title": "Hilfe & Dokumentation", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt-Generator & -Prüfer", + "description": "Passwörter mit bcrypt hashen und bestehende Hashes verifizieren." + }, + "chmodCalculator": { + "title": "Chmod-Rechner", + "description": "Unix-Berechtigungen zwischen Oktal, symbolisch und Kontrollkästchen umrechnen." + }, + "codeScreenshot": { + "title": "Code-Screenshot", + "description": "Quellcode in schöne, teilbare Bilder verwandeln." + }, + "cssGenerators": { + "title": "CSS-Generatoren", + "description": "Generatoren für box-shadow, border-radius und fluides clamp()." + }, + "exifViewer": { + "title": "EXIF-Viewer & -Entferner", + "description": "EXIF-Metadaten von Fotos lokal prüfen und entfernen." + }, + "faviconGenerator": { + "title": "Favicon-Generator", + "description": "Favicons, App-Icons und Manifest-Snippets erzeugen." + }, + "jsonDiff": { + "title": "JSON-Diff", + "description": "Semantischer, strukturbewusster Vergleich zweier JSON-Dokumente." + }, + "jsonpathPlayground": { + "title": "JSONPath-Playground", + "description": "JSON mit JSONPath oder JMESPath abfragen und Ergebnisse live sehen." + }, + "keycodeInspector": { + "title": "Keycode-Inspektor", + "description": "Eine Taste drücken, um ihre JavaScript-Event-Codes und Modifikatoren zu sehen." + }, + "markdownTableGenerator": { + "title": "Markdown-Tabellen-Generator", + "description": "Markdown- und HTML-Tabellen in einem tabellenkalkulationsähnlichen Raster-Editor erstellen." + }, + "timezoneConverter": { + "title": "Zeitzonen-Konverter", + "description": "Eine Zeit über viele Zonen umrechnen, mit Hinweisen zu Tageswechsel und Arbeitszeiten." + }, + "tokenCounter": { + "title": "LLM-Token-Zähler", + "description": "LLM-Tokens zählen, Chunks visualisieren und API-Kosten schätzen." + }, + "webhookTester": { + "title": "Webhook-Tester", + "description": "Eingehende Webhook-Anfragen in Echtzeit erfassen und untersuchen." + }, + "websocketTester": { + "title": "WebSocket-Tester", + "description": "Mit WebSocket-Endpunkten verbinden und Nachrichten in Echtzeit austauschen." + }, + "whoisLookup": { + "title": "Whois-Abfrage", + "description": "Registrierungsdaten von Domains und IPs per RDAP abrufen." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt-Generator & -Prüfer", + "subtitle": "Passwörter mit bcrypt hashen und Hashes verifizieren – alles bleibt in deinem Browser", + "tabs": { + "hash": "Hashen", + "verify": "Verifizieren" + }, + "showPassword": "Passwort anzeigen", + "hidePassword": "Passwort verbergen", + "copy": "Kopieren", + "copied": "Kopiert", + "hash": { + "passwordLabel": "Passwort", + "passwordPlaceholder": "Ein Passwort zum Hashen eingeben…", + "costLabel": "Kostenfaktor", + "costHint": "Ein höherer Kostenfaktor verdoppelt den Aufwand pro Schritt. 10-12 ist eine gängige Wahl für die Produktion.", + "slowWarning": "Kostenfaktoren ab 13 können mehrere Sekunden zur Berechnung benötigen.", + "generate": "Hash erzeugen", + "generating": "Hashen…", + "resultLabel": "Bcrypt-Hash", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Aufbau des Hashes", + "algorithm": "Algorithmus", + "cost": "Kosten", + "salt": "Salt (22 Zeichen)", + "hash": "Hash (31 Zeichen)" + } + }, + "verify": { + "passwordLabel": "Passwort", + "passwordPlaceholder": "Zu prüfendes Passwort eingeben…", + "hashLabel": "Bcrypt-Hash", + "hashPlaceholder": "$2b$10$…", + "idle": "Gib ein Passwort und einen Bcrypt-Hash ein, um sie zu vergleichen.", + "invalidHash": "Das sieht nicht nach einem gültigen Bcrypt-Hash aus.", + "checking": "Vergleiche…", + "match": "Übereinstimmung – das Passwort erzeugt diesen Hash.", + "noMatch": "Keine Übereinstimmung – das Passwort erzeugt diesen Hash nicht." + } + }, + "ChmodCalculator": { + "title": "Chmod-Rechner", + "subtitle": "Unix-Dateiberechtigungen zwischen Kontrollkästchen, Oktal und symbolischer Notation umrechnen", + "grid": { + "title": "Berechtigungen", + "read": "Lesen", + "write": "Schreiben", + "execute": "Ausführen", + "owner": "Eigentümer", + "group": "Gruppe", + "others": "Andere" + }, + "special": { + "title": "Spezielle Bits", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Oktal", + "placeholder": "755 oder 4755", + "invalid": "Gib 3 oder 4 Oktalziffern (0-7) ein." + }, + "symbolic": { + "label": "Symbolisch", + "placeholder": "rwxr-xr-x", + "invalid": "Gib 9 Zeichen wie rwxr-xr-x ein (s/S, t/T erlaubt)." + }, + "output": { + "title": "Befehl", + "copy": "Kopieren", + "copied": "Kopiert" + }, + "explain": { + "line": "{cls} kann {perms}.", + "none": "{cls} hat keine Berechtigungen.", + "setuid": "Setuid: Die Datei läuft mit den Rechten ihres Eigentümers.", + "setgid": "Setgid: Die Datei läuft mit den Rechten ihrer Gruppe (neue Dateien in einem Verzeichnis erben dessen Gruppe).", + "sticky": "Sticky: Nur der Eigentümer einer Datei kann sie in diesem Verzeichnis löschen oder umbenennen." + }, + "presets": { + "title": "Gängige Voreinstellungen", + "octal": "Oktal", + "permissions": "Berechtigungen", + "description": "Typische Verwendung", + "p644": "Normale Dateien: Eigentümer schreibt, alle lesen.", + "p755": "Ausführbare Dateien und Verzeichnisse: alle können lesen und betreten.", + "p600": "Private Dateien: nur der Eigentümer liest und schreibt.", + "p700": "Private ausführbare Dateien und Verzeichnisse: nur der Eigentümer.", + "p664": "Für die Gruppe schreibbare Dateien.", + "p775": "Für die Gruppe schreibbare Verzeichnisse und ausführbare Dateien.", + "p400": "Nur Lesezugriff für den Eigentümer (z. B. private SSH-Schlüssel)." + } + }, + "CodeScreenshot": { + "title": "Code-Screenshot", + "subtitle": "Code in schöne, teilbare Bilder verwandeln", + "languageLabel": "Sprache", + "autoDetect": "Automatisch erkennen", + "backgroundLabel": "Hintergrund", + "paddingLabel": "Innenabstand", + "filenameLabel": "Dateiname", + "filenamePlaceholder": "example.ts", + "windowLabel": "Fensterpunkte", + "themeLabel": "Fenster-Theme", + "themeDark": "Dunkel", + "themeLight": "Hell", + "scaleLabel": "Export-Skalierung", + "copyImage": "Bild kopieren", + "copied": "Kopiert", + "exportPng": "PNG exportieren", + "inputLabel": "Code", + "detected": "Erkannt: {language}", + "clear": "Leeren", + "placeholder": "Füge deinen Code hier ein…", + "previewLabel": "Vorschau", + "emptyPreview": "Die Vorschau deines Codes erscheint hier", + "exportFailed": "Bild konnte nicht exportiert werden", + "copyUnsupported": "Das Kopieren von Bildern wird in diesem Browser nicht unterstützt", + "copyFailed": "Bild konnte nicht kopiert werden", + "backgrounds": { + "ocean": "Ozean", + "sunset": "Sonnenuntergang", + "candy": "Bonbon", + "forest": "Wald", + "midnight": "Mitternacht", + "gold": "Gold", + "aurora": "Polarlicht", + "slate": "Schiefer", + "transparent": "Transparent" + } + }, + "CssGenerators": { + "title": "CSS-Generatoren", + "subtitle": "Box-shadow, border-radius und fluides clamp() – kopiere das CSS.", + "preview": "Vorschau", + "output": "CSS", + "copy": "Kopieren", + "copied": "Kopiert", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Fluides clamp()" + }, + "boxShadow": { + "presets": "Voreinstellungen", + "layer": "Ebene", + "addLayer": "Ebene hinzufügen", + "removeLayer": "Ebene entfernen", + "x": "X", + "y": "Y", + "blur": "Weichzeichnen", + "spread": "Ausbreitung", + "color": "Farbe", + "alpha": "Alpha", + "inset": "Innen", + "presetNames": { + "subtleCard": "Dezente Karte", + "elevated": "Erhöht", + "hardOffset": "Harter Versatz", + "neonGlow": "Neon-Leuchten", + "inner": "Innen", + "layeredSmooth": "Weich geschichtet" + } + }, + "borderRadius": { + "modeLabel": "Modus", + "modes": { + "simple": "Pro Ecke", + "blob": "Organischer Blob" + }, + "unit": "Einheit", + "linkCorners": "Ecken verknüpfen", + "randomize": "Zufällig", + "horizontal": "Horizontale Radien", + "vertical": "Vertikale Radien", + "corners": { + "tl": "Oben links", + "tr": "Oben rechts", + "br": "Unten rechts", + "bl": "Unten links" + } + }, + "clamp": { + "minFont": "Min. Schrift (px)", + "maxFont": "Max. Schrift (px)", + "minViewport": "Min. Viewport (px)", + "maxViewport": "Max. Viewport (px)", + "rootFont": "Wurzelschrift (px)", + "viewportWidth": "Simuliertes Viewport", + "computedSize": "Berechnete Größe", + "slope": "Steigung", + "intercept": "Achsenabschnitt", + "previewText": "Franz jagt im Taxi quer durch Bayern", + "invalid": "Gib gültige, unterschiedliche Viewport-Grenzen ein." + } + }, + "ExifViewer": { + "title": "EXIF-Viewer & -Entferner", + "subtitle": "Foto-Metadaten prüfen und in deinem Browser entfernen", + "dropTitle": "Ein Bild hier ablegen", + "dropHint": "oder zum Durchsuchen klicken – dein Bild verlässt nie dein Gerät", + "browse": "Bild auswählen", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Metadaten werden gelesen…", + "parseFailed": "Dieses Bild konnte nicht gelesen werden", + "clear": "Bild entfernen", + "gpsWarning": "Enthält GPS-Standort", + "openMap": "Auf OpenStreetMap ansehen", + "noMetadata": "In diesem Bild wurden keine eingebetteten Metadaten gefunden.", + "facts": { + "name": "Name", + "type": "Typ", + "dimensions": "Abmessungen", + "size": "Größe" + }, + "groups": { + "camera": "Kamera", + "exposure": "Belichtung", + "timestamps": "Zeitstempel", + "gps": "GPS-Standort", + "software": "Software & Sonstiges" + }, + "gpsCard": { + "latitude": "Breitengrad", + "longitude": "Längengrad", + "altitude": "Höhe" + }, + "raw": { + "title": "Alle Metadaten", + "copyJson": "JSON kopieren", + "copied": "Kopiert" + }, + "remove": { + "title": "Metadaten entfernen", + "jpegDescription": "EXIF, XMP, IPTC und Kommentare verlustfrei entfernen, ohne das Bild neu zu komprimieren.", + "reencodeDescription": "Das Bild neu codieren, um eingebettete Metadaten zu entfernen. Dabei werden die Pixel neu komprimiert.", + "keepIcc": "Farbprofil (ICC) beibehalten", + "stripButton": "Metadaten entfernen", + "reencodeButton": "Neu codieren & entfernen", + "unsupported": "Das Entfernen von Metadaten wird für diesen Dateityp nicht unterstützt.", + "failed": "Metadaten konnten nicht entfernt werden", + "before": "Vorher", + "after": "Nachher", + "download": "Bereinigtes Bild herunterladen" + } + }, + "FaviconGenerator": { + "title": "Favicon-Generator", + "subtitle": "Favicons und App-Icons aus einem einzigen Bild erstellen", + "dropTitle": "Ein Bild hier ablegen", + "dropHint": "PNG, JPEG oder SVG – zum Durchsuchen klicken", + "browse": "Bild auswählen", + "clear": "Bild entfernen", + "backgroundLabel": "Hintergrund", + "transparent": "Transparent", + "solidColor": "Einfarbig", + "cornerLabel": "Eckenform", + "cornerNone": "Quadratisch", + "cornerRounded": "Abgerundet", + "cornerCircle": "Kreis", + "maskPaddingLabel": "Maskable-Abstand", + "maskable": "maskable", + "previewLabel": "Vorschau", + "copy": "Kopieren", + "headSnippet": "HTML-head-Snippet", + "downloadIco": "favicon.ico herunterladen", + "downloadZip": "Alles herunterladen (.zip)" + }, + "JsonDiff": { + "title": "JSON-Diff", + "subtitle": "Semantischer, strukturbewusster Vergleich zweier JSON-Dokumente – Schlüsselreihenfolge und Formatierung werden ignoriert", + "options": { + "ignoreArrayOrder": "Array-Reihenfolge ignorieren" + }, + "loadBothSamples": "Beispiele laden", + "sample": "Beispiel laden", + "format": "Formatieren", + "clear": "Leeren", + "formatError": "Formatieren nicht möglich: Die Eingabe ist kein gültiges JSON", + "placeholder": "JSON hier einfügen oder eingeben…", + "panels": { + "left": "Original (links)", + "right": "Geändert (rechts)", + "results": "Unterschiede" + }, + "summary": { + "added": "{count} hinzugefügt", + "removed": "{count} entfernt", + "changed": "{count} geändert" + }, + "types": { + "added": "Hinzugefügt", + "removed": "Entfernt", + "changed": "Geändert" + }, + "copyChanges": "Als JSON kopieren", + "copied": "Kopiert", + "empty": "Füge JSON in beide Panels ein, um ihre Unterschiede zu sehen.", + "identical": "Die beiden Dokumente sind semantisch identisch." + }, + "JsonpathPlayground": { + "title": "JSONPath-Playground", + "subtitle": "JSON-Dokumente mit JSONPath oder JMESPath abfragen und Ergebnisse live sehen", + "engineLabel": "Engine", + "queryLabel": "Abfrage", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Spickzettel", + "cheats": { + "jpAllTitles": "Alle Buchtitel", + "jpAllAuthors": "Jeder Autor, überall im Dokument", + "jpFirstBook": "Erstes Buch", + "jpLastBook": "Letztes Buch", + "jpSlice": "Die ersten zwei Bücher (Slice)", + "jpFilterPrice": "Bücher günstiger als 40", + "jpFilterInStock": "Titel der vorrätigen Bücher", + "jpRecursive": "Alle Werte, rekursiv", + "jmAllTitles": "Alle Buchtitel", + "jmFirstAuthor": "Autor des ersten Buches", + "jmLastBook": "Letztes Buch", + "jmSlice": "Die ersten zwei Bücher (Slice)", + "jmFilterPrice": "Titel der Bücher günstiger als 40", + "jmCount": "Vorrätige Bücher zählen", + "jmProjection": "Jedes Buch in ein neues Objekt umformen", + "jmSort": "Nach Preis sortierte Titel" + }, + "panels": { + "input": "JSON-Eingabe", + "results": "Ergebnisse" + }, + "sample": "Beispiel laden", + "format": "Formatieren", + "clear": "Leeren", + "formatError": "Formatieren nicht möglich: Die Eingabe ist kein gültiges JSON", + "placeholder": "JSON hier einfügen oder eingeben…", + "outputPlaceholder": "Treffer erscheinen hier…", + "matchCount": "{count, plural, one {# Treffer} other {# Treffer}}", + "copy": "Kopieren", + "copied": "Kopiert" + }, + "KeycodeInspector": { + "title": "Keycode-Inspektor", + "subtitle": "Drücke eine beliebige Taste, um ihr JavaScript-Tastaturevent zu untersuchen", + "capture": { + "prompt": "Drücke eine beliebige Taste…", + "ready": "Höre zu – drücke eine beliebige Taste auf deiner Tastatur", + "clickToFocus": "Klicke hier, um zu fokussieren, und drücke dann eine beliebige Taste" + }, + "hero": { + "spaceKey": "(Leertaste)", + "keyLabel": "event.key", + "repeat": "Wiederholung" + }, + "copyJson": "JSON kopieren", + "copiedJson": "Event-JSON in die Zwischenablage kopiert", + "deprecated": "veraltet", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standard", + "left": "Links", + "right": "Rechts", + "numpad": "Ziffernblock", + "unknown": "Unbekannt" + }, + "history": { + "title": "Verlauf", + "clear": "Verlauf löschen", + "empty": "Tastendrücke erscheinen hier." + } + }, + "MarkdownTableGenerator": { + "title": "Markdown-Tabellen-Generator", + "subtitle": "Ein Raster bearbeiten, aus einer Tabellenkalkulation einfügen und eine hübsch ausgerichtete Markdown- oder HTML-Tabelle kopieren", + "addRow": "Zeile hinzufügen", + "addColumn": "Spalte hinzufügen", + "importLabel": "Importieren", + "importPlaceholder": "TSV (aus Excel / Google Sheets) oder CSV hier einfügen…", + "importButton": "Ins Raster importieren", + "pasteClipboard": "Aus Zwischenablage einfügen", + "importEmpty": "Nichts zu importieren – füge zuerst ein paar Zeilen ein", + "clipboardError": "Zwischenablage konnte nicht gelesen werden", + "clear": "Leeren", + "panels": { + "editor": "Tabelleneditor", + "output": "Ausgabe" + }, + "align": { + "left": "Linksbündig", + "center": "Zentriert", + "right": "Rechtsbündig" + }, + "headerPlaceholder": "Kopfzeile", + "cellPlaceholder": "Zelle", + "removeRow": "Zeile entfernen", + "removeColumn": "Spalte entfernen", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Kopieren", + "copied": "Kopiert" + }, + "TimezoneConverter": { + "title": "Zeitzonen-Konverter", + "subtitle": "Einen Moment weltweit umrechnen, um Meetings zu planen.", + "dateTimeLabel": "Datum & Uhrzeit", + "now": "Jetzt", + "sourceZoneLabel": "Ausgangszeitzone", + "selectZone": "Zone auswählen", + "searchZone": "Zeitzonen suchen...", + "noZone": "Keine Zeitzone gefunden.", + "scrubLabel": "Zeit verschieben", + "reset": "Zurücksetzen", + "targetsLabel": "Zielzonen", + "addZone": "Zone hinzufügen", + "remove": "Entfernen", + "localBadge": "Lokal", + "invalidDate": "Gib ein gültiges Datum und eine gültige Uhrzeit ein.", + "bands": { + "work": "Arbeitszeiten (09:00–18:00)", + "edge": "Randzeiten (früh / abends)", + "night": "Nacht" + } + }, + "TokenCounter": { + "title": "LLM-Token-Zähler", + "subtitle": "Tokens zählen, Chunks visualisieren und API-Kosten schätzen.", + "inputLabel": "Text", + "sample": "Beispiel laden", + "clear": "Leeren", + "placeholder": "Text, Code oder JSON einfügen, um Tokens zu zählen...", + "encoding": "Codierung", + "loading": "Tokenizer wird geladen...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-Serie)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Zeichen", + "words": "Wörter", + "ratio": "Tokens/Wort", + "ratioSub": "ca." + }, + "claudeRow": "Claude (näherungsweise)", + "estimate": "Schätzung", + "viz": { + "title": "Token-Visualisierung", + "toggle": "Anzeigen", + "hint": "Umschalten, um jedes Token als Chunk einzufärben.", + "empty": "Gib Text ein, um Tokens zu visualisieren.", + "truncated": "Zeigt die ersten {limit} von {total} Tokens." + }, + "cost": { + "title": "Kostenrechner", + "disclaimer": "editierbare Standardwerte – aktuelle Preise prüfen", + "model": "Modell", + "expectedOutput": "Erwartete Ausgabe-Tokens", + "customInput": "Eingabe $/MTok", + "customOutput": "Ausgabe $/MTok", + "inputCost": "Eingabekosten", + "outputCost": "Ausgabekosten", + "total": "Gesamt", + "per1000": "Pro 1000 Aufrufe" + } + }, + "WebhookTester": { + "title": "Webhook-Tester", + "subtitle": "Eine temporäre URL erstellen und eingehende Webhook-Anfragen live untersuchen.", + "intro": { + "title": "Erstelle eine Webhook-URL, um Anfragen zu erfassen", + "hint": "Du erhältst eine nicht erratbare URL, die jede eingehende Anfrage aufzeichnet. Sie läuft nach 24 Stunden ab." + }, + "createBtn": "Webhook-URL erstellen", + "creating": "Wird erstellt…", + "urlLabel": "Deine Webhook-URL", + "expiresIn": "Läuft ab in {countdown}", + "copyUrlBtn": "URL kopieren", + "copied": "Kopiert", + "clearBtn": "Leeren", + "newBinBtn": "Neue URL", + "requestsTitle": "Anfragen", + "selectPrompt": "Wähle eine Anfrage aus, um ihre Details zu untersuchen.", + "expiredNotice": "Diese Webhook-URL ist abgelaufen. Erstelle eine neue, um weiter zu testen.", + "base64Badge": "base64", + "truncatedBadge": "gekürzt", + "empty": { + "title": "Warte auf Anfragen…", + "hint": "Sende eine Anfrage an deine URL und sie erscheint hier sofort. Probiere das:" + }, + "detail": { + "receivedAt": "Empfangen", + "source": "Quell-IP", + "contentType": "Content-Type", + "query": "Query-Parameter", + "headers": "Header", + "body": "Body", + "copyBody": "Body kopieren", + "emptyBody": "Kein Body" + }, + "errors": { + "createFailed": "Webhook-URL konnte nicht erstellt werden. Bitte versuche es erneut.", + "clearFailed": "Anfragen konnten nicht gelöscht werden. Bitte versuche es erneut." + } + }, + "WebsocketTester": { + "title": "WebSocket-Tester", + "subtitle": "Mit einem WebSocket-Endpunkt verbinden und Nachrichten live senden, empfangen und untersuchen", + "urlLabel": "Endpunkt-URL", + "protocolsLabel": "Subprotokolle", + "protocolsPlaceholder": "optional, durch Kommas getrennt", + "connect": "Verbinden", + "disconnect": "Trennen", + "status": { + "disconnected": "Getrennt", + "connecting": "Verbinde", + "connected": "Verbunden", + "error": "Fehler" + }, + "messages": { + "connecting": "Verbinde mit {url}…", + "connected": "Verbindung hergestellt", + "connectedWithProtocol": "Verbindung hergestellt (Protokoll: {protocol})", + "invalidUrl": "Ungültige URL – muss mit ws:// oder wss:// beginnen", + "connectFailed": "Verbindung konnte nicht geöffnet werden", + "socketError": "Es ist ein Verbindungsfehler aufgetreten", + "notConnected": "Nicht verbunden – zuerst verbinden", + "binaryMessage": "Eine Binärnachricht empfangen" + }, + "log": { + "title": "Nachrichtenprotokoll", + "empty": "Noch keine Nachrichten. Verbinde dich und beginne zu senden.", + "copyAll": "Alle kopieren", + "download": "Als .txt herunterladen", + "clear": "Protokoll leeren", + "prettyPrint": "JSON formatieren", + "showRaw": "Rohtext anzeigen" + }, + "composer": { + "placeholder": "Eine Nachricht zum Senden eingeben…", + "send": "Senden", + "formatJson": "Als JSON formatieren", + "invalidJson": "Die Nachricht ist kein gültiges JSON", + "hint": "Cmd/Ctrl+Enter zum Senden" + }, + "ping": { + "label": "Auto-Ping", + "seconds": "Sek.", + "intervalTitle": "Ping-Intervall in Sekunden", + "payloadPlaceholder": "Ping-Payload" + } + }, + "WhoisLookup": { + "title": "Whois-Abfrage", + "subtitle": "Registrierungsdaten einer beliebigen Domain oder IP-Adresse per RDAP abrufen", + "placeholder": "example.com oder 8.8.8.8", + "lookupBtn": "Abfragen", + "looking": "Abfrage läuft…", + "refreshBtn": "Aktualisieren", + "tryLabel": "Probiere:", + "copy": "Kopieren", + "copied": "Kopiert", + "notAvailable": "Nicht verfügbar", + "summary": { + "registrar": "Registrar", + "network": "Netzwerk" + }, + "dates": { + "created": "Erstellt", + "updated": "Aktualisiert", + "expires": "Läuft ab" + }, + "sections": { + "status": "Status", + "nameservers": "Nameserver", + "contacts": "Kontakte", + "rawRdap": "Rohes RDAP" + }, + "contacts": { + "roles": "Rollen", + "name": "Name", + "organization": "Organisation" + }, + "errors": { + "invalidTarget": "Gib einen gültigen Domainnamen oder eine gültige IP-Adresse ein", + "lookupFailed": "Abfrage fehlgeschlagen (Status {status})" + } } } diff --git a/apps/web/messages/el.json b/apps/web/messages/el.json index ccc67057..535906f6 100644 --- a/apps/web/messages/el.json +++ b/apps/web/messages/el.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Υπολογιστής chmod", + "codeScreenshot": "Στιγμιότυπο κώδικα", + "cssGenerators": "Γεννήτριες CSS", + "exifViewer": "Προβολή EXIF", + "faviconGenerator": "Γεννήτρια favicon", + "jsonDiff": "Σύγκριση JSON", + "jsonpathPlayground": "Παιχνιδότοπος JSONPath", + "keycodeInspector": "Επιθεωρητής πλήκτρων", + "markdownTableGenerator": "Πίνακας Markdown", + "timezoneConverter": "Μετατροπέας ζωνών ώρας", + "tokenCounter": "Μετρητής token", + "webhookTester": "Δοκιμαστής webhook", + "websocketTester": "Δοκιμαστής WebSocket", + "whoisLookup": "Αναζήτηση Whois" }, "Help": { "title": "Βοήθεια και τεκμηρίωση", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Γεννήτρια και ελεγκτής Bcrypt", + "description": "Κατακερματίστε κωδικούς με bcrypt και επαληθεύστε υπάρχοντες κατακερματισμούς." + }, + "chmodCalculator": { + "title": "Υπολογιστής chmod", + "description": "Μετατρέψτε δικαιώματα Unix μεταξύ οκταδικής, συμβολικής μορφής και πλαισίων ελέγχου." + }, + "codeScreenshot": { + "title": "Στιγμιότυπο κώδικα", + "description": "Μετατρέψτε τον πηγαίο κώδικα σε όμορφες εικόνες για κοινή χρήση." + }, + "cssGenerators": { + "title": "Γεννήτριες CSS", + "description": "Γεννήτριες box-shadow, border-radius και ρευστού clamp()." + }, + "exifViewer": { + "title": "Προβολή και αφαίρεση EXIF", + "description": "Επιθεωρήστε και αφαιρέστε τα μεταδεδομένα EXIF φωτογραφιών τοπικά." + }, + "faviconGenerator": { + "title": "Γεννήτρια favicon", + "description": "Δημιουργήστε favicons, εικονίδια εφαρμογών και αποσπάσματα manifest." + }, + "jsonDiff": { + "title": "Σύγκριση JSON", + "description": "Σημασιολογική σύγκριση δύο εγγράφων JSON με επίγνωση της δομής." + }, + "jsonpathPlayground": { + "title": "Παιχνιδότοπος JSONPath", + "description": "Κάντε ερωτήματα σε JSON με JSONPath ή JMESPath και δείτε ζωντανά αποτελέσματα." + }, + "keycodeInspector": { + "title": "Επιθεωρητής πλήκτρων", + "description": "Πατήστε ένα πλήκτρο για να δείτε τους κωδικούς συμβάντων JavaScript και τους τροποποιητές του." + }, + "markdownTableGenerator": { + "title": "Γεννήτρια πινάκων Markdown", + "description": "Δημιουργήστε πίνακες Markdown και HTML σε έναν επεξεργαστή πλέγματος τύπου υπολογιστικού φύλλου." + }, + "timezoneConverter": { + "title": "Μετατροπέας ζωνών ώρας", + "description": "Μετατρέψτε μια ώρα σε πολλές ζώνες με ενδείξεις μετατόπισης ημέρας και ωραρίου εργασίας." + }, + "tokenCounter": { + "title": "Μετρητής token LLM", + "description": "Μετρήστε token LLM, οπτικοποιήστε τμήματα και εκτιμήστε το κόστος API." + }, + "webhookTester": { + "title": "Δοκιμαστής webhook", + "description": "Καταγράψτε και επιθεωρήστε εισερχόμενα αιτήματα webhook σε πραγματικό χρόνο." + }, + "websocketTester": { + "title": "Δοκιμαστής WebSocket", + "description": "Συνδεθείτε σε άκρα WebSocket και ανταλλάξτε μηνύματα σε πραγματικό χρόνο." + }, + "whoisLookup": { + "title": "Αναζήτηση Whois", + "description": "Αναζητήστε στοιχεία καταχώρισης τομέα και IP μέσω RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Γεννήτρια και ελεγκτής Bcrypt", + "subtitle": "Κατακερματίστε κωδικούς με bcrypt και επαληθεύστε κατακερματισμούς — όλα παραμένουν στο πρόγραμμα περιήγησής σας", + "tabs": { + "hash": "Κατακερματισμός", + "verify": "Επαλήθευση" + }, + "showPassword": "Εμφάνιση κωδικού", + "hidePassword": "Απόκρυψη κωδικού", + "copy": "Αντιγραφή", + "copied": "Αντιγράφηκε", + "hash": { + "passwordLabel": "Κωδικός", + "passwordPlaceholder": "Εισαγάγετε έναν κωδικό για κατακερματισμό…", + "costLabel": "Συντελεστής κόστους", + "costHint": "Υψηλότερο κόστος διπλασιάζει την εργασία ανά βήμα. Το 10–12 είναι συνήθης επιλογή για παραγωγή.", + "slowWarning": "Συντελεστές κόστους 13 και άνω μπορεί να χρειαστούν αρκετά δευτερόλεπτα για υπολογισμό.", + "generate": "Δημιουργία κατακερματισμού", + "generating": "Κατακερματισμός…", + "resultLabel": "Κατακερματισμός Bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Ανατομία κατακερματισμού", + "algorithm": "αλγόριθμος", + "cost": "κόστος", + "salt": "salt (22 χαρακτήρες)", + "hash": "hash (31 χαρακτήρες)" + } + }, + "verify": { + "passwordLabel": "Κωδικός", + "passwordPlaceholder": "Εισαγάγετε τον κωδικό προς έλεγχο…", + "hashLabel": "Κατακερματισμός Bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Εισαγάγετε έναν κωδικό και έναν κατακερματισμό bcrypt για να τους συγκρίνετε.", + "invalidHash": "Αυτό δεν φαίνεται να είναι έγκυρος κατακερματισμός bcrypt.", + "checking": "Σύγκριση…", + "match": "Ταιριάζει — ο κωδικός παράγει αυτόν τον κατακερματισμό.", + "noMatch": "Δεν ταιριάζει — ο κωδικός δεν παράγει αυτόν τον κατακερματισμό." + } + }, + "ChmodCalculator": { + "title": "Υπολογιστής chmod", + "subtitle": "Μετατρέψτε δικαιώματα αρχείων Unix μεταξύ πλαισίων ελέγχου, οκταδικής και συμβολικής σημειογραφίας", + "grid": { + "title": "Δικαιώματα", + "read": "Ανάγνωση", + "write": "Εγγραφή", + "execute": "Εκτέλεση", + "owner": "Ιδιοκτήτης", + "group": "Ομάδα", + "others": "Άλλοι" + }, + "special": { + "title": "Ειδικά bit", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Οκταδικό", + "placeholder": "755 ή 4755", + "invalid": "Εισαγάγετε 3 ή 4 οκταδικά ψηφία (0–7)." + }, + "symbolic": { + "label": "Συμβολικό", + "placeholder": "rwxr-xr-x", + "invalid": "Εισαγάγετε 9 χαρακτήρες όπως rwxr-xr-x (επιτρέπονται s/S, t/T)." + }, + "output": { + "title": "Εντολή", + "copy": "Αντιγραφή", + "copied": "Αντιγράφηκε" + }, + "explain": { + "line": "{cls} μπορεί {perms}.", + "none": "{cls} δεν έχει δικαιώματα.", + "setuid": "Setuid: το αρχείο εκτελείται με τα προνόμια του ιδιοκτήτη του.", + "setgid": "Setgid: το αρχείο εκτελείται με τα προνόμια της ομάδας του (νέα αρχεία σε έναν κατάλογο κληρονομούν την ομάδα του).", + "sticky": "Sticky: μόνο ο ιδιοκτήτης ενός αρχείου μπορεί να το διαγράψει ή να το μετονομάσει μέσα σε αυτόν τον κατάλογο." + }, + "presets": { + "title": "Κοινές προεπιλογές", + "octal": "Οκταδικό", + "permissions": "Δικαιώματα", + "description": "Τυπική χρήση", + "p644": "Κανονικά αρχεία: ο ιδιοκτήτης γράφει, όλοι διαβάζουν.", + "p755": "Εκτελέσιμα και κατάλογοι: όλοι μπορούν να διαβάσουν και να εισέλθουν.", + "p600": "Ιδιωτικά αρχεία: μόνο ο ιδιοκτήτης διαβάζει και γράφει.", + "p700": "Ιδιωτικά εκτελέσιμα και κατάλογοι: μόνο ο ιδιοκτήτης.", + "p664": "Αρχεία εγγράψιμα από την ομάδα.", + "p775": "Κατάλογοι και εκτελέσιμα εγγράψιμα από την ομάδα.", + "p400": "Μόνο για ανάγνωση από τον ιδιοκτήτη (π.χ. ιδιωτικά κλειδιά SSH)." + } + }, + "CodeScreenshot": { + "title": "Στιγμιότυπο κώδικα", + "subtitle": "Μετατρέψτε τον κώδικα σε όμορφες εικόνες για κοινή χρήση", + "languageLabel": "Γλώσσα", + "autoDetect": "Αυτόματη ανίχνευση", + "backgroundLabel": "Φόντο", + "paddingLabel": "Εσωτερικό περιθώριο", + "filenameLabel": "Όνομα αρχείου", + "filenamePlaceholder": "example.ts", + "windowLabel": "Κουκκίδες παραθύρου", + "themeLabel": "Θέμα παραθύρου", + "themeDark": "Σκούρο", + "themeLight": "Ανοιχτό", + "scaleLabel": "Κλίμακα εξαγωγής", + "copyImage": "Αντιγραφή εικόνας", + "copied": "Αντιγράφηκε", + "exportPng": "Εξαγωγή PNG", + "inputLabel": "Κώδικας", + "detected": "Ανιχνεύθηκε: {language}", + "clear": "Καθαρισμός", + "placeholder": "Επικολλήστε τον κώδικά σας εδώ…", + "previewLabel": "Προεπισκόπηση", + "emptyPreview": "Η προεπισκόπηση του κώδικά σας θα εμφανιστεί εδώ", + "exportFailed": "Η εξαγωγή της εικόνας απέτυχε", + "copyUnsupported": "Η αντιγραφή εικόνων δεν υποστηρίζεται σε αυτό το πρόγραμμα περιήγησης", + "copyFailed": "Η αντιγραφή της εικόνας απέτυχε", + "backgrounds": { + "ocean": "Ωκεανός", + "sunset": "Ηλιοβασίλεμα", + "candy": "Καραμέλα", + "forest": "Δάσος", + "midnight": "Μεσάνυχτα", + "gold": "Χρυσό", + "aurora": "Σέλας", + "slate": "Σχιστόλιθος", + "transparent": "Διαφανές" + } + }, + "CssGenerators": { + "title": "Γεννήτριες CSS", + "subtitle": "Box-shadow, border-radius και ρευστό clamp() — αντιγράψτε το CSS.", + "preview": "Προεπισκόπηση", + "output": "CSS", + "copy": "Αντιγραφή", + "copied": "Αντιγράφηκε", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Ρευστό clamp()" + }, + "boxShadow": { + "presets": "Προεπιλογές", + "layer": "Επίπεδο", + "addLayer": "Προσθήκη επιπέδου", + "removeLayer": "Αφαίρεση επιπέδου", + "x": "X", + "y": "Y", + "blur": "Θόλωση", + "spread": "Διάχυση", + "color": "Χρώμα", + "alpha": "Άλφα", + "inset": "Εσωτερικό", + "presetNames": { + "subtleCard": "Διακριτική κάρτα", + "elevated": "Ανυψωμένο", + "hardOffset": "Έντονη μετατόπιση", + "neonGlow": "Λάμψη νέον", + "inner": "Εσωτερικό", + "layeredSmooth": "Πολυεπίπεδο απαλό" + } + }, + "borderRadius": { + "modeLabel": "Λειτουργία", + "modes": { + "simple": "Ανά γωνία", + "blob": "Οργανικός λεκές" + }, + "unit": "Μονάδα", + "linkCorners": "Σύνδεση γωνιών", + "randomize": "Τυχαιοποίηση", + "horizontal": "Οριζόντιες ακτίνες", + "vertical": "Κατακόρυφες ακτίνες", + "corners": { + "tl": "Πάνω αριστερά", + "tr": "Πάνω δεξιά", + "br": "Κάτω δεξιά", + "bl": "Κάτω αριστερά" + } + }, + "clamp": { + "minFont": "Ελάχ. γραμματοσειρά (px)", + "maxFont": "Μέγ. γραμματοσειρά (px)", + "minViewport": "Ελάχ. viewport (px)", + "maxViewport": "Μέγ. viewport (px)", + "rootFont": "Ριζική γραμματοσειρά (px)", + "viewportWidth": "Προσομοιωμένο viewport", + "computedSize": "Υπολογισμένο μέγεθος", + "slope": "Κλίση", + "intercept": "Τεταγμένη επί την αρχή", + "previewText": "Ξεσκεπάζω την ψυχοφθόρα βδελυγμία", + "invalid": "Εισαγάγετε έγκυρα, διαφορετικά όρια viewport." + } + }, + "ExifViewer": { + "title": "Προβολή και αφαίρεση EXIF", + "subtitle": "Επιθεωρήστε τα μεταδεδομένα φωτογραφιών και αφαιρέστε τα στο πρόγραμμα περιήγησής σας", + "dropTitle": "Αποθέστε μια εικόνα εδώ", + "dropHint": "ή κάντε κλικ για αναζήτηση — η εικόνα σας δεν φεύγει ποτέ από τη συσκευή σας", + "browse": "Επιλογή εικόνας", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Ανάγνωση μεταδεδομένων…", + "parseFailed": "Η ανάγνωση αυτής της εικόνας απέτυχε", + "clear": "Αφαίρεση εικόνας", + "gpsWarning": "Περιέχει τοποθεσία GPS", + "openMap": "Προβολή στο OpenStreetMap", + "noMetadata": "Δεν βρέθηκαν ενσωματωμένα μεταδεδομένα σε αυτήν την εικόνα.", + "facts": { + "name": "Όνομα", + "type": "Τύπος", + "dimensions": "Διαστάσεις", + "size": "Μέγεθος" + }, + "groups": { + "camera": "Κάμερα", + "exposure": "Έκθεση", + "timestamps": "Χρονικές σημάνσεις", + "gps": "Τοποθεσία GPS", + "software": "Λογισμικό και άλλα" + }, + "gpsCard": { + "latitude": "Γεωγραφικό πλάτος", + "longitude": "Γεωγραφικό μήκος", + "altitude": "Υψόμετρο" + }, + "raw": { + "title": "Όλα τα μεταδεδομένα", + "copyJson": "Αντιγραφή JSON", + "copied": "Αντιγράφηκε" + }, + "remove": { + "title": "Αφαίρεση μεταδεδομένων", + "jpegDescription": "Αφαιρέστε χωρίς απώλειες EXIF, XMP, IPTC και σχόλια χωρίς επανασυμπίεση της εικόνας.", + "reencodeDescription": "Επανακωδικοποιήστε την εικόνα για να αφαιρεθούν τα ενσωματωμένα μεταδεδομένα. Αυτό επανασυμπιέζει τα pixel.", + "keepIcc": "Διατήρηση προφίλ χρώματος (ICC)", + "stripButton": "Αφαίρεση μεταδεδομένων", + "reencodeButton": "Επανακωδικοποίηση και αφαίρεση", + "unsupported": "Η αφαίρεση μεταδεδομένων δεν υποστηρίζεται για αυτόν τον τύπο αρχείου.", + "failed": "Η αφαίρεση μεταδεδομένων απέτυχε", + "before": "Πριν", + "after": "Μετά", + "download": "Λήψη καθαρισμένης εικόνας" + } + }, + "FaviconGenerator": { + "title": "Γεννήτρια favicon", + "subtitle": "Δημιουργήστε favicons και εικονίδια εφαρμογών από μία εικόνα", + "dropTitle": "Αποθέστε μια εικόνα εδώ", + "dropHint": "PNG, JPEG ή SVG — κάντε κλικ για αναζήτηση", + "browse": "Επιλογή εικόνας", + "clear": "Αφαίρεση εικόνας", + "backgroundLabel": "Φόντο", + "transparent": "Διαφανές", + "solidColor": "Συμπαγές", + "cornerLabel": "Σχήμα γωνίας", + "cornerNone": "Τετράγωνο", + "cornerRounded": "Στρογγυλεμένο", + "cornerCircle": "Κύκλος", + "maskPaddingLabel": "Περιθώριο maskable", + "maskable": "maskable", + "previewLabel": "Προεπισκόπηση", + "copy": "Αντιγραφή", + "headSnippet": "Απόσπασμα HTML head", + "downloadIco": "Λήψη favicon.ico", + "downloadZip": "Λήψη όλων (.zip)" + }, + "JsonDiff": { + "title": "Σύγκριση JSON", + "subtitle": "Σημασιολογική σύγκριση δύο εγγράφων JSON με επίγνωση της δομής — η σειρά κλειδιών και η μορφοποίηση αγνοούνται", + "options": { + "ignoreArrayOrder": "Αγνόηση σειράς πινάκων" + }, + "loadBothSamples": "Φόρτωση δειγμάτων", + "sample": "Δείγμα", + "format": "Μορφοποίηση", + "clear": "Καθαρισμός", + "formatError": "Αδυναμία μορφοποίησης: η είσοδος δεν είναι έγκυρο JSON", + "placeholder": "Επικολλήστε ή πληκτρολογήστε JSON εδώ…", + "panels": { + "left": "Πρωτότυπο (αριστερά)", + "right": "Τροποποιημένο (δεξιά)", + "results": "Διαφορές" + }, + "summary": { + "added": "{count} προστέθηκαν", + "removed": "{count} αφαιρέθηκαν", + "changed": "{count} άλλαξαν" + }, + "types": { + "added": "Προστέθηκε", + "removed": "Αφαιρέθηκε", + "changed": "Άλλαξε" + }, + "copyChanges": "Αντιγραφή ως JSON", + "copied": "Αντιγράφηκε", + "empty": "Επικολλήστε JSON και στα δύο πλαίσια για να δείτε τις διαφορές τους.", + "identical": "Τα δύο έγγραφα είναι σημασιολογικά πανομοιότυπα." + }, + "JsonpathPlayground": { + "title": "Παιχνιδότοπος JSONPath", + "subtitle": "Κάντε ερωτήματα σε έγγραφα JSON με JSONPath ή JMESPath και δείτε ζωντανά αποτελέσματα", + "engineLabel": "Μηχανή", + "queryLabel": "Ερώτημα", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Σύντομος οδηγός", + "cheats": { + "jpAllTitles": "Όλοι οι τίτλοι βιβλίων", + "jpAllAuthors": "Κάθε συγγραφέας, οπουδήποτε στο έγγραφο", + "jpFirstBook": "Πρώτο βιβλίο", + "jpLastBook": "Τελευταίο βιβλίο", + "jpSlice": "Τα δύο πρώτα βιβλία (τμήμα)", + "jpFilterPrice": "Βιβλία φθηνότερα από 40", + "jpFilterInStock": "Τίτλοι διαθέσιμων βιβλίων", + "jpRecursive": "Όλες οι τιμές, αναδρομικά", + "jmAllTitles": "Όλοι οι τίτλοι βιβλίων", + "jmFirstAuthor": "Συγγραφέας του πρώτου βιβλίου", + "jmLastBook": "Τελευταίο βιβλίο", + "jmSlice": "Τα δύο πρώτα βιβλία (τμήμα)", + "jmFilterPrice": "Τίτλοι βιβλίων φθηνότερων από 40", + "jmCount": "Πλήθος διαθέσιμων βιβλίων", + "jmProjection": "Αναδιαμορφώστε κάθε βιβλίο σε νέο αντικείμενο", + "jmSort": "Τίτλοι ταξινομημένοι κατά τιμή" + }, + "panels": { + "input": "Είσοδος JSON", + "results": "Αποτελέσματα" + }, + "sample": "Δείγμα", + "format": "Μορφοποίηση", + "clear": "Καθαρισμός", + "formatError": "Αδυναμία μορφοποίησης: η είσοδος δεν είναι έγκυρο JSON", + "placeholder": "Επικολλήστε ή πληκτρολογήστε JSON εδώ…", + "outputPlaceholder": "Οι αντιστοιχίσεις θα εμφανιστούν εδώ…", + "matchCount": "{count, plural, one {# αντιστοίχιση} other {# αντιστοιχίσεις}}", + "copy": "Αντιγραφή", + "copied": "Αντιγράφηκε" + }, + "KeycodeInspector": { + "title": "Επιθεωρητής πλήκτρων", + "subtitle": "Πατήστε οποιοδήποτε πλήκτρο για να επιθεωρήσετε το συμβάν πληκτρολογίου JavaScript του", + "capture": { + "prompt": "Πατήστε οποιοδήποτε πλήκτρο…", + "ready": "Ακρόαση — πατήστε οποιοδήποτε πλήκτρο στο πληκτρολόγιό σας", + "clickToFocus": "Κάντε κλικ εδώ για εστίαση, μετά πατήστε οποιοδήποτε πλήκτρο" + }, + "hero": { + "spaceKey": "(Διάστημα)", + "keyLabel": "event.key", + "repeat": "επανάληψη" + }, + "copyJson": "Αντιγραφή JSON", + "copiedJson": "Το JSON του συμβάντος αντιγράφηκε στο πρόχειρο", + "deprecated": "καταργημένο", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Τυπική", + "left": "Αριστερά", + "right": "Δεξιά", + "numpad": "Αριθμητικό πληκτρολόγιο", + "unknown": "Άγνωστη" + }, + "history": { + "title": "Ιστορικό", + "clear": "Καθαρισμός ιστορικού", + "empty": "Τα πατήματα πλήκτρων θα εμφανιστούν εδώ." + } + }, + "MarkdownTableGenerator": { + "title": "Γεννήτρια πινάκων Markdown", + "subtitle": "Επεξεργαστείτε ένα πλέγμα, επικολλήστε από υπολογιστικό φύλλο και αντιγράψτε έναν όμορφα στοιχισμένο πίνακα Markdown ή HTML", + "addRow": "Προσθήκη γραμμής", + "addColumn": "Προσθήκη στήλης", + "importLabel": "Εισαγωγή", + "importPlaceholder": "Επικολλήστε TSV (από Excel / Φύλλα Google) ή CSV εδώ…", + "importButton": "Εισαγωγή στο πλέγμα", + "pasteClipboard": "Επικόλληση από το πρόχειρο", + "importEmpty": "Δεν υπάρχει τίποτα για εισαγωγή — επικολλήστε πρώτα κάποιες γραμμές", + "clipboardError": "Δεν ήταν δυνατή η ανάγνωση από το πρόχειρο", + "clear": "Καθαρισμός", + "panels": { + "editor": "Επεξεργαστής πίνακα", + "output": "Έξοδος" + }, + "align": { + "left": "Στοίχιση αριστερά", + "center": "Στοίχιση στο κέντρο", + "right": "Στοίχιση δεξιά" + }, + "headerPlaceholder": "Κεφαλίδα", + "cellPlaceholder": "Κελί", + "removeRow": "Αφαίρεση γραμμής", + "removeColumn": "Αφαίρεση στήλης", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Αντιγραφή", + "copied": "Αντιγράφηκε" + }, + "TimezoneConverter": { + "title": "Μετατροπέας ζωνών ώρας", + "subtitle": "Μετατρέψτε μια στιγμή σε όλο τον κόσμο για τον προγραμματισμό συναντήσεων.", + "dateTimeLabel": "Ημερομηνία και ώρα", + "now": "Τώρα", + "sourceZoneLabel": "Ζώνη ώρας προέλευσης", + "selectZone": "Επιλογή ζώνης", + "searchZone": "Αναζήτηση ζωνών ώρας...", + "noZone": "Δεν βρέθηκε ζώνη ώρας.", + "scrubLabel": "Μετατόπιση ώρας", + "reset": "Επαναφορά", + "targetsLabel": "Ζώνες προορισμού", + "addZone": "Προσθήκη ζώνης", + "remove": "Αφαίρεση", + "localBadge": "Τοπική", + "invalidDate": "Εισαγάγετε έγκυρη ημερομηνία και ώρα.", + "bands": { + "work": "Ώρες εργασίας (09:00–18:00)", + "edge": "Οριακές ώρες (νωρίς / βράδυ)", + "night": "Νύχτα" + } + }, + "TokenCounter": { + "title": "Μετρητής token LLM", + "subtitle": "Μετρήστε token, οπτικοποιήστε τμήματα και εκτιμήστε το κόστος API.", + "inputLabel": "Κείμενο", + "sample": "Δείγμα", + "clear": "Καθαρισμός", + "placeholder": "Επικολλήστε κείμενο, κώδικα ή JSON για μέτρηση token...", + "encoding": "Κωδικοποίηση", + "loading": "Φόρτωση tokenizer...", + "encodings": { + "o200k": "o200k_base (GPT-4o / σειρά o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Token", + "characters": "Χαρακτήρες", + "words": "Λέξεις", + "ratio": "Token/λέξη", + "ratioSub": "περίπου" + }, + "claudeRow": "Claude (κατά προσέγγιση)", + "estimate": "εκτίμηση", + "viz": { + "title": "Οπτικοποίηση token", + "toggle": "Εμφάνιση", + "hint": "Εναλλάξτε για να χρωματίσετε κάθε token ως τμήμα.", + "empty": "Εισαγάγετε κείμενο για την οπτικοποίηση token.", + "truncated": "Εμφάνιση των πρώτων {limit} από {total} token." + }, + "cost": { + "title": "Υπολογιστής κόστους", + "disclaimer": "επεξεργάσιμες προεπιλογές — επαληθεύστε τις τρέχουσες τιμές", + "model": "Μοντέλο", + "expectedOutput": "Αναμενόμενα token εξόδου", + "customInput": "Είσοδος $/MTok", + "customOutput": "Έξοδος $/MTok", + "inputCost": "Κόστος εισόδου", + "outputCost": "Κόστος εξόδου", + "total": "Σύνολο", + "per1000": "Ανά 1000 κλήσεις" + } + }, + "WebhookTester": { + "title": "Δοκιμαστής webhook", + "subtitle": "Δημιουργήστε ένα προσωρινό URL και επιθεωρήστε ζωντανά τα εισερχόμενα αιτήματα webhook.", + "intro": { + "title": "Δημιουργήστε ένα URL webhook για να ξεκινήσετε την καταγραφή αιτημάτων", + "hint": "Θα λάβετε ένα μη προβλέψιμο URL που καταγράφει κάθε εισερχόμενο αίτημα. Λήγει μετά από 24 ώρες." + }, + "createBtn": "Δημιουργία URL webhook", + "creating": "Δημιουργία…", + "urlLabel": "Το URL webhook σας", + "expiresIn": "Λήγει σε {countdown}", + "copyUrlBtn": "Αντιγραφή URL", + "copied": "Αντιγράφηκε", + "clearBtn": "Καθαρισμός", + "newBinBtn": "Νέο URL", + "requestsTitle": "Αιτήματα", + "selectPrompt": "Επιλέξτε ένα αίτημα για να επιθεωρήσετε τις λεπτομέρειές του.", + "expiredNotice": "Αυτό το URL webhook έχει λήξει. Δημιουργήστε ένα νέο για να συνεχίσετε τις δοκιμές.", + "base64Badge": "base64", + "truncatedBadge": "περικομμένο", + "empty": { + "title": "Αναμονή αιτημάτων…", + "hint": "Στείλτε ένα αίτημα στο URL σας και θα εμφανιστεί εδώ αμέσως. Δοκιμάστε αυτό:" + }, + "detail": { + "receivedAt": "Ελήφθη", + "source": "IP προέλευσης", + "contentType": "Content-Type", + "query": "Παράμετροι ερωτήματος", + "headers": "Κεφαλίδες", + "body": "Σώμα", + "copyBody": "Αντιγραφή σώματος", + "emptyBody": "Χωρίς σώμα" + }, + "errors": { + "createFailed": "Η δημιουργία του URL webhook απέτυχε. Δοκιμάστε ξανά.", + "clearFailed": "Ο καθαρισμός των αιτημάτων απέτυχε. Δοκιμάστε ξανά." + } + }, + "WebsocketTester": { + "title": "Δοκιμαστής WebSocket", + "subtitle": "Συνδεθείτε σε ένα άκρο WebSocket και στείλτε, λάβετε και επιθεωρήστε μηνύματα ζωντανά", + "urlLabel": "URL άκρου", + "protocolsLabel": "Υποπρωτόκολλα", + "protocolsPlaceholder": "προαιρετικό, διαχωρισμένα με κόμμα", + "connect": "Σύνδεση", + "disconnect": "Αποσύνδεση", + "status": { + "disconnected": "Αποσυνδεδεμένο", + "connecting": "Σύνδεση", + "connected": "Συνδεδεμένο", + "error": "Σφάλμα" + }, + "messages": { + "connecting": "Σύνδεση σε {url}…", + "connected": "Η σύνδεση δημιουργήθηκε", + "connectedWithProtocol": "Η σύνδεση δημιουργήθηκε (πρωτόκολλο: {protocol})", + "invalidUrl": "Μη έγκυρο URL — πρέπει να ξεκινά με ws:// ή wss://", + "connectFailed": "Το άνοιγμα της σύνδεσης απέτυχε", + "socketError": "Παρουσιάστηκε σφάλμα σύνδεσης", + "notConnected": "Μη συνδεδεμένο — συνδεθείτε πρώτα", + "binaryMessage": "Ελήφθη δυαδικό μήνυμα" + }, + "log": { + "title": "Αρχείο μηνυμάτων", + "empty": "Δεν υπάρχουν ακόμη μηνύματα. Συνδεθείτε και ξεκινήστε την αποστολή.", + "copyAll": "Αντιγραφή όλων", + "download": "Λήψη ως .txt", + "clear": "Καθαρισμός αρχείου", + "prettyPrint": "Μορφοποίηση JSON", + "showRaw": "Εμφάνιση ακατέργαστου κειμένου" + }, + "composer": { + "placeholder": "Πληκτρολογήστε ένα μήνυμα για αποστολή…", + "send": "Αποστολή", + "formatJson": "Μορφοποίηση ως JSON", + "invalidJson": "Το μήνυμα δεν είναι έγκυρο JSON", + "hint": "Cmd/Ctrl+Enter για αποστολή" + }, + "ping": { + "label": "Αυτόματο ping", + "seconds": "δευτ", + "intervalTitle": "Διάστημα ping σε δευτερόλεπτα", + "payloadPlaceholder": "φορτίο ping" + } + }, + "WhoisLookup": { + "title": "Αναζήτηση Whois", + "subtitle": "Αναζητήστε στοιχεία καταχώρισης για οποιονδήποτε τομέα ή διεύθυνση IP μέσω RDAP", + "placeholder": "example.com ή 8.8.8.8", + "lookupBtn": "Αναζήτηση", + "looking": "Αναζήτηση…", + "refreshBtn": "Ανανέωση", + "tryLabel": "Δοκιμάστε:", + "copy": "Αντιγραφή", + "copied": "Αντιγράφηκε", + "notAvailable": "Μη διαθέσιμο", + "summary": { + "registrar": "Καταχωρητής", + "network": "Δίκτυο" + }, + "dates": { + "created": "Δημιουργήθηκε", + "updated": "Ενημερώθηκε", + "expires": "Λήγει" + }, + "sections": { + "status": "Κατάσταση", + "nameservers": "Διακομιστές ονομάτων", + "contacts": "Επαφές", + "rawRdap": "Ακατέργαστο RDAP" + }, + "contacts": { + "roles": "Ρόλοι", + "name": "Όνομα", + "organization": "Οργανισμός" + }, + "errors": { + "invalidTarget": "Εισαγάγετε ένα έγκυρο όνομα τομέα ή διεύθυνση IP", + "lookupFailed": "Η αναζήτηση απέτυχε (κατάσταση {status})" + } } } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index b8145c94..8101e4a2 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod Calculator", + "codeScreenshot": "Code Screenshot", + "cssGenerators": "CSS Generators", + "exifViewer": "EXIF Viewer", + "faviconGenerator": "Favicon Generator", + "jsonDiff": "JSON Diff", + "jsonpathPlayground": "JSONPath Playground", + "keycodeInspector": "Keycode Inspector", + "markdownTableGenerator": "Markdown Table", + "timezoneConverter": "Timezone Converter", + "tokenCounter": "Token Counter", + "webhookTester": "Webhook Tester", + "websocketTester": "WebSocket Tester", + "whoisLookup": "Whois Lookup" }, "Help": { "title": "Help & documentation", @@ -642,6 +657,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt Generator & Checker", + "description": "Hash passwords with bcrypt and verify existing hashes." + }, + "chmodCalculator": { + "title": "Chmod Calculator", + "description": "Convert Unix permissions between octal, symbolic, and checkboxes." + }, + "codeScreenshot": { + "title": "Code Screenshot", + "description": "Turn source code into beautiful shareable images." + }, + "cssGenerators": { + "title": "CSS Generators", + "description": "Box-shadow, border-radius, and fluid clamp() generators." + }, + "exifViewer": { + "title": "EXIF Viewer & Remover", + "description": "Inspect and strip photo EXIF metadata locally." + }, + "faviconGenerator": { + "title": "Favicon Generator", + "description": "Generate favicons, app icons and manifest snippets." + }, + "jsonDiff": { + "title": "JSON Diff", + "description": "Semantic, structure-aware diff of two JSON documents." + }, + "jsonpathPlayground": { + "title": "JSONPath Playground", + "description": "Query JSON with JSONPath or JMESPath and see live results." + }, + "keycodeInspector": { + "title": "Keycode Inspector", + "description": "Press a key to see its JavaScript event codes and modifiers." + }, + "markdownTableGenerator": { + "title": "Markdown Table Generator", + "description": "Build Markdown and HTML tables in a spreadsheet-like grid editor." + }, + "timezoneConverter": { + "title": "Timezone Converter", + "description": "Convert a time across many zones with day-shift and working-hours cues." + }, + "tokenCounter": { + "title": "LLM Token Counter", + "description": "Count LLM tokens, visualize chunks, and estimate API cost." + }, + "webhookTester": { + "title": "Webhook Tester", + "description": "Capture and inspect incoming webhook requests in real time." + }, + "websocketTester": { + "title": "WebSocket Tester", + "description": "Connect to WebSocket endpoints and exchange messages in real time." + }, + "whoisLookup": { + "title": "Whois Lookup", + "description": "Look up domain and IP registration details via RDAP." } } }, @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt Generator & Checker", + "subtitle": "Hash passwords with bcrypt and verify hashes — everything stays in your browser", + "tabs": { + "hash": "Hash", + "verify": "Verify" + }, + "showPassword": "Show password", + "hidePassword": "Hide password", + "copy": "Copy", + "copied": "Copied", + "hash": { + "passwordLabel": "Password", + "passwordPlaceholder": "Enter a password to hash…", + "costLabel": "Cost factor", + "costHint": "Higher cost doubles the work per step. 10-12 is a common choice for production.", + "slowWarning": "Cost factors of 13 and above can take several seconds to compute.", + "generate": "Generate hash", + "generating": "Hashing…", + "resultLabel": "Bcrypt hash", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Hash anatomy", + "algorithm": "algorithm", + "cost": "cost", + "salt": "salt (22 chars)", + "hash": "hash (31 chars)" + } + }, + "verify": { + "passwordLabel": "Password", + "passwordPlaceholder": "Enter the password to check…", + "hashLabel": "Bcrypt hash", + "hashPlaceholder": "$2b$10$…", + "idle": "Enter a password and a bcrypt hash to compare them.", + "invalidHash": "This does not look like a valid bcrypt hash.", + "checking": "Comparing…", + "match": "Match — the password produces this hash.", + "noMatch": "No match — the password does not produce this hash." + } + }, + "ChmodCalculator": { + "title": "Chmod Calculator", + "subtitle": "Convert Unix file permissions between checkboxes, octal, and symbolic notation", + "grid": { + "title": "Permissions", + "read": "Read", + "write": "Write", + "execute": "Execute", + "owner": "Owner", + "group": "Group", + "others": "Others" + }, + "special": { + "title": "Special bits", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Octal", + "placeholder": "755 or 4755", + "invalid": "Enter 3 or 4 octal digits (0-7)." + }, + "symbolic": { + "label": "Symbolic", + "placeholder": "rwxr-xr-x", + "invalid": "Enter 9 characters like rwxr-xr-x (s/S, t/T allowed)." + }, + "output": { + "title": "Command", + "copy": "Copy", + "copied": "Copied" + }, + "explain": { + "line": "{cls} can {perms}.", + "none": "{cls} has no permissions.", + "setuid": "Setuid: the file runs with its owner's privileges.", + "setgid": "Setgid: the file runs with its group's privileges (new files in a directory inherit its group).", + "sticky": "Sticky: only a file's owner can delete or rename it inside this directory." + }, + "presets": { + "title": "Common presets", + "octal": "Octal", + "permissions": "Permissions", + "description": "Typical use", + "p644": "Regular files: owner writes, everyone reads.", + "p755": "Executables and directories: everyone can read and enter.", + "p600": "Private files: only the owner reads and writes.", + "p700": "Private executables and directories: owner only.", + "p664": "Group-writable files.", + "p775": "Group-writable directories and executables.", + "p400": "Read-only for the owner (e.g. SSH private keys)." + } + }, + "CodeScreenshot": { + "title": "Code Screenshot", + "subtitle": "Turn code into beautiful, shareable images", + "languageLabel": "Language", + "autoDetect": "Auto-detect", + "backgroundLabel": "Background", + "paddingLabel": "Padding", + "filenameLabel": "Filename", + "filenamePlaceholder": "example.ts", + "windowLabel": "Window dots", + "themeLabel": "Window theme", + "themeDark": "Dark", + "themeLight": "Light", + "scaleLabel": "Export scale", + "copyImage": "Copy image", + "copied": "Copied", + "exportPng": "Export PNG", + "inputLabel": "Code", + "detected": "Detected: {language}", + "clear": "Clear", + "placeholder": "Paste your code here…", + "previewLabel": "Preview", + "emptyPreview": "Your code preview will appear here", + "exportFailed": "Failed to export image", + "copyUnsupported": "Copying images is not supported in this browser", + "copyFailed": "Failed to copy image", + "backgrounds": { + "ocean": "Ocean", + "sunset": "Sunset", + "candy": "Candy", + "forest": "Forest", + "midnight": "Midnight", + "gold": "Gold", + "aurora": "Aurora", + "slate": "Slate", + "transparent": "Transparent" + } + }, + "CssGenerators": { + "title": "CSS Generators", + "subtitle": "Box-shadow, border-radius, and fluid clamp() — copy the CSS.", + "preview": "Preview", + "output": "CSS", + "copy": "Copy", + "copied": "Copied", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Fluid clamp()" + }, + "boxShadow": { + "presets": "Presets", + "layer": "Layer", + "addLayer": "Add layer", + "removeLayer": "Remove layer", + "x": "X", + "y": "Y", + "blur": "Blur", + "spread": "Spread", + "color": "Color", + "alpha": "Alpha", + "inset": "Inset", + "presetNames": { + "subtleCard": "Subtle card", + "elevated": "Elevated", + "hardOffset": "Hard offset", + "neonGlow": "Neon glow", + "inner": "Inner", + "layeredSmooth": "Layered smooth" + } + }, + "borderRadius": { + "modeLabel": "Mode", + "modes": { + "simple": "Per-corner", + "blob": "Organic blob" + }, + "unit": "Unit", + "linkCorners": "Link corners", + "randomize": "Randomize", + "horizontal": "Horizontal radii", + "vertical": "Vertical radii", + "corners": { + "tl": "Top left", + "tr": "Top right", + "br": "Bottom right", + "bl": "Bottom left" + } + }, + "clamp": { + "minFont": "Min font (px)", + "maxFont": "Max font (px)", + "minViewport": "Min viewport (px)", + "maxViewport": "Max viewport (px)", + "rootFont": "Root font (px)", + "viewportWidth": "Simulated viewport", + "computedSize": "Computed size", + "slope": "Slope", + "intercept": "Intercept", + "previewText": "The quick brown fox", + "invalid": "Enter valid, differing viewport bounds." + } + }, + "ExifViewer": { + "title": "EXIF Viewer & Remover", + "subtitle": "Inspect photo metadata and strip it in your browser", + "dropTitle": "Drop an image here", + "dropHint": "or click to browse — your image never leaves your device", + "browse": "Choose image", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Reading metadata…", + "parseFailed": "Failed to read this image", + "clear": "Remove image", + "gpsWarning": "Contains GPS location", + "openMap": "View on OpenStreetMap", + "noMetadata": "No embedded metadata was found in this image.", + "facts": { + "name": "Name", + "type": "Type", + "dimensions": "Dimensions", + "size": "Size" + }, + "groups": { + "camera": "Camera", + "exposure": "Exposure", + "timestamps": "Timestamps", + "gps": "GPS Location", + "software": "Software & Other" + }, + "gpsCard": { + "latitude": "Latitude", + "longitude": "Longitude", + "altitude": "Altitude" + }, + "raw": { + "title": "All metadata", + "copyJson": "Copy JSON", + "copied": "Copied" + }, + "remove": { + "title": "Remove metadata", + "jpegDescription": "Losslessly remove EXIF, XMP, IPTC and comments without re-compressing the image.", + "reencodeDescription": "Re-encode the image to strip embedded metadata. This re-compresses the pixels.", + "keepIcc": "Keep color profile (ICC)", + "stripButton": "Strip metadata", + "reencodeButton": "Re-encode & strip", + "unsupported": "Metadata removal is not supported for this file type.", + "failed": "Failed to remove metadata", + "before": "Before", + "after": "After", + "download": "Download cleaned image" + } + }, + "FaviconGenerator": { + "title": "Favicon Generator", + "subtitle": "Create favicons and app icons from one image", + "dropTitle": "Drop an image here", + "dropHint": "PNG, JPEG or SVG — click to browse", + "browse": "Choose image", + "clear": "Remove image", + "backgroundLabel": "Background", + "transparent": "Transparent", + "solidColor": "Solid", + "cornerLabel": "Corner shape", + "cornerNone": "Square", + "cornerRounded": "Rounded", + "cornerCircle": "Circle", + "maskPaddingLabel": "Maskable padding", + "maskable": "maskable", + "previewLabel": "Preview", + "copy": "Copy", + "headSnippet": "HTML head snippet", + "downloadIco": "Download favicon.ico", + "downloadZip": "Download all (.zip)" + }, + "JsonDiff": { + "title": "JSON Diff", + "subtitle": "Semantic, structure-aware comparison of two JSON documents — key order and formatting ignored", + "options": { + "ignoreArrayOrder": "Ignore array order" + }, + "loadBothSamples": "Load samples", + "sample": "Sample", + "format": "Format", + "clear": "Clear", + "formatError": "Cannot format: input is not valid JSON", + "placeholder": "Paste or type JSON here…", + "panels": { + "left": "Original (left)", + "right": "Modified (right)", + "results": "Differences" + }, + "summary": { + "added": "{count} added", + "removed": "{count} removed", + "changed": "{count} changed" + }, + "types": { + "added": "Added", + "removed": "Removed", + "changed": "Changed" + }, + "copyChanges": "Copy as JSON", + "copied": "Copied", + "empty": "Paste JSON into both panels to see their differences.", + "identical": "The two documents are semantically identical." + }, + "JsonpathPlayground": { + "title": "JSONPath Playground", + "subtitle": "Query JSON documents with JSONPath or JMESPath and see live results", + "engineLabel": "Engine", + "queryLabel": "Query", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Cheatsheet", + "cheats": { + "jpAllTitles": "All book titles", + "jpAllAuthors": "Every author, anywhere in the document", + "jpFirstBook": "First book", + "jpLastBook": "Last book", + "jpSlice": "First two books (slice)", + "jpFilterPrice": "Books cheaper than 40", + "jpFilterInStock": "Titles of in-stock books", + "jpRecursive": "All values, recursively", + "jmAllTitles": "All book titles", + "jmFirstAuthor": "Author of the first book", + "jmLastBook": "Last book", + "jmSlice": "First two books (slice)", + "jmFilterPrice": "Titles of books cheaper than 40", + "jmCount": "Count in-stock books", + "jmProjection": "Reshape each book into a new object", + "jmSort": "Titles sorted by price" + }, + "panels": { + "input": "JSON input", + "results": "Results" + }, + "sample": "Sample", + "format": "Format", + "clear": "Clear", + "formatError": "Cannot format: input is not valid JSON", + "placeholder": "Paste or type JSON here…", + "outputPlaceholder": "Matches will appear here…", + "matchCount": "{count, plural, one {# match} other {# matches}}", + "copy": "Copy", + "copied": "Copied" + }, + "KeycodeInspector": { + "title": "Keycode Inspector", + "subtitle": "Press any key to inspect its JavaScript keyboard event", + "capture": { + "prompt": "Press any key…", + "ready": "Listening — press any key on your keyboard", + "clickToFocus": "Click here to focus, then press any key" + }, + "hero": { + "spaceKey": "(Space)", + "keyLabel": "event.key", + "repeat": "repeat" + }, + "copyJson": "Copy JSON", + "copiedJson": "Event JSON copied to clipboard", + "deprecated": "deprecated", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standard", + "left": "Left", + "right": "Right", + "numpad": "Numpad", + "unknown": "Unknown" + }, + "history": { + "title": "History", + "clear": "Clear history", + "empty": "Key presses will appear here." + } + }, + "MarkdownTableGenerator": { + "title": "Markdown Table Generator", + "subtitle": "Edit a grid, paste from a spreadsheet, and copy a pretty-aligned Markdown or HTML table", + "addRow": "Add row", + "addColumn": "Add column", + "importLabel": "Import", + "importPlaceholder": "Paste TSV (from Excel / Google Sheets) or CSV here…", + "importButton": "Import into grid", + "pasteClipboard": "Paste from clipboard", + "importEmpty": "Nothing to import — paste some rows first", + "clipboardError": "Could not read from the clipboard", + "clear": "Clear", + "panels": { + "editor": "Table editor", + "output": "Output" + }, + "align": { + "left": "Align left", + "center": "Align center", + "right": "Align right" + }, + "headerPlaceholder": "Header", + "cellPlaceholder": "Cell", + "removeRow": "Remove row", + "removeColumn": "Remove column", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Copy", + "copied": "Copied" + }, + "TimezoneConverter": { + "title": "Timezone Converter", + "subtitle": "Convert one moment across the world for meeting planning.", + "dateTimeLabel": "Date & time", + "now": "Now", + "sourceZoneLabel": "Source timezone", + "selectZone": "Select zone", + "searchZone": "Search timezones...", + "noZone": "No timezone found.", + "scrubLabel": "Shift time", + "reset": "Reset", + "targetsLabel": "Target zones", + "addZone": "Add zone", + "remove": "Remove", + "localBadge": "Local", + "invalidDate": "Enter a valid date and time.", + "bands": { + "work": "Working hours (09:00–18:00)", + "edge": "Edge hours (early / evening)", + "night": "Night" + } + }, + "TokenCounter": { + "title": "LLM Token Counter", + "subtitle": "Count tokens, visualize chunks, and estimate API cost.", + "inputLabel": "Text", + "sample": "Sample", + "clear": "Clear", + "placeholder": "Paste text, code, or JSON to count tokens...", + "encoding": "Encoding", + "loading": "Loading tokenizer...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-series)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Characters", + "words": "Words", + "ratio": "Tokens/word", + "ratioSub": "approx" + }, + "claudeRow": "Claude (approximate)", + "estimate": "estimate", + "viz": { + "title": "Token visualization", + "toggle": "Show", + "hint": "Toggle to color each token as a chunk.", + "empty": "Enter text to visualize tokens.", + "truncated": "Showing the first {limit} of {total} tokens." + }, + "cost": { + "title": "Cost calculator", + "disclaimer": "editable defaults — verify current pricing", + "model": "Model", + "expectedOutput": "Expected output tokens", + "customInput": "Input $/MTok", + "customOutput": "Output $/MTok", + "inputCost": "Input cost", + "outputCost": "Output cost", + "total": "Total", + "per1000": "Per 1000 calls" + } + }, + "WebhookTester": { + "title": "Webhook Tester", + "subtitle": "Create a temporary URL and inspect incoming webhook requests live.", + "intro": { + "title": "Create a webhook URL to start capturing requests", + "hint": "You'll get an unguessable URL that records every incoming request. It expires after 24 hours." + }, + "createBtn": "Create webhook URL", + "creating": "Creating…", + "urlLabel": "Your webhook URL", + "expiresIn": "Expires in {countdown}", + "copyUrlBtn": "Copy URL", + "copied": "Copied", + "clearBtn": "Clear", + "newBinBtn": "New URL", + "requestsTitle": "Requests", + "selectPrompt": "Select a request to inspect its details.", + "expiredNotice": "This webhook URL has expired. Create a new one to keep testing.", + "base64Badge": "base64", + "truncatedBadge": "truncated", + "empty": { + "title": "Waiting for requests…", + "hint": "Send a request to your URL and it will appear here instantly. Try this:" + }, + "detail": { + "receivedAt": "Received", + "source": "Source IP", + "contentType": "Content-Type", + "query": "Query params", + "headers": "Headers", + "body": "Body", + "copyBody": "Copy body", + "emptyBody": "No body" + }, + "errors": { + "createFailed": "Failed to create webhook URL. Please try again.", + "clearFailed": "Failed to clear requests. Please try again." + } + }, + "WebsocketTester": { + "title": "WebSocket Tester", + "subtitle": "Connect to a WebSocket endpoint and send, receive, and inspect messages live", + "urlLabel": "Endpoint URL", + "protocolsLabel": "Subprotocols", + "protocolsPlaceholder": "optional, comma separated", + "connect": "Connect", + "disconnect": "Disconnect", + "status": { + "disconnected": "Disconnected", + "connecting": "Connecting", + "connected": "Connected", + "error": "Error" + }, + "messages": { + "connecting": "Connecting to {url}…", + "connected": "Connection established", + "connectedWithProtocol": "Connection established (protocol: {protocol})", + "invalidUrl": "Invalid URL — must start with ws:// or wss://", + "connectFailed": "Failed to open connection", + "socketError": "A connection error occurred", + "notConnected": "Not connected — connect first", + "binaryMessage": "Received a binary message" + }, + "log": { + "title": "Message Log", + "empty": "No messages yet. Connect and start sending.", + "copyAll": "Copy all", + "download": "Download as .txt", + "clear": "Clear log", + "prettyPrint": "Pretty-print JSON", + "showRaw": "Show raw text" + }, + "composer": { + "placeholder": "Type a message to send…", + "send": "Send", + "formatJson": "Format as JSON", + "invalidJson": "Message is not valid JSON", + "hint": "Cmd/Ctrl+Enter to send" + }, + "ping": { + "label": "Auto-ping", + "seconds": "sec", + "intervalTitle": "Ping interval in seconds", + "payloadPlaceholder": "ping payload" + } + }, + "WhoisLookup": { + "title": "Whois Lookup", + "subtitle": "Look up registration details for any domain or IP address via RDAP", + "placeholder": "example.com or 8.8.8.8", + "lookupBtn": "Lookup", + "looking": "Looking up…", + "refreshBtn": "Refresh", + "tryLabel": "Try:", + "copy": "Copy", + "copied": "Copied", + "notAvailable": "Not available", + "summary": { + "registrar": "Registrar", + "network": "Network" + }, + "dates": { + "created": "Created", + "updated": "Updated", + "expires": "Expires" + }, + "sections": { + "status": "Status", + "nameservers": "Nameservers", + "contacts": "Contacts", + "rawRdap": "Raw RDAP" + }, + "contacts": { + "roles": "Roles", + "name": "Name", + "organization": "Organization" + }, + "errors": { + "invalidTarget": "Enter a valid domain name or IP address", + "lookupFailed": "Lookup failed (status {status})" + } } } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index d04dbd2f..fd0c8625 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Calculadora Chmod", + "codeScreenshot": "Captura de código", + "cssGenerators": "Generadores CSS", + "exifViewer": "Visor EXIF", + "faviconGenerator": "Generador de favicon", + "jsonDiff": "Diff JSON", + "jsonpathPlayground": "Playground JSONPath", + "keycodeInspector": "Inspector de keycode", + "markdownTableGenerator": "Tabla Markdown", + "timezoneConverter": "Conversor de zonas horarias", + "tokenCounter": "Contador de tokens", + "webhookTester": "Probador de webhooks", + "websocketTester": "Probador de WebSocket", + "whoisLookup": "Consulta Whois" }, "Help": { "title": "Ayuda y documentación", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Generador y verificador Bcrypt", + "description": "Genera hashes de contraseñas con bcrypt y verifica hashes existentes." + }, + "chmodCalculator": { + "title": "Calculadora Chmod", + "description": "Convierte permisos de Unix entre octal, simbólico y casillas." + }, + "codeScreenshot": { + "title": "Captura de código", + "description": "Convierte tu código fuente en imágenes atractivas y fáciles de compartir." + }, + "cssGenerators": { + "title": "Generadores CSS", + "description": "Generadores de box-shadow, border-radius y clamp() fluido." + }, + "exifViewer": { + "title": "Visor y eliminador EXIF", + "description": "Inspecciona y elimina los metadatos EXIF de las fotos de forma local." + }, + "faviconGenerator": { + "title": "Generador de favicon", + "description": "Genera favicons, iconos de app y fragmentos de manifiesto." + }, + "jsonDiff": { + "title": "Diff JSON", + "description": "Comparación semántica y estructural de dos documentos JSON." + }, + "jsonpathPlayground": { + "title": "Playground JSONPath", + "description": "Consulta JSON con JSONPath o JMESPath y ve los resultados en vivo." + }, + "keycodeInspector": { + "title": "Inspector de keycode", + "description": "Pulsa una tecla para ver sus códigos de evento de JavaScript y sus modificadores." + }, + "markdownTableGenerator": { + "title": "Generador de tablas Markdown", + "description": "Crea tablas Markdown y HTML en un editor de cuadrícula tipo hoja de cálculo." + }, + "timezoneConverter": { + "title": "Conversor de zonas horarias", + "description": "Convierte una hora entre muchas zonas con indicaciones de cambio de día y horario laboral." + }, + "tokenCounter": { + "title": "Contador de tokens de LLM", + "description": "Cuenta tokens de LLM, visualiza fragmentos y estima el coste de la API." + }, + "webhookTester": { + "title": "Probador de webhooks", + "description": "Captura e inspecciona solicitudes de webhook entrantes en tiempo real." + }, + "websocketTester": { + "title": "Probador de WebSocket", + "description": "Conéctate a endpoints WebSocket e intercambia mensajes en tiempo real." + }, + "whoisLookup": { + "title": "Consulta Whois", + "description": "Consulta los datos de registro de un dominio o IP mediante RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Generador y verificador Bcrypt", + "subtitle": "Genera hashes de contraseñas con bcrypt y verifica hashes: todo permanece en tu navegador", + "tabs": { + "hash": "Hashear", + "verify": "Verificar" + }, + "showPassword": "Mostrar contraseña", + "hidePassword": "Ocultar contraseña", + "copy": "Copiar", + "copied": "Copiado", + "hash": { + "passwordLabel": "Contraseña", + "passwordPlaceholder": "Introduce una contraseña para hashear…", + "costLabel": "Factor de coste", + "costHint": "Un coste mayor duplica el trabajo por paso. 10-12 es una elección habitual en producción.", + "slowWarning": "Los factores de coste de 13 o más pueden tardar varios segundos en calcularse.", + "generate": "Generar hash", + "generating": "Hasheando…", + "resultLabel": "Hash bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Anatomía del hash", + "algorithm": "algoritmo", + "cost": "coste", + "salt": "salt (22 caracteres)", + "hash": "hash (31 caracteres)" + } + }, + "verify": { + "passwordLabel": "Contraseña", + "passwordPlaceholder": "Introduce la contraseña a comprobar…", + "hashLabel": "Hash bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Introduce una contraseña y un hash bcrypt para compararlos.", + "invalidHash": "Esto no parece un hash bcrypt válido.", + "checking": "Comparando…", + "match": "Coincide: la contraseña produce este hash.", + "noMatch": "No coincide: la contraseña no produce este hash." + } + }, + "ChmodCalculator": { + "title": "Calculadora Chmod", + "subtitle": "Convierte permisos de archivos Unix entre casillas, octal y notación simbólica", + "grid": { + "title": "Permisos", + "read": "Lectura", + "write": "Escritura", + "execute": "Ejecución", + "owner": "Propietario", + "group": "Grupo", + "others": "Otros" + }, + "special": { + "title": "Bits especiales", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Octal", + "placeholder": "755 o 4755", + "invalid": "Introduce 3 o 4 dígitos octales (0-7)." + }, + "symbolic": { + "label": "Simbólico", + "placeholder": "rwxr-xr-x", + "invalid": "Introduce 9 caracteres como rwxr-xr-x (se permiten s/S, t/T)." + }, + "output": { + "title": "Comando", + "copy": "Copiar", + "copied": "Copiado" + }, + "explain": { + "line": "{cls} puede {perms}.", + "none": "{cls} no tiene permisos.", + "setuid": "Setuid: el archivo se ejecuta con los privilegios de su propietario.", + "setgid": "Setgid: el archivo se ejecuta con los privilegios de su grupo (los archivos nuevos de un directorio heredan su grupo).", + "sticky": "Sticky: solo el propietario de un archivo puede eliminarlo o renombrarlo dentro de este directorio." + }, + "presets": { + "title": "Ajustes habituales", + "octal": "Octal", + "permissions": "Permisos", + "description": "Uso típico", + "p644": "Archivos normales: el propietario escribe, todos leen.", + "p755": "Ejecutables y directorios: todos pueden leer y entrar.", + "p600": "Archivos privados: solo el propietario lee y escribe.", + "p700": "Ejecutables y directorios privados: solo el propietario.", + "p664": "Archivos con escritura para el grupo.", + "p775": "Directorios y ejecutables con escritura para el grupo.", + "p400": "Solo lectura para el propietario (p. ej. claves privadas SSH)." + } + }, + "CodeScreenshot": { + "title": "Captura de código", + "subtitle": "Convierte código en imágenes atractivas y fáciles de compartir", + "languageLabel": "Lenguaje", + "autoDetect": "Detección automática", + "backgroundLabel": "Fondo", + "paddingLabel": "Relleno", + "filenameLabel": "Nombre de archivo", + "filenamePlaceholder": "example.ts", + "windowLabel": "Puntos de ventana", + "themeLabel": "Tema de ventana", + "themeDark": "Oscuro", + "themeLight": "Claro", + "scaleLabel": "Escala de exportación", + "copyImage": "Copiar imagen", + "copied": "Copiado", + "exportPng": "Exportar PNG", + "inputLabel": "Código", + "detected": "Detectado: {language}", + "clear": "Limpiar", + "placeholder": "Pega tu código aquí…", + "previewLabel": "Vista previa", + "emptyPreview": "La vista previa de tu código aparecerá aquí", + "exportFailed": "No se pudo exportar la imagen", + "copyUnsupported": "Copiar imágenes no es compatible con este navegador", + "copyFailed": "No se pudo copiar la imagen", + "backgrounds": { + "ocean": "Océano", + "sunset": "Atardecer", + "candy": "Caramelo", + "forest": "Bosque", + "midnight": "Medianoche", + "gold": "Oro", + "aurora": "Aurora", + "slate": "Pizarra", + "transparent": "Transparente" + } + }, + "CssGenerators": { + "title": "Generadores CSS", + "subtitle": "Box-shadow, border-radius y clamp() fluido: copia el CSS.", + "preview": "Vista previa", + "output": "CSS", + "copy": "Copiar", + "copied": "Copiado", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() fluido" + }, + "boxShadow": { + "presets": "Ajustes", + "layer": "Capa", + "addLayer": "Añadir capa", + "removeLayer": "Quitar capa", + "x": "X", + "y": "Y", + "blur": "Desenfoque", + "spread": "Extensión", + "color": "Color", + "alpha": "Alfa", + "inset": "Interior", + "presetNames": { + "subtleCard": "Tarjeta sutil", + "elevated": "Elevado", + "hardOffset": "Desplazamiento marcado", + "neonGlow": "Brillo neón", + "inner": "Interior", + "layeredSmooth": "Capas suaves" + } + }, + "borderRadius": { + "modeLabel": "Modo", + "modes": { + "simple": "Por esquina", + "blob": "Blob orgánico" + }, + "unit": "Unidad", + "linkCorners": "Vincular esquinas", + "randomize": "Aleatorizar", + "horizontal": "Radios horizontales", + "vertical": "Radios verticales", + "corners": { + "tl": "Superior izquierda", + "tr": "Superior derecha", + "br": "Inferior derecha", + "bl": "Inferior izquierda" + } + }, + "clamp": { + "minFont": "Fuente mín. (px)", + "maxFont": "Fuente máx. (px)", + "minViewport": "Viewport mín. (px)", + "maxViewport": "Viewport máx. (px)", + "rootFont": "Fuente raíz (px)", + "viewportWidth": "Viewport simulado", + "computedSize": "Tamaño calculado", + "slope": "Pendiente", + "intercept": "Intersección", + "previewText": "El veloz murciélago hindú", + "invalid": "Introduce límites de viewport válidos y distintos." + } + }, + "ExifViewer": { + "title": "Visor y eliminador EXIF", + "subtitle": "Inspecciona los metadatos de las fotos y elimínalos en tu navegador", + "dropTitle": "Suelta una imagen aquí", + "dropHint": "o haz clic para buscar: tu imagen nunca sale de tu dispositivo", + "browse": "Elegir imagen", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Leyendo metadatos…", + "parseFailed": "No se pudo leer esta imagen", + "clear": "Quitar imagen", + "gpsWarning": "Contiene ubicación GPS", + "openMap": "Ver en OpenStreetMap", + "noMetadata": "No se encontraron metadatos incrustados en esta imagen.", + "facts": { + "name": "Nombre", + "type": "Tipo", + "dimensions": "Dimensiones", + "size": "Tamaño" + }, + "groups": { + "camera": "Cámara", + "exposure": "Exposición", + "timestamps": "Marcas de tiempo", + "gps": "Ubicación GPS", + "software": "Software y otros" + }, + "gpsCard": { + "latitude": "Latitud", + "longitude": "Longitud", + "altitude": "Altitud" + }, + "raw": { + "title": "Todos los metadatos", + "copyJson": "Copiar JSON", + "copied": "Copiado" + }, + "remove": { + "title": "Eliminar metadatos", + "jpegDescription": "Elimina sin pérdidas EXIF, XMP, IPTC y comentarios sin recomprimir la imagen.", + "reencodeDescription": "Recodifica la imagen para eliminar los metadatos incrustados. Esto recomprime los píxeles.", + "keepIcc": "Conservar el perfil de color (ICC)", + "stripButton": "Eliminar metadatos", + "reencodeButton": "Recodificar y limpiar", + "unsupported": "La eliminación de metadatos no es compatible con este tipo de archivo.", + "failed": "No se pudieron eliminar los metadatos", + "before": "Antes", + "after": "Después", + "download": "Descargar imagen limpia" + } + }, + "FaviconGenerator": { + "title": "Generador de favicon", + "subtitle": "Crea favicons e iconos de app a partir de una sola imagen", + "dropTitle": "Suelta una imagen aquí", + "dropHint": "PNG, JPEG o SVG: haz clic para buscar", + "browse": "Elegir imagen", + "clear": "Quitar imagen", + "backgroundLabel": "Fondo", + "transparent": "Transparente", + "solidColor": "Sólido", + "cornerLabel": "Forma de las esquinas", + "cornerNone": "Cuadrado", + "cornerRounded": "Redondeado", + "cornerCircle": "Círculo", + "maskPaddingLabel": "Relleno maskable", + "maskable": "maskable", + "previewLabel": "Vista previa", + "copy": "Copiar", + "headSnippet": "Fragmento para el head HTML", + "downloadIco": "Descargar favicon.ico", + "downloadZip": "Descargar todo (.zip)" + }, + "JsonDiff": { + "title": "Diff JSON", + "subtitle": "Comparación semántica y estructural de dos documentos JSON: se ignoran el orden de las claves y el formato", + "options": { + "ignoreArrayOrder": "Ignorar el orden de los arrays" + }, + "loadBothSamples": "Cargar ejemplos", + "sample": "Cargar ejemplo", + "format": "Formatear", + "clear": "Limpiar", + "formatError": "No se puede formatear: la entrada no es un JSON válido", + "placeholder": "Pega o escribe JSON aquí…", + "panels": { + "left": "Original (izquierda)", + "right": "Modificado (derecha)", + "results": "Diferencias" + }, + "summary": { + "added": "{count} añadidos", + "removed": "{count} eliminados", + "changed": "{count} modificados" + }, + "types": { + "added": "Añadido", + "removed": "Eliminado", + "changed": "Modificado" + }, + "copyChanges": "Copiar como JSON", + "copied": "Copiado", + "empty": "Pega JSON en ambos paneles para ver sus diferencias.", + "identical": "Los dos documentos son semánticamente idénticos." + }, + "JsonpathPlayground": { + "title": "Playground JSONPath", + "subtitle": "Consulta documentos JSON con JSONPath o JMESPath y ve los resultados en vivo", + "engineLabel": "Motor", + "queryLabel": "Consulta", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Chuleta", + "cheats": { + "jpAllTitles": "Todos los títulos de libros", + "jpAllAuthors": "Cada autor, en cualquier parte del documento", + "jpFirstBook": "Primer libro", + "jpLastBook": "Último libro", + "jpSlice": "Los dos primeros libros (slice)", + "jpFilterPrice": "Libros de menos de 40", + "jpFilterInStock": "Títulos de libros en stock", + "jpRecursive": "Todos los valores, de forma recursiva", + "jmAllTitles": "Todos los títulos de libros", + "jmFirstAuthor": "Autor del primer libro", + "jmLastBook": "Último libro", + "jmSlice": "Los dos primeros libros (slice)", + "jmFilterPrice": "Títulos de libros de menos de 40", + "jmCount": "Contar libros en stock", + "jmProjection": "Reformar cada libro en un nuevo objeto", + "jmSort": "Títulos ordenados por precio" + }, + "panels": { + "input": "Entrada JSON", + "results": "Resultados" + }, + "sample": "Cargar ejemplo", + "format": "Formatear", + "clear": "Limpiar", + "formatError": "No se puede formatear: la entrada no es un JSON válido", + "placeholder": "Pega o escribe JSON aquí…", + "outputPlaceholder": "Las coincidencias aparecerán aquí…", + "matchCount": "{count, plural, one {# coincidencia} other {# coincidencias}}", + "copy": "Copiar", + "copied": "Copiado" + }, + "KeycodeInspector": { + "title": "Inspector de keycode", + "subtitle": "Pulsa cualquier tecla para inspeccionar su evento de teclado de JavaScript", + "capture": { + "prompt": "Pulsa cualquier tecla…", + "ready": "Escuchando: pulsa cualquier tecla de tu teclado", + "clickToFocus": "Haz clic aquí para enfocar y luego pulsa cualquier tecla" + }, + "hero": { + "spaceKey": "(Espacio)", + "keyLabel": "event.key", + "repeat": "repetición" + }, + "copyJson": "Copiar JSON", + "copiedJson": "JSON del evento copiado al portapapeles", + "deprecated": "obsoleto", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Estándar", + "left": "Izquierda", + "right": "Derecha", + "numpad": "Teclado numérico", + "unknown": "Desconocido" + }, + "history": { + "title": "Historial", + "clear": "Borrar historial", + "empty": "Las pulsaciones de teclas aparecerán aquí." + } + }, + "MarkdownTableGenerator": { + "title": "Generador de tablas Markdown", + "subtitle": "Edita una cuadrícula, pega desde una hoja de cálculo y copia una tabla Markdown o HTML bien alineada", + "addRow": "Añadir fila", + "addColumn": "Añadir columna", + "importLabel": "Importar", + "importPlaceholder": "Pega TSV (desde Excel / Google Sheets) o CSV aquí…", + "importButton": "Importar a la cuadrícula", + "pasteClipboard": "Pegar desde el portapapeles", + "importEmpty": "Nada que importar: pega primero algunas filas", + "clipboardError": "No se pudo leer el portapapeles", + "clear": "Limpiar", + "panels": { + "editor": "Editor de tabla", + "output": "Salida" + }, + "align": { + "left": "Alinear a la izquierda", + "center": "Centrar", + "right": "Alinear a la derecha" + }, + "headerPlaceholder": "Encabezado", + "cellPlaceholder": "Celda", + "removeRow": "Quitar fila", + "removeColumn": "Quitar columna", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Copiar", + "copied": "Copiado" + }, + "TimezoneConverter": { + "title": "Conversor de zonas horarias", + "subtitle": "Convierte un mismo momento por todo el mundo para planificar reuniones.", + "dateTimeLabel": "Fecha y hora", + "now": "Ahora", + "sourceZoneLabel": "Zona horaria de origen", + "selectZone": "Seleccionar zona", + "searchZone": "Buscar zonas horarias...", + "noZone": "No se encontró ninguna zona horaria.", + "scrubLabel": "Desplazar la hora", + "reset": "Restablecer", + "targetsLabel": "Zonas de destino", + "addZone": "Añadir zona", + "remove": "Quitar", + "localBadge": "Local", + "invalidDate": "Introduce una fecha y hora válidas.", + "bands": { + "work": "Horario laboral (09:00–18:00)", + "edge": "Horas límite (temprano / noche)", + "night": "Noche" + } + }, + "TokenCounter": { + "title": "Contador de tokens de LLM", + "subtitle": "Cuenta tokens, visualiza fragmentos y estima el coste de la API.", + "inputLabel": "Texto", + "sample": "Cargar ejemplo", + "clear": "Limpiar", + "placeholder": "Pega texto, código o JSON para contar tokens...", + "encoding": "Codificación", + "loading": "Cargando el tokenizador...", + "encodings": { + "o200k": "o200k_base (GPT-4o / serie o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Caracteres", + "words": "Palabras", + "ratio": "Tokens/palabra", + "ratioSub": "aprox." + }, + "claudeRow": "Claude (aproximado)", + "estimate": "estimación", + "viz": { + "title": "Visualización de tokens", + "toggle": "Mostrar", + "hint": "Activa para colorear cada token como un fragmento.", + "empty": "Introduce texto para visualizar los tokens.", + "truncated": "Mostrando los primeros {limit} de {total} tokens." + }, + "cost": { + "title": "Calculadora de costes", + "disclaimer": "valores por defecto editables: verifica los precios actuales", + "model": "Modelo", + "expectedOutput": "Tokens de salida previstos", + "customInput": "Entrada $/MTok", + "customOutput": "Salida $/MTok", + "inputCost": "Coste de entrada", + "outputCost": "Coste de salida", + "total": "Total", + "per1000": "Por cada 1000 llamadas" + } + }, + "WebhookTester": { + "title": "Probador de webhooks", + "subtitle": "Crea una URL temporal e inspecciona las solicitudes de webhook entrantes en vivo.", + "intro": { + "title": "Crea una URL de webhook para empezar a capturar solicitudes", + "hint": "Obtendrás una URL imposible de adivinar que registra cada solicitud entrante. Caduca a las 24 horas." + }, + "createBtn": "Crear URL de webhook", + "creating": "Creando…", + "urlLabel": "Tu URL de webhook", + "expiresIn": "Caduca en {countdown}", + "copyUrlBtn": "Copiar URL", + "copied": "Copiado", + "clearBtn": "Limpiar", + "newBinBtn": "Nueva URL", + "requestsTitle": "Solicitudes", + "selectPrompt": "Selecciona una solicitud para inspeccionar sus detalles.", + "expiredNotice": "Esta URL de webhook ha caducado. Crea una nueva para seguir probando.", + "base64Badge": "base64", + "truncatedBadge": "truncado", + "empty": { + "title": "Esperando solicitudes…", + "hint": "Envía una solicitud a tu URL y aparecerá aquí al instante. Prueba esto:" + }, + "detail": { + "receivedAt": "Recibido", + "source": "IP de origen", + "contentType": "Content-Type", + "query": "Parámetros de consulta", + "headers": "Cabeceras", + "body": "Cuerpo", + "copyBody": "Copiar cuerpo", + "emptyBody": "Sin cuerpo" + }, + "errors": { + "createFailed": "No se pudo crear la URL de webhook. Inténtalo de nuevo.", + "clearFailed": "No se pudieron borrar las solicitudes. Inténtalo de nuevo." + } + }, + "WebsocketTester": { + "title": "Probador de WebSocket", + "subtitle": "Conéctate a un endpoint WebSocket y envía, recibe e inspecciona mensajes en vivo", + "urlLabel": "URL del endpoint", + "protocolsLabel": "Subprotocolos", + "protocolsPlaceholder": "opcional, separados por comas", + "connect": "Conectar", + "disconnect": "Desconectar", + "status": { + "disconnected": "Desconectado", + "connecting": "Conectando", + "connected": "Conectado", + "error": "Error" + }, + "messages": { + "connecting": "Conectando a {url}…", + "connected": "Conexión establecida", + "connectedWithProtocol": "Conexión establecida (protocolo: {protocol})", + "invalidUrl": "URL no válida: debe empezar por ws:// o wss://", + "connectFailed": "No se pudo abrir la conexión", + "socketError": "Se produjo un error de conexión", + "notConnected": "No conectado: conéctate primero", + "binaryMessage": "Se recibió un mensaje binario" + }, + "log": { + "title": "Registro de mensajes", + "empty": "Todavía no hay mensajes. Conéctate y empieza a enviar.", + "copyAll": "Copiar todo", + "download": "Descargar como .txt", + "clear": "Borrar registro", + "prettyPrint": "Formatear JSON", + "showRaw": "Mostrar texto sin formato" + }, + "composer": { + "placeholder": "Escribe un mensaje para enviar…", + "send": "Enviar", + "formatJson": "Formatear como JSON", + "invalidJson": "El mensaje no es un JSON válido", + "hint": "Cmd/Ctrl+Intro para enviar" + }, + "ping": { + "label": "Auto-ping", + "seconds": "s", + "intervalTitle": "Intervalo de ping en segundos", + "payloadPlaceholder": "carga útil del ping" + } + }, + "WhoisLookup": { + "title": "Consulta Whois", + "subtitle": "Consulta los datos de registro de cualquier dominio o dirección IP mediante RDAP", + "placeholder": "example.com o 8.8.8.8", + "lookupBtn": "Consultar", + "looking": "Consultando…", + "refreshBtn": "Actualizar", + "tryLabel": "Prueba:", + "copy": "Copiar", + "copied": "Copiado", + "notAvailable": "No disponible", + "summary": { + "registrar": "Registrador", + "network": "Red" + }, + "dates": { + "created": "Creado", + "updated": "Actualizado", + "expires": "Caduca" + }, + "sections": { + "status": "Estado", + "nameservers": "Servidores de nombres", + "contacts": "Contactos", + "rawRdap": "RDAP sin procesar" + }, + "contacts": { + "roles": "Roles", + "name": "Nombre", + "organization": "Organización" + }, + "errors": { + "invalidTarget": "Introduce un nombre de dominio o dirección IP válidos", + "lookupFailed": "La consulta falló (estado {status})" + } } } diff --git a/apps/web/messages/fa.json b/apps/web/messages/fa.json index 832a3f4f..e68f40e2 100644 --- a/apps/web/messages/fa.json +++ b/apps/web/messages/fa.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "ماشین‌حساب Chmod", + "codeScreenshot": "اسکرین‌شات کد", + "cssGenerators": "مولدهای CSS", + "exifViewer": "نمایشگر EXIF", + "faviconGenerator": "مولد Favicon", + "jsonDiff": "تفاوت JSON", + "jsonpathPlayground": "زمین بازی JSONPath", + "keycodeInspector": "بازرس کدِ کلید", + "markdownTableGenerator": "جدول Markdown", + "timezoneConverter": "مبدل منطقه زمانی", + "tokenCounter": "شمارنده توکن", + "webhookTester": "آزمایشگر Webhook", + "websocketTester": "آزمایشگر WebSocket", + "whoisLookup": "جست‌وجوی Whois" }, "Help": { "title": "راهنما و مستندات", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "مولد و بررسی‌کننده Bcrypt", + "description": "گذرواژه‌ها را با bcrypt هش کنید و هش‌های موجود را تأیید کنید." + }, + "chmodCalculator": { + "title": "ماشین‌حساب Chmod", + "description": "مجوزهای یونیکس را بین هشت‌هشتی، نمادین و چک‌باکس تبدیل کنید." + }, + "codeScreenshot": { + "title": "اسکرین‌شات کد", + "description": "کد منبع را به تصاویر زیبا و قابل‌اشتراک تبدیل کنید." + }, + "cssGenerators": { + "title": "مولدهای CSS", + "description": "مولدهای box-shadow، border-radius و clamp() سیّال." + }, + "exifViewer": { + "title": "نمایشگر و حذف‌کننده EXIF", + "description": "متادیتای EXIF عکس را به‌صورت محلی بررسی و حذف کنید." + }, + "faviconGenerator": { + "title": "مولد Favicon", + "description": "favicon، آیکون‌های اپ و قطعه‌کدهای manifest بسازید." + }, + "jsonDiff": { + "title": "تفاوت JSON", + "description": "مقایسه معنایی و ساختارآگاه دو سند JSON." + }, + "jsonpathPlayground": { + "title": "زمین بازی JSONPath", + "description": "JSON را با JSONPath یا JMESPath پرس‌وجو کنید و نتایج زنده را ببینید." + }, + "keycodeInspector": { + "title": "بازرس کدِ کلید", + "description": "کلیدی را فشار دهید تا کدهای رویداد JavaScript و کلیدهای تغییردهنده آن را ببینید." + }, + "markdownTableGenerator": { + "title": "مولد جدول Markdown", + "description": "جدول‌های Markdown و HTML را در ویرایشگر شبکه‌ای شبیه صفحه‌گسترده بسازید." + }, + "timezoneConverter": { + "title": "مبدل منطقه زمانی", + "description": "زمانی را در چند منطقه با نشانه‌های تغییر روز و ساعات کاری تبدیل کنید." + }, + "tokenCounter": { + "title": "شمارنده توکن LLM", + "description": "توکن‌های LLM را بشمارید، تکه‌ها را تجسم کنید و هزینه API را تخمین بزنید." + }, + "webhookTester": { + "title": "آزمایشگر Webhook", + "description": "درخواست‌های ورودی webhook را در لحظه ثبت و بررسی کنید." + }, + "websocketTester": { + "title": "آزمایشگر WebSocket", + "description": "به نقاط پایانی WebSocket متصل شوید و پیام‌ها را در لحظه رد و بدل کنید." + }, + "whoisLookup": { + "title": "جست‌وجوی Whois", + "description": "جزئیات ثبت دامنه و IP را از طریق RDAP جست‌وجو کنید." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "مولد و بررسی‌کننده Bcrypt", + "subtitle": "گذرواژه‌ها را با bcrypt هش کنید و هش‌ها را تأیید کنید — همه‌چیز در مرورگر شما می‌ماند", + "tabs": { + "hash": "هش", + "verify": "تأیید" + }, + "showPassword": "نمایش گذرواژه", + "hidePassword": "پنهان‌کردن گذرواژه", + "copy": "کپی", + "copied": "کپی شد", + "hash": { + "passwordLabel": "گذرواژه", + "passwordPlaceholder": "گذرواژه‌ای برای هش‌کردن وارد کنید…", + "costLabel": "ضریب هزینه", + "costHint": "هزینه بالاتر کار هر مرحله را دو برابر می‌کند. ۱۰ تا ۱۲ انتخابی رایج برای محیط تولید است.", + "slowWarning": "ضریب هزینه ۱۳ به بالا ممکن است چند ثانیه برای محاسبه طول بکشد.", + "generate": "تولید هش", + "generating": "در حال هش‌کردن…", + "resultLabel": "هش Bcrypt", + "timeTaken": "{ms} میلی‌ثانیه", + "anatomy": { + "title": "ساختار هش", + "algorithm": "الگوریتم", + "cost": "هزینه", + "salt": "salt (۲۲ کاراکتر)", + "hash": "هش (۳۱ کاراکتر)" + } + }, + "verify": { + "passwordLabel": "گذرواژه", + "passwordPlaceholder": "گذرواژه‌ای برای بررسی وارد کنید…", + "hashLabel": "هش Bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "یک گذرواژه و یک هش bcrypt وارد کنید تا مقایسه شوند.", + "invalidHash": "این به‌نظر یک هش bcrypt معتبر نمی‌آید.", + "checking": "در حال مقایسه…", + "match": "منطبق است — گذرواژه این هش را تولید می‌کند.", + "noMatch": "منطبق نیست — گذرواژه این هش را تولید نمی‌کند." + } + }, + "ChmodCalculator": { + "title": "ماشین‌حساب Chmod", + "subtitle": "مجوزهای فایل یونیکس را بین چک‌باکس، هشت‌هشتی و نماد نمادین تبدیل کنید", + "grid": { + "title": "مجوزها", + "read": "خواندن", + "write": "نوشتن", + "execute": "اجرا", + "owner": "مالک", + "group": "گروه", + "others": "دیگران" + }, + "special": { + "title": "بیت‌های ویژه", + "setuid": "setuid", + "setgid": "setgid", + "sticky": "sticky" + }, + "octal": { + "label": "هشت‌هشتی", + "placeholder": "755 یا 4755", + "invalid": "۳ یا ۴ رقم هشت‌هشتی (۰-۷) وارد کنید." + }, + "symbolic": { + "label": "نمادین", + "placeholder": "rwxr-xr-x", + "invalid": "۹ کاراکتر مانند rwxr-xr-x وارد کنید (s/S و t/T مجاز است)." + }, + "output": { + "title": "دستور", + "copy": "کپی", + "copied": "کپی شد" + }, + "explain": { + "line": "{cls} می‌تواند {perms}.", + "none": "{cls} هیچ مجوزی ندارد.", + "setuid": "setuid: فایل با امتیازات مالک خود اجرا می‌شود.", + "setgid": "setgid: فایل با امتیازات گروه خود اجرا می‌شود (فایل‌های جدید در یک دایرکتوری گروه آن را به ارث می‌برند).", + "sticky": "sticky: فقط مالک فایل می‌تواند آن را در این دایرکتوری حذف یا تغییرنام دهد." + }, + "presets": { + "title": "پیش‌تنظیم‌های رایج", + "octal": "هشت‌هشتی", + "permissions": "مجوزها", + "description": "کاربرد معمول", + "p644": "فایل‌های معمولی: مالک می‌نویسد، همه می‌خوانند.", + "p755": "فایل‌های اجرایی و دایرکتوری‌ها: همه می‌توانند بخوانند و وارد شوند.", + "p600": "فایل‌های خصوصی: فقط مالک می‌خواند و می‌نویسد.", + "p700": "فایل‌های اجرایی و دایرکتوری‌های خصوصی: فقط مالک.", + "p664": "فایل‌های قابل‌نوشتن توسط گروه.", + "p775": "دایرکتوری‌ها و فایل‌های اجرایی قابل‌نوشتن توسط گروه.", + "p400": "فقط‌خواندنی برای مالک (مثلاً کلیدهای خصوصی SSH)." + } + }, + "CodeScreenshot": { + "title": "اسکرین‌شات کد", + "subtitle": "کد را به تصاویر زیبا و قابل‌اشتراک تبدیل کنید", + "languageLabel": "زبان", + "autoDetect": "تشخیص خودکار", + "backgroundLabel": "پس‌زمینه", + "paddingLabel": "فاصله داخلی", + "filenameLabel": "نام فایل", + "filenamePlaceholder": "example.ts", + "windowLabel": "نقطه‌های پنجره", + "themeLabel": "پوسته پنجره", + "themeDark": "تیره", + "themeLight": "روشن", + "scaleLabel": "مقیاس خروجی", + "copyImage": "کپی تصویر", + "copied": "کپی شد", + "exportPng": "خروجی PNG", + "inputLabel": "کد", + "detected": "تشخیص‌داده‌شده: {language}", + "clear": "پاک کردن", + "placeholder": "کد خود را اینجا بچسبانید…", + "previewLabel": "پیش‌نمایش", + "emptyPreview": "پیش‌نمایش کد شما اینجا ظاهر می‌شود", + "exportFailed": "خروجی‌گرفتن از تصویر ناموفق بود", + "copyUnsupported": "کپی تصاویر در این مرورگر پشتیبانی نمی‌شود", + "copyFailed": "کپی تصویر ناموفق بود", + "backgrounds": { + "ocean": "اقیانوس", + "sunset": "غروب", + "candy": "آب‌نبات", + "forest": "جنگل", + "midnight": "نیمه‌شب", + "gold": "طلایی", + "aurora": "شفق", + "slate": "سنگی", + "transparent": "شفاف" + } + }, + "CssGenerators": { + "title": "مولدهای CSS", + "subtitle": "box-shadow، border-radius و clamp() سیّال — کد CSS را کپی کنید.", + "preview": "پیش‌نمایش", + "output": "CSS", + "copy": "کپی", + "copied": "کپی شد", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() سیّال" + }, + "boxShadow": { + "presets": "پیش‌تنظیم‌ها", + "layer": "لایه", + "addLayer": "افزودن لایه", + "removeLayer": "حذف لایه", + "x": "X", + "y": "Y", + "blur": "محو", + "spread": "گسترش", + "color": "رنگ", + "alpha": "آلفا", + "inset": "داخلی", + "presetNames": { + "subtleCard": "کارت ملایم", + "elevated": "برجسته", + "hardOffset": "افست تیز", + "neonGlow": "درخشش نئون", + "inner": "درونی", + "layeredSmooth": "چندلایه نرم" + } + }, + "borderRadius": { + "modeLabel": "حالت", + "modes": { + "simple": "برای هر گوشه", + "blob": "شکل ارگانیک" + }, + "unit": "واحد", + "linkCorners": "پیوند گوشه‌ها", + "randomize": "تصادفی", + "horizontal": "شعاع‌های افقی", + "vertical": "شعاع‌های عمودی", + "corners": { + "tl": "بالا چپ", + "tr": "بالا راست", + "br": "پایین راست", + "bl": "پایین چپ" + } + }, + "clamp": { + "minFont": "کمینه فونت (px)", + "maxFont": "بیشینه فونت (px)", + "minViewport": "کمینه viewport (px)", + "maxViewport": "بیشینه viewport (px)", + "rootFont": "فونت ریشه (px)", + "viewportWidth": "viewport شبیه‌سازی‌شده", + "computedSize": "اندازه محاسبه‌شده", + "slope": "شیب", + "intercept": "عرض از مبدأ", + "previewText": "متن نمونه برای پیش‌نمایش", + "invalid": "کران‌های معتبر و متفاوت viewport وارد کنید." + } + }, + "ExifViewer": { + "title": "نمایشگر و حذف‌کننده EXIF", + "subtitle": "متادیتای عکس را بررسی و در مرورگر خود حذف کنید", + "dropTitle": "تصویری را اینجا رها کنید", + "dropHint": "یا برای مرور کلیک کنید — تصویر شما هرگز دستگاهتان را ترک نمی‌کند", + "browse": "انتخاب تصویر", + "formats": "JPEG، PNG، WebP، HEIC، TIFF", + "parsing": "در حال خواندن متادیتا…", + "parseFailed": "خواندن این تصویر ناموفق بود", + "clear": "حذف تصویر", + "gpsWarning": "حاوی موقعیت GPS است", + "openMap": "نمایش روی OpenStreetMap", + "noMetadata": "هیچ متادیتای تعبیه‌شده‌ای در این تصویر یافت نشد.", + "facts": { + "name": "نام", + "type": "نوع", + "dimensions": "ابعاد", + "size": "اندازه" + }, + "groups": { + "camera": "دوربین", + "exposure": "نوردهی", + "timestamps": "برچسب‌های زمانی", + "gps": "موقعیت GPS", + "software": "نرم‌افزار و سایر" + }, + "gpsCard": { + "latitude": "عرض جغرافیایی", + "longitude": "طول جغرافیایی", + "altitude": "ارتفاع" + }, + "raw": { + "title": "همه متادیتا", + "copyJson": "کپی JSON", + "copied": "کپی شد" + }, + "remove": { + "title": "حذف متادیتا", + "jpegDescription": "EXIF، XMP، IPTC و کامنت‌ها را بدون افت کیفیت و بدون فشرده‌سازی مجدد تصویر حذف کنید.", + "reencodeDescription": "تصویر را دوباره کدگذاری کنید تا متادیتای تعبیه‌شده حذف شود. این کار پیکسل‌ها را دوباره فشرده می‌کند.", + "keepIcc": "حفظ پروفایل رنگ (ICC)", + "stripButton": "حذف متادیتا", + "reencodeButton": "کدگذاری مجدد و حذف", + "unsupported": "حذف متادیتا برای این نوع فایل پشتیبانی نمی‌شود.", + "failed": "حذف متادیتا ناموفق بود", + "before": "قبل", + "after": "بعد", + "download": "دانلود تصویر پاک‌شده" + } + }, + "FaviconGenerator": { + "title": "مولد Favicon", + "subtitle": "از یک تصویر، favicon و آیکون‌های اپ بسازید", + "dropTitle": "تصویری را اینجا رها کنید", + "dropHint": "PNG، JPEG یا SVG — برای مرور کلیک کنید", + "browse": "انتخاب تصویر", + "clear": "حذف تصویر", + "backgroundLabel": "پس‌زمینه", + "transparent": "شفاف", + "solidColor": "یک‌رنگ", + "cornerLabel": "شکل گوشه", + "cornerNone": "مربع", + "cornerRounded": "گِرد", + "cornerCircle": "دایره", + "maskPaddingLabel": "فاصله داخلی maskable", + "maskable": "maskable", + "previewLabel": "پیش‌نمایش", + "copy": "کپی", + "headSnippet": "قطعه‌کد HTML head", + "downloadIco": "دانلود favicon.ico", + "downloadZip": "دانلود همه (.zip)" + }, + "JsonDiff": { + "title": "تفاوت JSON", + "subtitle": "مقایسه معنایی و ساختارآگاه دو سند JSON — ترتیب کلیدها و قالب‌بندی نادیده گرفته می‌شود", + "options": { + "ignoreArrayOrder": "نادیده‌گرفتن ترتیب آرایه" + }, + "loadBothSamples": "بارگذاری نمونه‌ها", + "sample": "نمونه", + "format": "قالب", + "clear": "پاک کردن", + "formatError": "قالب‌بندی ممکن نیست: ورودی JSON معتبر نیست", + "placeholder": "JSON را اینجا بچسبانید یا تایپ کنید…", + "panels": { + "left": "اصلی (چپ)", + "right": "تغییریافته (راست)", + "results": "تفاوت‌ها" + }, + "summary": { + "added": "{count} افزوده", + "removed": "{count} حذف‌شده", + "changed": "{count} تغییریافته" + }, + "types": { + "added": "افزوده", + "removed": "حذف‌شده", + "changed": "تغییریافته" + }, + "copyChanges": "کپی به‌صورت JSON", + "copied": "کپی شد", + "empty": "JSON را در هر دو پنل بچسبانید تا تفاوت‌هایشان را ببینید.", + "identical": "دو سند از نظر معنایی یکسان هستند." + }, + "JsonpathPlayground": { + "title": "زمین بازی JSONPath", + "subtitle": "اسناد JSON را با JSONPath یا JMESPath پرس‌وجو کنید و نتایج زنده را ببینید", + "engineLabel": "موتور", + "queryLabel": "پرس‌وجو", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "برگه تقلب", + "cheats": { + "jpAllTitles": "همه عنوان‌های کتاب", + "jpAllAuthors": "هر نویسنده، در هر جای سند", + "jpFirstBook": "اولین کتاب", + "jpLastBook": "آخرین کتاب", + "jpSlice": "دو کتاب اول (برش)", + "jpFilterPrice": "کتاب‌های ارزان‌تر از ۴۰", + "jpFilterInStock": "عنوان کتاب‌های موجود", + "jpRecursive": "همه مقادیر، به‌صورت بازگشتی", + "jmAllTitles": "همه عنوان‌های کتاب", + "jmFirstAuthor": "نویسنده اولین کتاب", + "jmLastBook": "آخرین کتاب", + "jmSlice": "دو کتاب اول (برش)", + "jmFilterPrice": "عنوان کتاب‌های ارزان‌تر از ۴۰", + "jmCount": "شمارش کتاب‌های موجود", + "jmProjection": "بازآرایی هر کتاب به یک شیء جدید", + "jmSort": "عنوان‌ها مرتب‌شده بر اساس قیمت" + }, + "panels": { + "input": "ورودی JSON", + "results": "نتایج" + }, + "sample": "نمونه", + "format": "قالب", + "clear": "پاک کردن", + "formatError": "قالب‌بندی ممکن نیست: ورودی JSON معتبر نیست", + "placeholder": "JSON را اینجا بچسبانید یا تایپ کنید…", + "outputPlaceholder": "تطبیق‌ها اینجا ظاهر می‌شوند…", + "matchCount": "{count, plural, one {# تطبیق} other {# تطبیق}}", + "copy": "کپی", + "copied": "کپی شد" + }, + "KeycodeInspector": { + "title": "بازرس کدِ کلید", + "subtitle": "هر کلیدی را فشار دهید تا رویداد صفحه‌کلید JavaScript آن را بررسی کنید", + "capture": { + "prompt": "هر کلیدی را فشار دهید…", + "ready": "در حال گوش‌دادن — هر کلیدی روی صفحه‌کلید خود فشار دهید", + "clickToFocus": "برای فوکوس اینجا کلیک کنید، سپس هر کلیدی را فشار دهید" + }, + "hero": { + "spaceKey": "(فاصله)", + "keyLabel": "event.key", + "repeat": "تکرار" + }, + "copyJson": "کپی JSON", + "copiedJson": "JSON رویداد در کلیپ‌بورد کپی شد", + "deprecated": "منسوخ", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "استاندارد", + "left": "چپ", + "right": "راست", + "numpad": "صفحه‌کلید عددی", + "unknown": "نامشخص" + }, + "history": { + "title": "تاریخچه", + "clear": "پاک‌کردن تاریخچه", + "empty": "فشردن کلیدها اینجا ظاهر می‌شود." + } + }, + "MarkdownTableGenerator": { + "title": "مولد جدول Markdown", + "subtitle": "یک شبکه را ویرایش کنید، از صفحه‌گسترده بچسبانید و جدول Markdown یا HTML با تراز زیبا کپی کنید", + "addRow": "افزودن ردیف", + "addColumn": "افزودن ستون", + "importLabel": "درون‌ریزی", + "importPlaceholder": "TSV (از Excel / Google Sheets) یا CSV را اینجا بچسبانید…", + "importButton": "درون‌ریزی به شبکه", + "pasteClipboard": "چسباندن از کلیپ‌بورد", + "importEmpty": "چیزی برای درون‌ریزی نیست — ابتدا چند ردیف بچسبانید", + "clipboardError": "خواندن از کلیپ‌بورد ممکن نشد", + "clear": "پاک کردن", + "panels": { + "editor": "ویرایشگر جدول", + "output": "خروجی" + }, + "align": { + "left": "تراز چپ", + "center": "تراز وسط", + "right": "تراز راست" + }, + "headerPlaceholder": "سرستون", + "cellPlaceholder": "سلول", + "removeRow": "حذف ردیف", + "removeColumn": "حذف ستون", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "کپی", + "copied": "کپی شد" + }, + "TimezoneConverter": { + "title": "مبدل منطقه زمانی", + "subtitle": "یک لحظه را در سراسر جهان برای برنامه‌ریزی جلسات تبدیل کنید.", + "dateTimeLabel": "تاریخ و زمان", + "now": "اکنون", + "sourceZoneLabel": "منطقه زمانی مبدأ", + "selectZone": "انتخاب منطقه", + "searchZone": "جست‌وجوی مناطق زمانی...", + "noZone": "منطقه زمانی‌ای یافت نشد.", + "scrubLabel": "جابه‌جایی زمان", + "reset": "بازنشانی", + "targetsLabel": "مناطق مقصد", + "addZone": "افزودن منطقه", + "remove": "حذف", + "localBadge": "محلی", + "invalidDate": "تاریخ و زمان معتبری وارد کنید.", + "bands": { + "work": "ساعات کاری (۰۹:۰۰–۱۸:۰۰)", + "edge": "ساعات حاشیه‌ای (صبح زود / عصر)", + "night": "شب" + } + }, + "TokenCounter": { + "title": "شمارنده توکن LLM", + "subtitle": "توکن‌ها را بشمارید، تکه‌ها را تجسم کنید و هزینه API را تخمین بزنید.", + "inputLabel": "متن", + "sample": "نمونه", + "clear": "پاک کردن", + "placeholder": "متن، کد یا JSON را برای شمارش توکن بچسبانید...", + "encoding": "کدگذاری", + "loading": "در حال بارگذاری توکنایزر...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-series)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "توکن‌ها", + "characters": "کاراکترها", + "words": "کلمات", + "ratio": "توکن/کلمه", + "ratioSub": "تقریبی" + }, + "claudeRow": "Claude (تقریبی)", + "estimate": "تخمین", + "viz": { + "title": "تجسم توکن", + "toggle": "نمایش", + "hint": "برای رنگ‌آمیزی هر توکن به‌عنوان یک تکه، تغییر وضعیت دهید.", + "empty": "برای تجسم توکن‌ها متن وارد کنید.", + "truncated": "نمایش {limit} توکن اول از {total} توکن." + }, + "cost": { + "title": "ماشین‌حساب هزینه", + "disclaimer": "مقادیر پیش‌فرض قابل‌ویرایش — قیمت‌گذاری فعلی را بررسی کنید", + "model": "مدل", + "expectedOutput": "توکن‌های خروجی موردانتظار", + "customInput": "ورودی $/MTok", + "customOutput": "خروجی $/MTok", + "inputCost": "هزینه ورودی", + "outputCost": "هزینه خروجی", + "total": "کل", + "per1000": "به‌ازای هر ۱۰۰۰ فراخوانی" + } + }, + "WebhookTester": { + "title": "آزمایشگر Webhook", + "subtitle": "یک URL موقت بسازید و درخواست‌های ورودی webhook را زنده بررسی کنید.", + "intro": { + "title": "برای شروع ثبت درخواست‌ها یک URL webhook بسازید", + "hint": "یک URL غیرقابل‌حدس دریافت می‌کنید که هر درخواست ورودی را ثبت می‌کند. پس از ۲۴ ساعت منقضی می‌شود." + }, + "createBtn": "ساخت URL webhook", + "creating": "در حال ساخت…", + "urlLabel": "URL webhook شما", + "expiresIn": "انقضا در {countdown}", + "copyUrlBtn": "کپی URL", + "copied": "کپی شد", + "clearBtn": "پاک کردن", + "newBinBtn": "URL جدید", + "requestsTitle": "درخواست‌ها", + "selectPrompt": "یک درخواست را برای بررسی جزئیاتش انتخاب کنید.", + "expiredNotice": "این URL webhook منقضی شده است. برای ادامه آزمایش یک URL جدید بسازید.", + "base64Badge": "base64", + "truncatedBadge": "کوتاه‌شده", + "empty": { + "title": "در انتظار درخواست‌ها…", + "hint": "درخواستی به URL خود بفرستید تا فوراً اینجا ظاهر شود. این را امتحان کنید:" + }, + "detail": { + "receivedAt": "دریافت‌شده", + "source": "IP مبدأ", + "contentType": "Content-Type", + "query": "پارامترهای پرس‌وجو", + "headers": "هدرها", + "body": "بدنه", + "copyBody": "کپی بدنه", + "emptyBody": "بدون بدنه" + }, + "errors": { + "createFailed": "ساخت URL webhook ناموفق بود. لطفاً دوباره تلاش کنید.", + "clearFailed": "پاک‌کردن درخواست‌ها ناموفق بود. لطفاً دوباره تلاش کنید." + } + }, + "WebsocketTester": { + "title": "آزمایشگر WebSocket", + "subtitle": "به یک نقطه پایانی WebSocket متصل شوید و پیام‌ها را زنده بفرستید، دریافت و بررسی کنید", + "urlLabel": "URL نقطه پایانی", + "protocolsLabel": "زیرپروتکل‌ها", + "protocolsPlaceholder": "اختیاری، جداشده با کاما", + "connect": "اتصال", + "disconnect": "قطع اتصال", + "status": { + "disconnected": "قطع‌شده", + "connecting": "در حال اتصال", + "connected": "متصل", + "error": "خطا" + }, + "messages": { + "connecting": "در حال اتصال به {url}…", + "connected": "اتصال برقرار شد", + "connectedWithProtocol": "اتصال برقرار شد (پروتکل: {protocol})", + "invalidUrl": "URL نامعتبر — باید با ws:// یا wss:// شروع شود", + "connectFailed": "بازکردن اتصال ناموفق بود", + "socketError": "خطایی در اتصال رخ داد", + "notConnected": "متصل نیست — ابتدا متصل شوید", + "binaryMessage": "یک پیام باینری دریافت شد" + }, + "log": { + "title": "گزارش پیام‌ها", + "empty": "هنوز پیامی نیست. متصل شوید و ارسال را آغاز کنید.", + "copyAll": "کپی همه", + "download": "دانلود به‌صورت .txt", + "clear": "پاک‌کردن گزارش", + "prettyPrint": "قالب‌بندی JSON", + "showRaw": "نمایش متن خام" + }, + "composer": { + "placeholder": "پیامی برای ارسال بنویسید…", + "send": "ارسال", + "formatJson": "قالب‌بندی به‌صورت JSON", + "invalidJson": "پیام JSON معتبر نیست", + "hint": "Cmd/Ctrl+Enter برای ارسال" + }, + "ping": { + "label": "ping خودکار", + "seconds": "ثانیه", + "intervalTitle": "فاصله ping به ثانیه", + "payloadPlaceholder": "محموله ping" + } + }, + "WhoisLookup": { + "title": "جست‌وجوی Whois", + "subtitle": "جزئیات ثبت هر دامنه یا آدرس IP را از طریق RDAP جست‌وجو کنید", + "placeholder": "example.com یا 8.8.8.8", + "lookupBtn": "جست‌وجو", + "looking": "در حال جست‌وجو…", + "refreshBtn": "تازه‌سازی", + "tryLabel": "امتحان کنید:", + "copy": "کپی", + "copied": "کپی شد", + "notAvailable": "در دسترس نیست", + "summary": { + "registrar": "ثبت‌کننده", + "network": "شبکه" + }, + "dates": { + "created": "ایجادشده", + "updated": "به‌روزشده", + "expires": "انقضا" + }, + "sections": { + "status": "وضعیت", + "nameservers": "سرورهای نام", + "contacts": "تماس‌ها", + "rawRdap": "RDAP خام" + }, + "contacts": { + "roles": "نقش‌ها", + "name": "نام", + "organization": "سازمان" + }, + "errors": { + "invalidTarget": "یک نام دامنه یا آدرس IP معتبر وارد کنید", + "lookupFailed": "جست‌وجو ناموفق بود (وضعیت {status})" + } } } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index df0db479..f7c9f1c8 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Calculateur Chmod", + "codeScreenshot": "Capture de code", + "cssGenerators": "Générateurs CSS", + "exifViewer": "Visionneuse EXIF", + "faviconGenerator": "Générateur de favicon", + "jsonDiff": "Diff JSON", + "jsonpathPlayground": "Playground JSONPath", + "keycodeInspector": "Inspecteur de keycode", + "markdownTableGenerator": "Tableau Markdown", + "timezoneConverter": "Convertisseur de fuseaux", + "tokenCounter": "Compteur de tokens", + "webhookTester": "Testeur de webhook", + "websocketTester": "Testeur WebSocket", + "whoisLookup": "Recherche Whois" }, "Help": { "title": "Aide et documentation", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Générateur et vérificateur Bcrypt", + "description": "Hachez des mots de passe avec bcrypt et vérifiez des hachages existants." + }, + "chmodCalculator": { + "title": "Calculateur Chmod", + "description": "Convertissez les permissions Unix entre octal, symbolique et cases à cocher." + }, + "codeScreenshot": { + "title": "Capture de code", + "description": "Transformez votre code source en images élégantes et partageables." + }, + "cssGenerators": { + "title": "Générateurs CSS", + "description": "Générateurs de box-shadow, border-radius et clamp() fluide." + }, + "exifViewer": { + "title": "Visionneuse et nettoyeur EXIF", + "description": "Inspectez et supprimez les métadonnées EXIF des photos en local." + }, + "faviconGenerator": { + "title": "Générateur de favicon", + "description": "Générez des favicons, icônes d'application et extraits de manifeste." + }, + "jsonDiff": { + "title": "Diff JSON", + "description": "Comparaison sémantique tenant compte de la structure de deux documents JSON." + }, + "jsonpathPlayground": { + "title": "Playground JSONPath", + "description": "Interrogez du JSON avec JSONPath ou JMESPath et voyez les résultats en direct." + }, + "keycodeInspector": { + "title": "Inspecteur de keycode", + "description": "Appuyez sur une touche pour voir ses codes d'événement JavaScript et ses modificateurs." + }, + "markdownTableGenerator": { + "title": "Générateur de tableaux Markdown", + "description": "Créez des tableaux Markdown et HTML dans un éditeur en grille de type tableur." + }, + "timezoneConverter": { + "title": "Convertisseur de fuseaux horaires", + "description": "Convertissez une heure sur de nombreux fuseaux avec repères de décalage de jour et d'heures ouvrées." + }, + "tokenCounter": { + "title": "Compteur de tokens LLM", + "description": "Comptez les tokens LLM, visualisez les segments et estimez le coût de l'API." + }, + "webhookTester": { + "title": "Testeur de webhook", + "description": "Capturez et inspectez les requêtes webhook entrantes en temps réel." + }, + "websocketTester": { + "title": "Testeur WebSocket", + "description": "Connectez-vous à des points de terminaison WebSocket et échangez des messages en temps réel." + }, + "whoisLookup": { + "title": "Recherche Whois", + "description": "Consultez les détails d'enregistrement d'un domaine ou d'une IP via RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Générateur et vérificateur Bcrypt", + "subtitle": "Hachez des mots de passe avec bcrypt et vérifiez des hachages — tout reste dans votre navigateur", + "tabs": { + "hash": "Hacher", + "verify": "Vérifier" + }, + "showPassword": "Afficher le mot de passe", + "hidePassword": "Masquer le mot de passe", + "copy": "Copier", + "copied": "Copié", + "hash": { + "passwordLabel": "Mot de passe", + "passwordPlaceholder": "Saisissez un mot de passe à hacher…", + "costLabel": "Facteur de coût", + "costHint": "Un coût plus élevé double le travail par étape. 10-12 est un choix courant en production.", + "slowWarning": "Les facteurs de coût de 13 et plus peuvent prendre plusieurs secondes à calculer.", + "generate": "Générer le hachage", + "generating": "Hachage…", + "resultLabel": "Hachage bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Anatomie du hachage", + "algorithm": "algorithme", + "cost": "coût", + "salt": "sel (22 caractères)", + "hash": "hachage (31 caractères)" + } + }, + "verify": { + "passwordLabel": "Mot de passe", + "passwordPlaceholder": "Saisissez le mot de passe à vérifier…", + "hashLabel": "Hachage bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Saisissez un mot de passe et un hachage bcrypt pour les comparer.", + "invalidHash": "Ceci ne ressemble pas à un hachage bcrypt valide.", + "checking": "Comparaison…", + "match": "Correspondance — le mot de passe produit ce hachage.", + "noMatch": "Aucune correspondance — le mot de passe ne produit pas ce hachage." + } + }, + "ChmodCalculator": { + "title": "Calculateur Chmod", + "subtitle": "Convertissez les permissions de fichiers Unix entre cases à cocher, octal et notation symbolique", + "grid": { + "title": "Permissions", + "read": "Lecture", + "write": "Écriture", + "execute": "Exécution", + "owner": "Propriétaire", + "group": "Groupe", + "others": "Autres" + }, + "special": { + "title": "Bits spéciaux", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Octal", + "placeholder": "755 ou 4755", + "invalid": "Saisissez 3 ou 4 chiffres octaux (0-7)." + }, + "symbolic": { + "label": "Symbolique", + "placeholder": "rwxr-xr-x", + "invalid": "Saisissez 9 caractères comme rwxr-xr-x (s/S, t/T autorisés)." + }, + "output": { + "title": "Commande", + "copy": "Copier", + "copied": "Copié" + }, + "explain": { + "line": "{cls} peut {perms}.", + "none": "{cls} n'a aucune permission.", + "setuid": "Setuid : le fichier s'exécute avec les privilèges de son propriétaire.", + "setgid": "Setgid : le fichier s'exécute avec les privilèges de son groupe (les nouveaux fichiers d'un répertoire héritent de son groupe).", + "sticky": "Sticky : seul le propriétaire d'un fichier peut le supprimer ou le renommer dans ce répertoire." + }, + "presets": { + "title": "Préréglages courants", + "octal": "Octal", + "permissions": "Permissions", + "description": "Usage typique", + "p644": "Fichiers ordinaires : le propriétaire écrit, tout le monde lit.", + "p755": "Exécutables et répertoires : tout le monde peut lire et entrer.", + "p600": "Fichiers privés : seul le propriétaire lit et écrit.", + "p700": "Exécutables et répertoires privés : propriétaire uniquement.", + "p664": "Fichiers modifiables par le groupe.", + "p775": "Répertoires et exécutables modifiables par le groupe.", + "p400": "Lecture seule pour le propriétaire (ex. clés privées SSH)." + } + }, + "CodeScreenshot": { + "title": "Capture de code", + "subtitle": "Transformez votre code en images élégantes et partageables", + "languageLabel": "Langage", + "autoDetect": "Détection auto", + "backgroundLabel": "Arrière-plan", + "paddingLabel": "Marge intérieure", + "filenameLabel": "Nom de fichier", + "filenamePlaceholder": "example.ts", + "windowLabel": "Points de fenêtre", + "themeLabel": "Thème de fenêtre", + "themeDark": "Sombre", + "themeLight": "Clair", + "scaleLabel": "Échelle d'export", + "copyImage": "Copier l'image", + "copied": "Copié", + "exportPng": "Exporter en PNG", + "inputLabel": "Code", + "detected": "Détecté : {language}", + "clear": "Effacer", + "placeholder": "Collez votre code ici…", + "previewLabel": "Aperçu", + "emptyPreview": "L'aperçu de votre code apparaîtra ici", + "exportFailed": "Échec de l'export de l'image", + "copyUnsupported": "La copie d'images n'est pas prise en charge dans ce navigateur", + "copyFailed": "Échec de la copie de l'image", + "backgrounds": { + "ocean": "Océan", + "sunset": "Coucher de soleil", + "candy": "Bonbon", + "forest": "Forêt", + "midnight": "Minuit", + "gold": "Or", + "aurora": "Aurore", + "slate": "Ardoise", + "transparent": "Transparent" + } + }, + "CssGenerators": { + "title": "Générateurs CSS", + "subtitle": "Box-shadow, border-radius et clamp() fluide — copiez le CSS.", + "preview": "Aperçu", + "output": "CSS", + "copy": "Copier", + "copied": "Copié", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() fluide" + }, + "boxShadow": { + "presets": "Préréglages", + "layer": "Calque", + "addLayer": "Ajouter un calque", + "removeLayer": "Supprimer le calque", + "x": "X", + "y": "Y", + "blur": "Flou", + "spread": "Étendue", + "color": "Couleur", + "alpha": "Alpha", + "inset": "Intérieur", + "presetNames": { + "subtleCard": "Carte subtile", + "elevated": "Surélevé", + "hardOffset": "Décalage net", + "neonGlow": "Lueur néon", + "inner": "Intérieur", + "layeredSmooth": "Superposé doux" + } + }, + "borderRadius": { + "modeLabel": "Mode", + "modes": { + "simple": "Par coin", + "blob": "Blob organique" + }, + "unit": "Unité", + "linkCorners": "Lier les coins", + "randomize": "Aléatoire", + "horizontal": "Rayons horizontaux", + "vertical": "Rayons verticaux", + "corners": { + "tl": "Haut gauche", + "tr": "Haut droite", + "br": "Bas droite", + "bl": "Bas gauche" + } + }, + "clamp": { + "minFont": "Police min (px)", + "maxFont": "Police max (px)", + "minViewport": "Viewport min (px)", + "maxViewport": "Viewport max (px)", + "rootFont": "Police racine (px)", + "viewportWidth": "Viewport simulé", + "computedSize": "Taille calculée", + "slope": "Pente", + "intercept": "Ordonnée à l'origine", + "previewText": "Portez ce vieux whisky au juge blond", + "invalid": "Saisissez des bornes de viewport valides et différentes." + } + }, + "ExifViewer": { + "title": "Visionneuse et nettoyeur EXIF", + "subtitle": "Inspectez les métadonnées des photos et supprimez-les dans votre navigateur", + "dropTitle": "Déposez une image ici", + "dropHint": "ou cliquez pour parcourir — votre image ne quitte jamais votre appareil", + "browse": "Choisir une image", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Lecture des métadonnées…", + "parseFailed": "Échec de la lecture de cette image", + "clear": "Supprimer l'image", + "gpsWarning": "Contient une position GPS", + "openMap": "Voir sur OpenStreetMap", + "noMetadata": "Aucune métadonnée intégrée n'a été trouvée dans cette image.", + "facts": { + "name": "Nom", + "type": "Type", + "dimensions": "Dimensions", + "size": "Taille" + }, + "groups": { + "camera": "Appareil photo", + "exposure": "Exposition", + "timestamps": "Horodatages", + "gps": "Position GPS", + "software": "Logiciel et autres" + }, + "gpsCard": { + "latitude": "Latitude", + "longitude": "Longitude", + "altitude": "Altitude" + }, + "raw": { + "title": "Toutes les métadonnées", + "copyJson": "Copier le JSON", + "copied": "Copié" + }, + "remove": { + "title": "Supprimer les métadonnées", + "jpegDescription": "Supprimez sans perte les données EXIF, XMP, IPTC et les commentaires sans recompresser l'image.", + "reencodeDescription": "Réencodez l'image pour supprimer les métadonnées intégrées. Cela recompresse les pixels.", + "keepIcc": "Conserver le profil colorimétrique (ICC)", + "stripButton": "Supprimer les métadonnées", + "reencodeButton": "Réencoder et nettoyer", + "unsupported": "La suppression des métadonnées n'est pas prise en charge pour ce type de fichier.", + "failed": "Échec de la suppression des métadonnées", + "before": "Avant", + "after": "Après", + "download": "Télécharger l'image nettoyée" + } + }, + "FaviconGenerator": { + "title": "Générateur de favicon", + "subtitle": "Créez des favicons et icônes d'application à partir d'une seule image", + "dropTitle": "Déposez une image ici", + "dropHint": "PNG, JPEG ou SVG — cliquez pour parcourir", + "browse": "Choisir une image", + "clear": "Supprimer l'image", + "backgroundLabel": "Arrière-plan", + "transparent": "Transparent", + "solidColor": "Uni", + "cornerLabel": "Forme des coins", + "cornerNone": "Carré", + "cornerRounded": "Arrondi", + "cornerCircle": "Cercle", + "maskPaddingLabel": "Marge maskable", + "maskable": "maskable", + "previewLabel": "Aperçu", + "copy": "Copier", + "headSnippet": "Extrait pour le head HTML", + "downloadIco": "Télécharger favicon.ico", + "downloadZip": "Tout télécharger (.zip)" + }, + "JsonDiff": { + "title": "Diff JSON", + "subtitle": "Comparaison sémantique tenant compte de la structure de deux documents JSON — ordre des clés et formatage ignorés", + "options": { + "ignoreArrayOrder": "Ignorer l'ordre des tableaux" + }, + "loadBothSamples": "Charger des exemples", + "sample": "Exemple", + "format": "Formater", + "clear": "Effacer", + "formatError": "Impossible de formater : l'entrée n'est pas un JSON valide", + "placeholder": "Collez ou saisissez du JSON ici…", + "panels": { + "left": "Original (gauche)", + "right": "Modifié (droite)", + "results": "Différences" + }, + "summary": { + "added": "{count} ajouté(s)", + "removed": "{count} supprimé(s)", + "changed": "{count} modifié(s)" + }, + "types": { + "added": "Ajouté", + "removed": "Supprimé", + "changed": "Modifié" + }, + "copyChanges": "Copier en JSON", + "copied": "Copié", + "empty": "Collez du JSON dans les deux panneaux pour voir leurs différences.", + "identical": "Les deux documents sont sémantiquement identiques." + }, + "JsonpathPlayground": { + "title": "Playground JSONPath", + "subtitle": "Interrogez des documents JSON avec JSONPath ou JMESPath et voyez les résultats en direct", + "engineLabel": "Moteur", + "queryLabel": "Requête", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Aide-mémoire", + "cheats": { + "jpAllTitles": "Tous les titres de livres", + "jpAllAuthors": "Chaque auteur, n'importe où dans le document", + "jpFirstBook": "Premier livre", + "jpLastBook": "Dernier livre", + "jpSlice": "Les deux premiers livres (slice)", + "jpFilterPrice": "Livres à moins de 40", + "jpFilterInStock": "Titres des livres en stock", + "jpRecursive": "Toutes les valeurs, récursivement", + "jmAllTitles": "Tous les titres de livres", + "jmFirstAuthor": "Auteur du premier livre", + "jmLastBook": "Dernier livre", + "jmSlice": "Les deux premiers livres (slice)", + "jmFilterPrice": "Titres des livres à moins de 40", + "jmCount": "Compter les livres en stock", + "jmProjection": "Reformer chaque livre en un nouvel objet", + "jmSort": "Titres triés par prix" + }, + "panels": { + "input": "Entrée JSON", + "results": "Résultats" + }, + "sample": "Exemple", + "format": "Formater", + "clear": "Effacer", + "formatError": "Impossible de formater : l'entrée n'est pas un JSON valide", + "placeholder": "Collez ou saisissez du JSON ici…", + "outputPlaceholder": "Les correspondances apparaîtront ici…", + "matchCount": "{count, plural, one {# correspondance} other {# correspondances}}", + "copy": "Copier", + "copied": "Copié" + }, + "KeycodeInspector": { + "title": "Inspecteur de keycode", + "subtitle": "Appuyez sur n'importe quelle touche pour inspecter son événement clavier JavaScript", + "capture": { + "prompt": "Appuyez sur une touche…", + "ready": "À l'écoute — appuyez sur une touche de votre clavier", + "clickToFocus": "Cliquez ici pour donner le focus, puis appuyez sur une touche" + }, + "hero": { + "spaceKey": "(Espace)", + "keyLabel": "event.key", + "repeat": "répétition" + }, + "copyJson": "Copier le JSON", + "copiedJson": "JSON de l'événement copié dans le presse-papiers", + "deprecated": "obsolète", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standard", + "left": "Gauche", + "right": "Droite", + "numpad": "Pavé numérique", + "unknown": "Inconnu" + }, + "history": { + "title": "Historique", + "clear": "Effacer l'historique", + "empty": "Les appuis sur les touches apparaîtront ici." + } + }, + "MarkdownTableGenerator": { + "title": "Générateur de tableaux Markdown", + "subtitle": "Modifiez une grille, collez depuis un tableur et copiez un tableau Markdown ou HTML joliment aligné", + "addRow": "Ajouter une ligne", + "addColumn": "Ajouter une colonne", + "importLabel": "Importer", + "importPlaceholder": "Collez du TSV (depuis Excel / Google Sheets) ou du CSV ici…", + "importButton": "Importer dans la grille", + "pasteClipboard": "Coller depuis le presse-papiers", + "importEmpty": "Rien à importer — collez d'abord quelques lignes", + "clipboardError": "Impossible de lire le presse-papiers", + "clear": "Effacer", + "panels": { + "editor": "Éditeur de tableau", + "output": "Résultat" + }, + "align": { + "left": "Aligner à gauche", + "center": "Centrer", + "right": "Aligner à droite" + }, + "headerPlaceholder": "En-tête", + "cellPlaceholder": "Cellule", + "removeRow": "Supprimer la ligne", + "removeColumn": "Supprimer la colonne", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Copier", + "copied": "Copié" + }, + "TimezoneConverter": { + "title": "Convertisseur de fuseaux horaires", + "subtitle": "Convertissez un moment à travers le monde pour planifier vos réunions.", + "dateTimeLabel": "Date et heure", + "now": "Maintenant", + "sourceZoneLabel": "Fuseau source", + "selectZone": "Sélectionner un fuseau", + "searchZone": "Rechercher des fuseaux...", + "noZone": "Aucun fuseau trouvé.", + "scrubLabel": "Décaler l'heure", + "reset": "Réinitialiser", + "targetsLabel": "Fuseaux cibles", + "addZone": "Ajouter un fuseau", + "remove": "Supprimer", + "localBadge": "Local", + "invalidDate": "Saisissez une date et une heure valides.", + "bands": { + "work": "Heures ouvrées (09:00–18:00)", + "edge": "Heures limites (tôt / soir)", + "night": "Nuit" + } + }, + "TokenCounter": { + "title": "Compteur de tokens LLM", + "subtitle": "Comptez les tokens, visualisez les segments et estimez le coût de l'API.", + "inputLabel": "Texte", + "sample": "Exemple", + "clear": "Effacer", + "placeholder": "Collez du texte, du code ou du JSON pour compter les tokens...", + "encoding": "Encodage", + "loading": "Chargement du tokeniseur...", + "encodings": { + "o200k": "o200k_base (GPT-4o / série o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Caractères", + "words": "Mots", + "ratio": "Tokens/mot", + "ratioSub": "approx." + }, + "claudeRow": "Claude (approximatif)", + "estimate": "estimation", + "viz": { + "title": "Visualisation des tokens", + "toggle": "Afficher", + "hint": "Activez pour colorer chaque token comme un segment.", + "empty": "Saisissez du texte pour visualiser les tokens.", + "truncated": "Affichage des {limit} premiers tokens sur {total}." + }, + "cost": { + "title": "Calculateur de coût", + "disclaimer": "valeurs par défaut modifiables — vérifiez les tarifs actuels", + "model": "Modèle", + "expectedOutput": "Tokens de sortie attendus", + "customInput": "Entrée $/MTok", + "customOutput": "Sortie $/MTok", + "inputCost": "Coût d'entrée", + "outputCost": "Coût de sortie", + "total": "Total", + "per1000": "Pour 1000 appels" + } + }, + "WebhookTester": { + "title": "Testeur de webhook", + "subtitle": "Créez une URL temporaire et inspectez les requêtes webhook entrantes en direct.", + "intro": { + "title": "Créez une URL de webhook pour commencer à capturer les requêtes", + "hint": "Vous obtiendrez une URL non devinable qui enregistre chaque requête entrante. Elle expire au bout de 24 heures." + }, + "createBtn": "Créer une URL de webhook", + "creating": "Création…", + "urlLabel": "Votre URL de webhook", + "expiresIn": "Expire dans {countdown}", + "copyUrlBtn": "Copier l'URL", + "copied": "Copié", + "clearBtn": "Effacer", + "newBinBtn": "Nouvelle URL", + "requestsTitle": "Requêtes", + "selectPrompt": "Sélectionnez une requête pour en inspecter les détails.", + "expiredNotice": "Cette URL de webhook a expiré. Créez-en une nouvelle pour continuer les tests.", + "base64Badge": "base64", + "truncatedBadge": "tronqué", + "empty": { + "title": "En attente de requêtes…", + "hint": "Envoyez une requête à votre URL et elle apparaîtra ici instantanément. Essayez ceci :" + }, + "detail": { + "receivedAt": "Reçu", + "source": "IP source", + "contentType": "Content-Type", + "query": "Paramètres de requête", + "headers": "En-têtes", + "body": "Corps", + "copyBody": "Copier le corps", + "emptyBody": "Aucun corps" + }, + "errors": { + "createFailed": "Échec de la création de l'URL de webhook. Veuillez réessayer.", + "clearFailed": "Échec de l'effacement des requêtes. Veuillez réessayer." + } + }, + "WebsocketTester": { + "title": "Testeur WebSocket", + "subtitle": "Connectez-vous à un point de terminaison WebSocket et envoyez, recevez et inspectez des messages en direct", + "urlLabel": "URL du point de terminaison", + "protocolsLabel": "Sous-protocoles", + "protocolsPlaceholder": "facultatif, séparés par des virgules", + "connect": "Connecter", + "disconnect": "Déconnecter", + "status": { + "disconnected": "Déconnecté", + "connecting": "Connexion", + "connected": "Connecté", + "error": "Erreur" + }, + "messages": { + "connecting": "Connexion à {url}…", + "connected": "Connexion établie", + "connectedWithProtocol": "Connexion établie (protocole : {protocol})", + "invalidUrl": "URL invalide — doit commencer par ws:// ou wss://", + "connectFailed": "Échec de l'ouverture de la connexion", + "socketError": "Une erreur de connexion s'est produite", + "notConnected": "Non connecté — connectez-vous d'abord", + "binaryMessage": "Message binaire reçu" + }, + "log": { + "title": "Journal des messages", + "empty": "Aucun message pour l'instant. Connectez-vous et commencez à envoyer.", + "copyAll": "Tout copier", + "download": "Télécharger en .txt", + "clear": "Effacer le journal", + "prettyPrint": "Formater le JSON", + "showRaw": "Afficher le texte brut" + }, + "composer": { + "placeholder": "Saisissez un message à envoyer…", + "send": "Envoyer", + "formatJson": "Formater en JSON", + "invalidJson": "Le message n'est pas un JSON valide", + "hint": "Cmd/Ctrl+Entrée pour envoyer" + }, + "ping": { + "label": "Auto-ping", + "seconds": "s", + "intervalTitle": "Intervalle de ping en secondes", + "payloadPlaceholder": "charge utile du ping" + } + }, + "WhoisLookup": { + "title": "Recherche Whois", + "subtitle": "Consultez les détails d'enregistrement de n'importe quel domaine ou adresse IP via RDAP", + "placeholder": "example.com ou 8.8.8.8", + "lookupBtn": "Rechercher", + "looking": "Recherche…", + "refreshBtn": "Actualiser", + "tryLabel": "Essayez :", + "copy": "Copier", + "copied": "Copié", + "notAvailable": "Non disponible", + "summary": { + "registrar": "Bureau d'enregistrement", + "network": "Réseau" + }, + "dates": { + "created": "Créé", + "updated": "Mis à jour", + "expires": "Expire" + }, + "sections": { + "status": "Statut", + "nameservers": "Serveurs de noms", + "contacts": "Contacts", + "rawRdap": "RDAP brut" + }, + "contacts": { + "roles": "Rôles", + "name": "Nom", + "organization": "Organisation" + }, + "errors": { + "invalidTarget": "Saisissez un nom de domaine ou une adresse IP valide", + "lookupFailed": "Échec de la recherche (statut {status})" + } } } diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index d19794a0..685ce44f 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Kalkulator Chmod", + "codeScreenshot": "Tangkapan Kode", + "cssGenerators": "Generator CSS", + "exifViewer": "Penampil EXIF", + "faviconGenerator": "Generator Favicon", + "jsonDiff": "Diff JSON", + "jsonpathPlayground": "Playground JSONPath", + "keycodeInspector": "Inspektur Keycode", + "markdownTableGenerator": "Tabel Markdown", + "timezoneConverter": "Konverter Zona Waktu", + "tokenCounter": "Penghitung Token", + "webhookTester": "Penguji Webhook", + "websocketTester": "Penguji WebSocket", + "whoisLookup": "Pencarian Whois" }, "Help": { "title": "Bantuan & dokumentasi", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Generator & Pemeriksa Bcrypt", + "description": "Hash kata sandi dengan bcrypt dan verifikasi hash yang ada." + }, + "chmodCalculator": { + "title": "Kalkulator Chmod", + "description": "Konversi izin Unix antara oktal, simbolik, dan kotak centang." + }, + "codeScreenshot": { + "title": "Tangkapan Kode", + "description": "Ubah kode sumber menjadi gambar indah yang mudah dibagikan." + }, + "cssGenerators": { + "title": "Generator CSS", + "description": "Generator box-shadow, border-radius, dan clamp() fluid." + }, + "exifViewer": { + "title": "Penampil & Penghapus EXIF", + "description": "Periksa dan hapus metadata EXIF foto secara lokal." + }, + "faviconGenerator": { + "title": "Generator Favicon", + "description": "Buat favicon, ikon aplikasi, dan cuplikan manifest." + }, + "jsonDiff": { + "title": "Diff JSON", + "description": "Perbandingan dua dokumen JSON secara semantik dan sadar struktur." + }, + "jsonpathPlayground": { + "title": "Playground JSONPath", + "description": "Kueri JSON dengan JSONPath atau JMESPath dan lihat hasilnya secara langsung." + }, + "keycodeInspector": { + "title": "Inspektur Keycode", + "description": "Tekan tombol untuk melihat kode event JavaScript dan tombol pengubahnya." + }, + "markdownTableGenerator": { + "title": "Generator Tabel Markdown", + "description": "Buat tabel Markdown dan HTML di editor grid mirip spreadsheet." + }, + "timezoneConverter": { + "title": "Konverter Zona Waktu", + "description": "Konversi satu waktu ke banyak zona dengan petunjuk pergeseran hari dan jam kerja." + }, + "tokenCounter": { + "title": "Penghitung Token LLM", + "description": "Hitung token LLM, visualisasikan potongan, dan perkirakan biaya API." + }, + "webhookTester": { + "title": "Penguji Webhook", + "description": "Tangkap dan periksa permintaan webhook masuk secara real time." + }, + "websocketTester": { + "title": "Penguji WebSocket", + "description": "Sambungkan ke endpoint WebSocket dan bertukar pesan secara real time." + }, + "whoisLookup": { + "title": "Pencarian Whois", + "description": "Cari detail pendaftaran domain dan IP melalui RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Generator & Pemeriksa Bcrypt", + "subtitle": "Hash kata sandi dengan bcrypt dan verifikasi hash — semuanya tetap di browser Anda", + "tabs": { + "hash": "Hash", + "verify": "Verifikasi" + }, + "showPassword": "Tampilkan kata sandi", + "hidePassword": "Sembunyikan kata sandi", + "copy": "Salin", + "copied": "Disalin", + "hash": { + "passwordLabel": "Kata sandi", + "passwordPlaceholder": "Masukkan kata sandi untuk di-hash…", + "costLabel": "Faktor biaya", + "costHint": "Biaya yang lebih tinggi menggandakan pekerjaan per langkah. 10-12 adalah pilihan umum untuk produksi.", + "slowWarning": "Faktor biaya 13 ke atas bisa memakan waktu beberapa detik untuk dihitung.", + "generate": "Buat hash", + "generating": "Meng-hash…", + "resultLabel": "Hash Bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Anatomi hash", + "algorithm": "algoritma", + "cost": "biaya", + "salt": "salt (22 karakter)", + "hash": "hash (31 karakter)" + } + }, + "verify": { + "passwordLabel": "Kata sandi", + "passwordPlaceholder": "Masukkan kata sandi yang akan diperiksa…", + "hashLabel": "Hash Bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Masukkan kata sandi dan hash bcrypt untuk membandingkannya.", + "invalidHash": "Ini sepertinya bukan hash bcrypt yang valid.", + "checking": "Membandingkan…", + "match": "Cocok — kata sandi ini menghasilkan hash ini.", + "noMatch": "Tidak cocok — kata sandi ini tidak menghasilkan hash ini." + } + }, + "ChmodCalculator": { + "title": "Kalkulator Chmod", + "subtitle": "Konversi izin file Unix antara kotak centang, oktal, dan notasi simbolik", + "grid": { + "title": "Izin", + "read": "Baca", + "write": "Tulis", + "execute": "Eksekusi", + "owner": "Pemilik", + "group": "Grup", + "others": "Lainnya" + }, + "special": { + "title": "Bit khusus", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Oktal", + "placeholder": "755 atau 4755", + "invalid": "Masukkan 3 atau 4 digit oktal (0-7)." + }, + "symbolic": { + "label": "Simbolik", + "placeholder": "rwxr-xr-x", + "invalid": "Masukkan 9 karakter seperti rwxr-xr-x (s/S, t/T diperbolehkan)." + }, + "output": { + "title": "Perintah", + "copy": "Salin", + "copied": "Disalin" + }, + "explain": { + "line": "{cls} dapat {perms}.", + "none": "{cls} tidak memiliki izin.", + "setuid": "Setuid: file berjalan dengan hak istimewa pemiliknya.", + "setgid": "Setgid: file berjalan dengan hak istimewa grupnya (file baru dalam direktori mewarisi grupnya).", + "sticky": "Sticky: hanya pemilik file yang dapat menghapus atau mengganti namanya di dalam direktori ini." + }, + "presets": { + "title": "Preset umum", + "octal": "Oktal", + "permissions": "Izin", + "description": "Penggunaan umum", + "p644": "File biasa: pemilik menulis, semua orang membaca.", + "p755": "File eksekusi dan direktori: semua orang dapat membaca dan masuk.", + "p600": "File privat: hanya pemilik yang membaca dan menulis.", + "p700": "File eksekusi dan direktori privat: hanya pemilik.", + "p664": "File yang dapat ditulis grup.", + "p775": "Direktori dan file eksekusi yang dapat ditulis grup.", + "p400": "Baca saja untuk pemilik (mis. kunci privat SSH)." + } + }, + "CodeScreenshot": { + "title": "Tangkapan Kode", + "subtitle": "Ubah kode menjadi gambar indah yang mudah dibagikan", + "languageLabel": "Bahasa", + "autoDetect": "Deteksi otomatis", + "backgroundLabel": "Latar belakang", + "paddingLabel": "Padding", + "filenameLabel": "Nama file", + "filenamePlaceholder": "example.ts", + "windowLabel": "Titik jendela", + "themeLabel": "Tema jendela", + "themeDark": "Gelap", + "themeLight": "Terang", + "scaleLabel": "Skala ekspor", + "copyImage": "Salin gambar", + "copied": "Disalin", + "exportPng": "Ekspor PNG", + "inputLabel": "Kode", + "detected": "Terdeteksi: {language}", + "clear": "Bersihkan", + "placeholder": "Tempel kode Anda di sini…", + "previewLabel": "Pratinjau", + "emptyPreview": "Pratinjau kode Anda akan muncul di sini", + "exportFailed": "Gagal mengekspor gambar", + "copyUnsupported": "Menyalin gambar tidak didukung di browser ini", + "copyFailed": "Gagal menyalin gambar", + "backgrounds": { + "ocean": "Samudra", + "sunset": "Senja", + "candy": "Permen", + "forest": "Hutan", + "midnight": "Tengah malam", + "gold": "Emas", + "aurora": "Aurora", + "slate": "Batu tulis", + "transparent": "Transparan" + } + }, + "CssGenerators": { + "title": "Generator CSS", + "subtitle": "Box-shadow, border-radius, dan clamp() fluid — salin CSS-nya.", + "preview": "Pratinjau", + "output": "CSS", + "copy": "Salin", + "copied": "Disalin", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() fluid" + }, + "boxShadow": { + "presets": "Preset", + "layer": "Lapisan", + "addLayer": "Tambah lapisan", + "removeLayer": "Hapus lapisan", + "x": "X", + "y": "Y", + "blur": "Blur", + "spread": "Sebaran", + "color": "Warna", + "alpha": "Alfa", + "inset": "Inset", + "presetNames": { + "subtleCard": "Kartu halus", + "elevated": "Terangkat", + "hardOffset": "Offset tegas", + "neonGlow": "Cahaya neon", + "inner": "Bayangan dalam", + "layeredSmooth": "Berlapis halus" + } + }, + "borderRadius": { + "modeLabel": "Mode", + "modes": { + "simple": "Per sudut", + "blob": "Blob organik" + }, + "unit": "Satuan", + "linkCorners": "Tautkan sudut", + "randomize": "Acak", + "horizontal": "Radius horizontal", + "vertical": "Radius vertikal", + "corners": { + "tl": "Kiri atas", + "tr": "Kanan atas", + "br": "Kanan bawah", + "bl": "Kiri bawah" + } + }, + "clamp": { + "minFont": "Font min (px)", + "maxFont": "Font maks (px)", + "minViewport": "Viewport min (px)", + "maxViewport": "Viewport maks (px)", + "rootFont": "Font root (px)", + "viewportWidth": "Viewport simulasi", + "computedSize": "Ukuran terhitung", + "slope": "Kemiringan", + "intercept": "Intersep", + "previewText": "The quick brown fox", + "invalid": "Masukkan batas viewport yang valid dan berbeda." + } + }, + "ExifViewer": { + "title": "Penampil & Penghapus EXIF", + "subtitle": "Periksa metadata foto dan hapus di browser Anda", + "dropTitle": "Jatuhkan gambar di sini", + "dropHint": "atau klik untuk menjelajah — gambar Anda tidak pernah meninggalkan perangkat", + "browse": "Pilih gambar", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Membaca metadata…", + "parseFailed": "Gagal membaca gambar ini", + "clear": "Hapus gambar", + "gpsWarning": "Berisi lokasi GPS", + "openMap": "Lihat di OpenStreetMap", + "noMetadata": "Tidak ada metadata tertanam yang ditemukan pada gambar ini.", + "facts": { + "name": "Nama", + "type": "Tipe", + "dimensions": "Dimensi", + "size": "Ukuran" + }, + "groups": { + "camera": "Kamera", + "exposure": "Pencahayaan", + "timestamps": "Stempel waktu", + "gps": "Lokasi GPS", + "software": "Perangkat lunak & lainnya" + }, + "gpsCard": { + "latitude": "Lintang", + "longitude": "Bujur", + "altitude": "Ketinggian" + }, + "raw": { + "title": "Semua metadata", + "copyJson": "Salin JSON", + "copied": "Disalin" + }, + "remove": { + "title": "Hapus metadata", + "jpegDescription": "Hapus EXIF, XMP, IPTC, dan komentar tanpa kehilangan kualitas dan tanpa mengompresi ulang gambar.", + "reencodeDescription": "Encode ulang gambar untuk menghapus metadata tertanam. Ini mengompresi ulang piksel.", + "keepIcc": "Pertahankan profil warna (ICC)", + "stripButton": "Hapus metadata", + "reencodeButton": "Encode ulang & hapus", + "unsupported": "Penghapusan metadata tidak didukung untuk tipe file ini.", + "failed": "Gagal menghapus metadata", + "before": "Sebelum", + "after": "Sesudah", + "download": "Unduh gambar yang sudah dibersihkan" + } + }, + "FaviconGenerator": { + "title": "Generator Favicon", + "subtitle": "Buat favicon dan ikon aplikasi dari satu gambar", + "dropTitle": "Jatuhkan gambar di sini", + "dropHint": "PNG, JPEG atau SVG — klik untuk menjelajah", + "browse": "Pilih gambar", + "clear": "Hapus gambar", + "backgroundLabel": "Latar belakang", + "transparent": "Transparan", + "solidColor": "Solid", + "cornerLabel": "Bentuk sudut", + "cornerNone": "Persegi", + "cornerRounded": "Membulat", + "cornerCircle": "Lingkaran", + "maskPaddingLabel": "Padding maskable", + "maskable": "maskable", + "previewLabel": "Pratinjau", + "copy": "Salin", + "headSnippet": "Cuplikan HTML head", + "downloadIco": "Unduh favicon.ico", + "downloadZip": "Unduh semua (.zip)" + }, + "JsonDiff": { + "title": "Diff JSON", + "subtitle": "Perbandingan semantik dan sadar struktur atas dua dokumen JSON — urutan kunci dan format diabaikan", + "options": { + "ignoreArrayOrder": "Abaikan urutan array" + }, + "loadBothSamples": "Muat contoh", + "sample": "Contoh", + "format": "Format", + "clear": "Bersihkan", + "formatError": "Tidak dapat memformat: input bukan JSON yang valid", + "placeholder": "Tempel atau ketik JSON di sini…", + "panels": { + "left": "Asli (kiri)", + "right": "Dimodifikasi (kanan)", + "results": "Perbedaan" + }, + "summary": { + "added": "{count} ditambahkan", + "removed": "{count} dihapus", + "changed": "{count} diubah" + }, + "types": { + "added": "Ditambahkan", + "removed": "Dihapus", + "changed": "Diubah" + }, + "copyChanges": "Salin sebagai JSON", + "copied": "Disalin", + "empty": "Tempel JSON ke kedua panel untuk melihat perbedaannya.", + "identical": "Kedua dokumen identik secara semantik." + }, + "JsonpathPlayground": { + "title": "Playground JSONPath", + "subtitle": "Kueri dokumen JSON dengan JSONPath atau JMESPath dan lihat hasilnya secara langsung", + "engineLabel": "Engine", + "queryLabel": "Kueri", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Contekan", + "cheats": { + "jpAllTitles": "Semua judul buku", + "jpAllAuthors": "Setiap penulis, di mana pun dalam dokumen", + "jpFirstBook": "Buku pertama", + "jpLastBook": "Buku terakhir", + "jpSlice": "Dua buku pertama (slice)", + "jpFilterPrice": "Buku lebih murah dari 40", + "jpFilterInStock": "Judul buku yang tersedia", + "jpRecursive": "Semua nilai, secara rekursif", + "jmAllTitles": "Semua judul buku", + "jmFirstAuthor": "Penulis buku pertama", + "jmLastBook": "Buku terakhir", + "jmSlice": "Dua buku pertama (slice)", + "jmFilterPrice": "Judul buku yang lebih murah dari 40", + "jmCount": "Hitung buku yang tersedia", + "jmProjection": "Bentuk ulang setiap buku menjadi objek baru", + "jmSort": "Judul diurutkan berdasarkan harga" + }, + "panels": { + "input": "Input JSON", + "results": "Hasil" + }, + "sample": "Contoh", + "format": "Format", + "clear": "Bersihkan", + "formatError": "Tidak dapat memformat: input bukan JSON yang valid", + "placeholder": "Tempel atau ketik JSON di sini…", + "outputPlaceholder": "Kecocokan akan muncul di sini…", + "matchCount": "{count, plural, one {# kecocokan} other {# kecocokan}}", + "copy": "Salin", + "copied": "Disalin" + }, + "KeycodeInspector": { + "title": "Inspektur Keycode", + "subtitle": "Tekan tombol apa saja untuk memeriksa event keyboard JavaScript-nya", + "capture": { + "prompt": "Tekan tombol apa saja…", + "ready": "Mendengarkan — tekan tombol apa saja di keyboard Anda", + "clickToFocus": "Klik di sini untuk fokus, lalu tekan tombol apa saja" + }, + "hero": { + "spaceKey": "(Spasi)", + "keyLabel": "event.key", + "repeat": "ulang" + }, + "copyJson": "Salin JSON", + "copiedJson": "JSON event disalin ke clipboard", + "deprecated": "usang", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standar", + "left": "Kiri", + "right": "Kanan", + "numpad": "Numpad", + "unknown": "Tidak diketahui" + }, + "history": { + "title": "Riwayat", + "clear": "Bersihkan riwayat", + "empty": "Penekanan tombol akan muncul di sini." + } + }, + "MarkdownTableGenerator": { + "title": "Generator Tabel Markdown", + "subtitle": "Edit grid, tempel dari spreadsheet, dan salin tabel Markdown atau HTML yang tersejajar rapi", + "addRow": "Tambah baris", + "addColumn": "Tambah kolom", + "importLabel": "Impor", + "importPlaceholder": "Tempel TSV (dari Excel / Google Sheets) atau CSV di sini…", + "importButton": "Impor ke grid", + "pasteClipboard": "Tempel dari clipboard", + "importEmpty": "Tidak ada yang diimpor — tempel beberapa baris dulu", + "clipboardError": "Tidak dapat membaca dari clipboard", + "clear": "Bersihkan", + "panels": { + "editor": "Editor tabel", + "output": "Output" + }, + "align": { + "left": "Rata kiri", + "center": "Rata tengah", + "right": "Rata kanan" + }, + "headerPlaceholder": "Header", + "cellPlaceholder": "Sel", + "removeRow": "Hapus baris", + "removeColumn": "Hapus kolom", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Salin", + "copied": "Disalin" + }, + "TimezoneConverter": { + "title": "Konverter Zona Waktu", + "subtitle": "Konversi satu momen ke seluruh dunia untuk merencanakan rapat.", + "dateTimeLabel": "Tanggal & waktu", + "now": "Sekarang", + "sourceZoneLabel": "Zona waktu sumber", + "selectZone": "Pilih zona", + "searchZone": "Cari zona waktu...", + "noZone": "Zona waktu tidak ditemukan.", + "scrubLabel": "Geser waktu", + "reset": "Atur ulang", + "targetsLabel": "Zona tujuan", + "addZone": "Tambah zona", + "remove": "Hapus", + "localBadge": "Lokal", + "invalidDate": "Masukkan tanggal dan waktu yang valid.", + "bands": { + "work": "Jam kerja (09:00–18:00)", + "edge": "Jam pinggir (pagi / malam)", + "night": "Malam" + } + }, + "TokenCounter": { + "title": "Penghitung Token LLM", + "subtitle": "Hitung token, visualisasikan potongan, dan perkirakan biaya API.", + "inputLabel": "Teks", + "sample": "Contoh", + "clear": "Bersihkan", + "placeholder": "Tempel teks, kode, atau JSON untuk menghitung token...", + "encoding": "Encoding", + "loading": "Memuat tokenizer...", + "encodings": { + "o200k": "o200k_base (GPT-4o / seri o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Token", + "characters": "Karakter", + "words": "Kata", + "ratio": "Token/kata", + "ratioSub": "kira-kira" + }, + "claudeRow": "Claude (perkiraan)", + "estimate": "perkiraan", + "viz": { + "title": "Visualisasi token", + "toggle": "Tampilkan", + "hint": "Alihkan untuk mewarnai setiap token sebagai satu potongan.", + "empty": "Masukkan teks untuk memvisualisasikan token.", + "truncated": "Menampilkan {limit} token pertama dari {total}." + }, + "cost": { + "title": "Kalkulator biaya", + "disclaimer": "nilai bawaan dapat diedit — verifikasi harga terkini", + "model": "Model", + "expectedOutput": "Token output yang diharapkan", + "customInput": "Input $/MTok", + "customOutput": "Output $/MTok", + "inputCost": "Biaya input", + "outputCost": "Biaya output", + "total": "Total", + "per1000": "Per 1000 panggilan" + } + }, + "WebhookTester": { + "title": "Penguji Webhook", + "subtitle": "Buat URL sementara dan periksa permintaan webhook masuk secara langsung.", + "intro": { + "title": "Buat URL webhook untuk mulai menangkap permintaan", + "hint": "Anda akan mendapat URL yang tidak dapat ditebak yang merekam setiap permintaan masuk. URL kedaluwarsa setelah 24 jam." + }, + "createBtn": "Buat URL webhook", + "creating": "Membuat…", + "urlLabel": "URL webhook Anda", + "expiresIn": "Kedaluwarsa dalam {countdown}", + "copyUrlBtn": "Salin URL", + "copied": "Disalin", + "clearBtn": "Bersihkan", + "newBinBtn": "URL baru", + "requestsTitle": "Permintaan", + "selectPrompt": "Pilih permintaan untuk memeriksa detailnya.", + "expiredNotice": "URL webhook ini telah kedaluwarsa. Buat yang baru untuk terus menguji.", + "base64Badge": "base64", + "truncatedBadge": "dipotong", + "empty": { + "title": "Menunggu permintaan…", + "hint": "Kirim permintaan ke URL Anda dan itu akan muncul di sini seketika. Coba ini:" + }, + "detail": { + "receivedAt": "Diterima", + "source": "IP sumber", + "contentType": "Content-Type", + "query": "Parameter kueri", + "headers": "Header", + "body": "Body", + "copyBody": "Salin body", + "emptyBody": "Tidak ada body" + }, + "errors": { + "createFailed": "Gagal membuat URL webhook. Silakan coba lagi.", + "clearFailed": "Gagal membersihkan permintaan. Silakan coba lagi." + } + }, + "WebsocketTester": { + "title": "Penguji WebSocket", + "subtitle": "Sambungkan ke endpoint WebSocket lalu kirim, terima, dan periksa pesan secara langsung", + "urlLabel": "URL endpoint", + "protocolsLabel": "Subprotokol", + "protocolsPlaceholder": "opsional, dipisahkan koma", + "connect": "Hubungkan", + "disconnect": "Putuskan", + "status": { + "disconnected": "Terputus", + "connecting": "Menghubungkan", + "connected": "Terhubung", + "error": "Kesalahan" + }, + "messages": { + "connecting": "Menghubungkan ke {url}…", + "connected": "Koneksi berhasil dibuat", + "connectedWithProtocol": "Koneksi berhasil dibuat (protokol: {protocol})", + "invalidUrl": "URL tidak valid — harus dimulai dengan ws:// atau wss://", + "connectFailed": "Gagal membuka koneksi", + "socketError": "Terjadi kesalahan koneksi", + "notConnected": "Tidak terhubung — hubungkan dulu", + "binaryMessage": "Menerima pesan biner" + }, + "log": { + "title": "Log Pesan", + "empty": "Belum ada pesan. Hubungkan dan mulai mengirim.", + "copyAll": "Salin semua", + "download": "Unduh sebagai .txt", + "clear": "Bersihkan log", + "prettyPrint": "Cetak rapi JSON", + "showRaw": "Tampilkan teks mentah" + }, + "composer": { + "placeholder": "Ketik pesan untuk dikirim…", + "send": "Kirim", + "formatJson": "Format sebagai JSON", + "invalidJson": "Pesan bukan JSON yang valid", + "hint": "Cmd/Ctrl+Enter untuk mengirim" + }, + "ping": { + "label": "Ping otomatis", + "seconds": "dtk", + "intervalTitle": "Interval ping dalam detik", + "payloadPlaceholder": "payload ping" + } + }, + "WhoisLookup": { + "title": "Pencarian Whois", + "subtitle": "Cari detail pendaftaran untuk domain atau alamat IP apa pun melalui RDAP", + "placeholder": "example.com atau 8.8.8.8", + "lookupBtn": "Cari", + "looking": "Mencari…", + "refreshBtn": "Segarkan", + "tryLabel": "Coba:", + "copy": "Salin", + "copied": "Disalin", + "notAvailable": "Tidak tersedia", + "summary": { + "registrar": "Registrar", + "network": "Jaringan" + }, + "dates": { + "created": "Dibuat", + "updated": "Diperbarui", + "expires": "Kedaluwarsa" + }, + "sections": { + "status": "Status", + "nameservers": "Nameserver", + "contacts": "Kontak", + "rawRdap": "RDAP mentah" + }, + "contacts": { + "roles": "Peran", + "name": "Nama", + "organization": "Organisasi" + }, + "errors": { + "invalidTarget": "Masukkan nama domain atau alamat IP yang valid", + "lookupFailed": "Pencarian gagal (status {status})" + } } } diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 4e95fc93..c94ecb52 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Calcolatore Chmod", + "codeScreenshot": "Screenshot di codice", + "cssGenerators": "Generatori CSS", + "exifViewer": "Visualizzatore EXIF", + "faviconGenerator": "Generatore di favicon", + "jsonDiff": "Diff JSON", + "jsonpathPlayground": "Playground JSONPath", + "keycodeInspector": "Ispettore di keycode", + "markdownTableGenerator": "Tabella Markdown", + "timezoneConverter": "Convertitore di fusi orari", + "tokenCounter": "Contatore di token", + "webhookTester": "Tester di webhook", + "websocketTester": "Tester WebSocket", + "whoisLookup": "Ricerca Whois" }, "Help": { "title": "Aiuto e documentazione", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Generatore e verificatore Bcrypt", + "description": "Genera hash di password con bcrypt e verifica hash esistenti." + }, + "chmodCalculator": { + "title": "Calcolatore Chmod", + "description": "Converti i permessi Unix tra ottale, simbolico e caselle di controllo." + }, + "codeScreenshot": { + "title": "Screenshot di codice", + "description": "Trasforma il codice sorgente in immagini eleganti e condivisibili." + }, + "cssGenerators": { + "title": "Generatori CSS", + "description": "Generatori di box-shadow, border-radius e clamp() fluido." + }, + "exifViewer": { + "title": "Visualizzatore e rimozione EXIF", + "description": "Ispeziona e rimuovi i metadati EXIF delle foto in locale." + }, + "faviconGenerator": { + "title": "Generatore di favicon", + "description": "Genera favicon, icone dell'app e snippet di manifest." + }, + "jsonDiff": { + "title": "Diff JSON", + "description": "Confronto semantico e consapevole della struttura tra due documenti JSON." + }, + "jsonpathPlayground": { + "title": "Playground JSONPath", + "description": "Interroga JSON con JSONPath o JMESPath e vedi i risultati in tempo reale." + }, + "keycodeInspector": { + "title": "Ispettore di keycode", + "description": "Premi un tasto per vedere i suoi codici evento JavaScript e i modificatori." + }, + "markdownTableGenerator": { + "title": "Generatore di tabelle Markdown", + "description": "Crea tabelle Markdown e HTML in un editor a griglia simile a un foglio di calcolo." + }, + "timezoneConverter": { + "title": "Convertitore di fusi orari", + "description": "Converti un orario tra molti fusi con indicazioni di cambio giorno e orario lavorativo." + }, + "tokenCounter": { + "title": "Contatore di token LLM", + "description": "Conta i token LLM, visualizza i chunk e stima il costo dell'API." + }, + "webhookTester": { + "title": "Tester di webhook", + "description": "Cattura e ispeziona le richieste webhook in arrivo in tempo reale." + }, + "websocketTester": { + "title": "Tester WebSocket", + "description": "Connettiti a endpoint WebSocket e scambia messaggi in tempo reale." + }, + "whoisLookup": { + "title": "Ricerca Whois", + "description": "Cerca i dettagli di registrazione di domini e IP tramite RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Generatore e verificatore Bcrypt", + "subtitle": "Genera hash di password con bcrypt e verifica gli hash — tutto resta nel tuo browser", + "tabs": { + "hash": "Hash", + "verify": "Verifica" + }, + "showPassword": "Mostra password", + "hidePassword": "Nascondi password", + "copy": "Copia", + "copied": "Copiato", + "hash": { + "passwordLabel": "Password", + "passwordPlaceholder": "Inserisci una password da hashare…", + "costLabel": "Fattore di costo", + "costHint": "Un costo più alto raddoppia il lavoro per passaggio. 10-12 è una scelta comune in produzione.", + "slowWarning": "I fattori di costo pari o superiori a 13 possono richiedere diversi secondi per il calcolo.", + "generate": "Genera hash", + "generating": "Hashing…", + "resultLabel": "Hash bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Anatomia dell'hash", + "algorithm": "algoritmo", + "cost": "costo", + "salt": "salt (22 caratteri)", + "hash": "hash (31 caratteri)" + } + }, + "verify": { + "passwordLabel": "Password", + "passwordPlaceholder": "Inserisci la password da verificare…", + "hashLabel": "Hash bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Inserisci una password e un hash bcrypt per confrontarli.", + "invalidHash": "Questo non sembra un hash bcrypt valido.", + "checking": "Confronto in corso…", + "match": "Corrispondenza — la password produce questo hash.", + "noMatch": "Nessuna corrispondenza — la password non produce questo hash." + } + }, + "ChmodCalculator": { + "title": "Calcolatore Chmod", + "subtitle": "Converti i permessi dei file Unix tra caselle di controllo, ottale e notazione simbolica", + "grid": { + "title": "Permessi", + "read": "Lettura", + "write": "Scrittura", + "execute": "Esecuzione", + "owner": "Proprietario", + "group": "Gruppo", + "others": "Altri" + }, + "special": { + "title": "Bit speciali", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Ottale", + "placeholder": "755 o 4755", + "invalid": "Inserisci 3 o 4 cifre ottali (0-7)." + }, + "symbolic": { + "label": "Simbolico", + "placeholder": "rwxr-xr-x", + "invalid": "Inserisci 9 caratteri come rwxr-xr-x (sono consentiti s/S, t/T)." + }, + "output": { + "title": "Comando", + "copy": "Copia", + "copied": "Copiato" + }, + "explain": { + "line": "{cls} può {perms}.", + "none": "{cls} non ha permessi.", + "setuid": "Setuid: il file viene eseguito con i privilegi del proprietario.", + "setgid": "Setgid: il file viene eseguito con i privilegi del suo gruppo (i nuovi file in una directory ne ereditano il gruppo).", + "sticky": "Sticky: solo il proprietario di un file può eliminarlo o rinominarlo all'interno di questa directory." + }, + "presets": { + "title": "Preimpostazioni comuni", + "octal": "Ottale", + "permissions": "Permessi", + "description": "Uso tipico", + "p644": "File normali: il proprietario scrive, tutti leggono.", + "p755": "Eseguibili e directory: tutti possono leggere ed entrare.", + "p600": "File privati: solo il proprietario legge e scrive.", + "p700": "Eseguibili e directory privati: solo il proprietario.", + "p664": "File scrivibili dal gruppo.", + "p775": "Directory ed eseguibili scrivibili dal gruppo.", + "p400": "Sola lettura per il proprietario (es. chiavi private SSH)." + } + }, + "CodeScreenshot": { + "title": "Screenshot di codice", + "subtitle": "Trasforma il codice in immagini eleganti e condivisibili", + "languageLabel": "Linguaggio", + "autoDetect": "Rilevamento automatico", + "backgroundLabel": "Sfondo", + "paddingLabel": "Spaziatura", + "filenameLabel": "Nome del file", + "filenamePlaceholder": "example.ts", + "windowLabel": "Puntini della finestra", + "themeLabel": "Tema della finestra", + "themeDark": "Scuro", + "themeLight": "Chiaro", + "scaleLabel": "Scala di esportazione", + "copyImage": "Copia immagine", + "copied": "Copiato", + "exportPng": "Esporta PNG", + "inputLabel": "Codice", + "detected": "Rilevato: {language}", + "clear": "Pulisci", + "placeholder": "Incolla qui il tuo codice…", + "previewLabel": "Anteprima", + "emptyPreview": "L'anteprima del tuo codice apparirà qui", + "exportFailed": "Esportazione dell'immagine non riuscita", + "copyUnsupported": "La copia delle immagini non è supportata in questo browser", + "copyFailed": "Copia dell'immagine non riuscita", + "backgrounds": { + "ocean": "Oceano", + "sunset": "Tramonto", + "candy": "Caramella", + "forest": "Foresta", + "midnight": "Mezzanotte", + "gold": "Oro", + "aurora": "Aurora", + "slate": "Ardesia", + "transparent": "Trasparente" + } + }, + "CssGenerators": { + "title": "Generatori CSS", + "subtitle": "Box-shadow, border-radius e clamp() fluido — copia il CSS.", + "preview": "Anteprima", + "output": "CSS", + "copy": "Copia", + "copied": "Copiato", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() fluido" + }, + "boxShadow": { + "presets": "Preimpostazioni", + "layer": "Livello", + "addLayer": "Aggiungi livello", + "removeLayer": "Rimuovi livello", + "x": "X", + "y": "Y", + "blur": "Sfocatura", + "spread": "Estensione", + "color": "Colore", + "alpha": "Alfa", + "inset": "Interno", + "presetNames": { + "subtleCard": "Scheda delicata", + "elevated": "Rialzato", + "hardOffset": "Offset netto", + "neonGlow": "Bagliore neon", + "inner": "Interno", + "layeredSmooth": "Stratificato morbido" + } + }, + "borderRadius": { + "modeLabel": "Modalità", + "modes": { + "simple": "Per angolo", + "blob": "Blob organico" + }, + "unit": "Unità", + "linkCorners": "Collega gli angoli", + "randomize": "Casuale", + "horizontal": "Raggi orizzontali", + "vertical": "Raggi verticali", + "corners": { + "tl": "In alto a sinistra", + "tr": "In alto a destra", + "br": "In basso a destra", + "bl": "In basso a sinistra" + } + }, + "clamp": { + "minFont": "Font min (px)", + "maxFont": "Font max (px)", + "minViewport": "Viewport min (px)", + "maxViewport": "Viewport max (px)", + "rootFont": "Font radice (px)", + "viewportWidth": "Viewport simulato", + "computedSize": "Dimensione calcolata", + "slope": "Pendenza", + "intercept": "Intercetta", + "previewText": "Ma la volpe col suo balzo", + "invalid": "Inserisci limiti di viewport validi e diversi tra loro." + } + }, + "ExifViewer": { + "title": "Visualizzatore e rimozione EXIF", + "subtitle": "Ispeziona i metadati delle foto e rimuovili nel tuo browser", + "dropTitle": "Trascina qui un'immagine", + "dropHint": "oppure clicca per sfogliare — la tua immagine non lascia mai il tuo dispositivo", + "browse": "Scegli immagine", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Lettura dei metadati…", + "parseFailed": "Impossibile leggere questa immagine", + "clear": "Rimuovi immagine", + "gpsWarning": "Contiene posizione GPS", + "openMap": "Visualizza su OpenStreetMap", + "noMetadata": "Nessun metadato incorporato trovato in questa immagine.", + "facts": { + "name": "Nome", + "type": "Tipo", + "dimensions": "Dimensioni", + "size": "Peso" + }, + "groups": { + "camera": "Fotocamera", + "exposure": "Esposizione", + "timestamps": "Timestamp", + "gps": "Posizione GPS", + "software": "Software e altro" + }, + "gpsCard": { + "latitude": "Latitudine", + "longitude": "Longitudine", + "altitude": "Altitudine" + }, + "raw": { + "title": "Tutti i metadati", + "copyJson": "Copia JSON", + "copied": "Copiato" + }, + "remove": { + "title": "Rimuovi metadati", + "jpegDescription": "Rimuovi senza perdita EXIF, XMP, IPTC e commenti senza ricomprimere l'immagine.", + "reencodeDescription": "Ricodifica l'immagine per rimuovere i metadati incorporati. Questo ricomprime i pixel.", + "keepIcc": "Mantieni il profilo colore (ICC)", + "stripButton": "Rimuovi metadati", + "reencodeButton": "Ricodifica e rimuovi", + "unsupported": "La rimozione dei metadati non è supportata per questo tipo di file.", + "failed": "Impossibile rimuovere i metadati", + "before": "Prima", + "after": "Dopo", + "download": "Scarica immagine ripulita" + } + }, + "FaviconGenerator": { + "title": "Generatore di favicon", + "subtitle": "Crea favicon e icone dell'app da una singola immagine", + "dropTitle": "Trascina qui un'immagine", + "dropHint": "PNG, JPEG o SVG — clicca per sfogliare", + "browse": "Scegli immagine", + "clear": "Rimuovi immagine", + "backgroundLabel": "Sfondo", + "transparent": "Trasparente", + "solidColor": "Tinta unita", + "cornerLabel": "Forma degli angoli", + "cornerNone": "Quadrato", + "cornerRounded": "Arrotondato", + "cornerCircle": "Cerchio", + "maskPaddingLabel": "Spaziatura maskable", + "maskable": "maskable", + "previewLabel": "Anteprima", + "copy": "Copia", + "headSnippet": "Snippet per l'head HTML", + "downloadIco": "Scarica favicon.ico", + "downloadZip": "Scarica tutto (.zip)" + }, + "JsonDiff": { + "title": "Diff JSON", + "subtitle": "Confronto semantico e consapevole della struttura tra due documenti JSON — ordine delle chiavi e formattazione ignorati", + "options": { + "ignoreArrayOrder": "Ignora l'ordine degli array" + }, + "loadBothSamples": "Carica esempi", + "sample": "Carica esempio", + "format": "Formatta", + "clear": "Pulisci", + "formatError": "Impossibile formattare: l'input non è un JSON valido", + "placeholder": "Incolla o digita qui il JSON…", + "panels": { + "left": "Originale (sinistra)", + "right": "Modificato (destra)", + "results": "Differenze" + }, + "summary": { + "added": "{count} aggiunti", + "removed": "{count} rimossi", + "changed": "{count} modificati" + }, + "types": { + "added": "Aggiunto", + "removed": "Rimosso", + "changed": "Modificato" + }, + "copyChanges": "Copia come JSON", + "copied": "Copiato", + "empty": "Incolla il JSON in entrambi i pannelli per vederne le differenze.", + "identical": "I due documenti sono semanticamente identici." + }, + "JsonpathPlayground": { + "title": "Playground JSONPath", + "subtitle": "Interroga documenti JSON con JSONPath o JMESPath e vedi i risultati in tempo reale", + "engineLabel": "Motore", + "queryLabel": "Query", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Prontuario", + "cheats": { + "jpAllTitles": "Tutti i titoli dei libri", + "jpAllAuthors": "Ogni autore, ovunque nel documento", + "jpFirstBook": "Primo libro", + "jpLastBook": "Ultimo libro", + "jpSlice": "I primi due libri (slice)", + "jpFilterPrice": "Libri sotto i 40", + "jpFilterInStock": "Titoli dei libri disponibili", + "jpRecursive": "Tutti i valori, ricorsivamente", + "jmAllTitles": "Tutti i titoli dei libri", + "jmFirstAuthor": "Autore del primo libro", + "jmLastBook": "Ultimo libro", + "jmSlice": "I primi due libri (slice)", + "jmFilterPrice": "Titoli dei libri sotto i 40", + "jmCount": "Conta i libri disponibili", + "jmProjection": "Rimodella ogni libro in un nuovo oggetto", + "jmSort": "Titoli ordinati per prezzo" + }, + "panels": { + "input": "Input JSON", + "results": "Risultati" + }, + "sample": "Carica esempio", + "format": "Formatta", + "clear": "Pulisci", + "formatError": "Impossibile formattare: l'input non è un JSON valido", + "placeholder": "Incolla o digita qui il JSON…", + "outputPlaceholder": "Le corrispondenze appariranno qui…", + "matchCount": "{count, plural, one {# corrispondenza} other {# corrispondenze}}", + "copy": "Copia", + "copied": "Copiato" + }, + "KeycodeInspector": { + "title": "Ispettore di keycode", + "subtitle": "Premi un tasto qualsiasi per ispezionare il suo evento da tastiera JavaScript", + "capture": { + "prompt": "Premi un tasto qualsiasi…", + "ready": "In ascolto — premi un tasto qualsiasi sulla tastiera", + "clickToFocus": "Clicca qui per mettere a fuoco, poi premi un tasto qualsiasi" + }, + "hero": { + "spaceKey": "(Spazio)", + "keyLabel": "event.key", + "repeat": "ripetizione" + }, + "copyJson": "Copia JSON", + "copiedJson": "JSON dell'evento copiato negli appunti", + "deprecated": "deprecato", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standard", + "left": "Sinistra", + "right": "Destra", + "numpad": "Tastierino numerico", + "unknown": "Sconosciuto" + }, + "history": { + "title": "Cronologia", + "clear": "Cancella cronologia", + "empty": "Le pressioni dei tasti appariranno qui." + } + }, + "MarkdownTableGenerator": { + "title": "Generatore di tabelle Markdown", + "subtitle": "Modifica una griglia, incolla da un foglio di calcolo e copia una tabella Markdown o HTML ben allineata", + "addRow": "Aggiungi riga", + "addColumn": "Aggiungi colonna", + "importLabel": "Importa", + "importPlaceholder": "Incolla qui TSV (da Excel / Google Sheets) o CSV…", + "importButton": "Importa nella griglia", + "pasteClipboard": "Incolla dagli appunti", + "importEmpty": "Niente da importare — incolla prima alcune righe", + "clipboardError": "Impossibile leggere dagli appunti", + "clear": "Pulisci", + "panels": { + "editor": "Editor di tabelle", + "output": "Output" + }, + "align": { + "left": "Allinea a sinistra", + "center": "Centra", + "right": "Allinea a destra" + }, + "headerPlaceholder": "Intestazione", + "cellPlaceholder": "Cella", + "removeRow": "Rimuovi riga", + "removeColumn": "Rimuovi colonna", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Copia", + "copied": "Copiato" + }, + "TimezoneConverter": { + "title": "Convertitore di fusi orari", + "subtitle": "Converti un singolo momento in tutto il mondo per pianificare le riunioni.", + "dateTimeLabel": "Data e ora", + "now": "Adesso", + "sourceZoneLabel": "Fuso orario di origine", + "selectZone": "Seleziona fuso", + "searchZone": "Cerca fusi orari...", + "noZone": "Nessun fuso orario trovato.", + "scrubLabel": "Sposta l'ora", + "reset": "Reimposta", + "targetsLabel": "Fusi di destinazione", + "addZone": "Aggiungi fuso", + "remove": "Rimuovi", + "localBadge": "Locale", + "invalidDate": "Inserisci una data e un'ora valide.", + "bands": { + "work": "Orario lavorativo (09:00–18:00)", + "edge": "Orari limite (presto / sera)", + "night": "Notte" + } + }, + "TokenCounter": { + "title": "Contatore di token LLM", + "subtitle": "Conta i token, visualizza i chunk e stima il costo dell'API.", + "inputLabel": "Testo", + "sample": "Carica esempio", + "clear": "Pulisci", + "placeholder": "Incolla testo, codice o JSON per contare i token...", + "encoding": "Codifica", + "loading": "Caricamento del tokenizer...", + "encodings": { + "o200k": "o200k_base (GPT-4o / serie o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Token", + "characters": "Caratteri", + "words": "Parole", + "ratio": "Token/parola", + "ratioSub": "circa" + }, + "claudeRow": "Claude (approssimativo)", + "estimate": "stima", + "viz": { + "title": "Visualizzazione dei token", + "toggle": "Mostra", + "hint": "Attiva per colorare ogni token come un chunk.", + "empty": "Inserisci del testo per visualizzare i token.", + "truncated": "Mostra i primi {limit} di {total} token." + }, + "cost": { + "title": "Calcolatore di costi", + "disclaimer": "valori predefiniti modificabili — verifica i prezzi attuali", + "model": "Modello", + "expectedOutput": "Token di output previsti", + "customInput": "Input $/MTok", + "customOutput": "Output $/MTok", + "inputCost": "Costo di input", + "outputCost": "Costo di output", + "total": "Totale", + "per1000": "Ogni 1000 chiamate" + } + }, + "WebhookTester": { + "title": "Tester di webhook", + "subtitle": "Crea un URL temporaneo e ispeziona le richieste webhook in arrivo dal vivo.", + "intro": { + "title": "Crea un URL webhook per iniziare a catturare le richieste", + "hint": "Otterrai un URL non indovinabile che registra ogni richiesta in arrivo. Scade dopo 24 ore." + }, + "createBtn": "Crea URL webhook", + "creating": "Creazione…", + "urlLabel": "Il tuo URL webhook", + "expiresIn": "Scade tra {countdown}", + "copyUrlBtn": "Copia URL", + "copied": "Copiato", + "clearBtn": "Pulisci", + "newBinBtn": "Nuovo URL", + "requestsTitle": "Richieste", + "selectPrompt": "Seleziona una richiesta per ispezionarne i dettagli.", + "expiredNotice": "Questo URL webhook è scaduto. Creane uno nuovo per continuare a testare.", + "base64Badge": "base64", + "truncatedBadge": "troncato", + "empty": { + "title": "In attesa di richieste…", + "hint": "Invia una richiesta al tuo URL e apparirà qui all'istante. Prova così:" + }, + "detail": { + "receivedAt": "Ricevuto", + "source": "IP di origine", + "contentType": "Content-Type", + "query": "Parametri query", + "headers": "Intestazioni", + "body": "Corpo", + "copyBody": "Copia corpo", + "emptyBody": "Nessun corpo" + }, + "errors": { + "createFailed": "Impossibile creare l'URL webhook. Riprova.", + "clearFailed": "Impossibile cancellare le richieste. Riprova." + } + }, + "WebsocketTester": { + "title": "Tester WebSocket", + "subtitle": "Connettiti a un endpoint WebSocket e invia, ricevi e ispeziona messaggi dal vivo", + "urlLabel": "URL dell'endpoint", + "protocolsLabel": "Sottoprotocolli", + "protocolsPlaceholder": "opzionale, separati da virgola", + "connect": "Connetti", + "disconnect": "Disconnetti", + "status": { + "disconnected": "Disconnesso", + "connecting": "Connessione", + "connected": "Connesso", + "error": "Errore" + }, + "messages": { + "connecting": "Connessione a {url}…", + "connected": "Connessione stabilita", + "connectedWithProtocol": "Connessione stabilita (protocollo: {protocol})", + "invalidUrl": "URL non valido — deve iniziare con ws:// o wss://", + "connectFailed": "Impossibile aprire la connessione", + "socketError": "Si è verificato un errore di connessione", + "notConnected": "Non connesso — connettiti prima", + "binaryMessage": "Ricevuto un messaggio binario" + }, + "log": { + "title": "Registro dei messaggi", + "empty": "Ancora nessun messaggio. Connettiti e inizia a inviare.", + "copyAll": "Copia tutto", + "download": "Scarica come .txt", + "clear": "Cancella registro", + "prettyPrint": "Formatta JSON", + "showRaw": "Mostra testo grezzo" + }, + "composer": { + "placeholder": "Digita un messaggio da inviare…", + "send": "Invia", + "formatJson": "Formatta come JSON", + "invalidJson": "Il messaggio non è un JSON valido", + "hint": "Cmd/Ctrl+Invio per inviare" + }, + "ping": { + "label": "Auto-ping", + "seconds": "sec", + "intervalTitle": "Intervallo di ping in secondi", + "payloadPlaceholder": "payload del ping" + } + }, + "WhoisLookup": { + "title": "Ricerca Whois", + "subtitle": "Cerca i dettagli di registrazione di qualsiasi dominio o indirizzo IP tramite RDAP", + "placeholder": "example.com o 8.8.8.8", + "lookupBtn": "Cerca", + "looking": "Ricerca…", + "refreshBtn": "Aggiorna", + "tryLabel": "Prova:", + "copy": "Copia", + "copied": "Copiato", + "notAvailable": "Non disponibile", + "summary": { + "registrar": "Registrar", + "network": "Rete" + }, + "dates": { + "created": "Creato", + "updated": "Aggiornato", + "expires": "Scade" + }, + "sections": { + "status": "Stato", + "nameservers": "Nameserver", + "contacts": "Contatti", + "rawRdap": "RDAP grezzo" + }, + "contacts": { + "roles": "Ruoli", + "name": "Nome", + "organization": "Organizzazione" + }, + "errors": { + "invalidTarget": "Inserisci un nome di dominio o un indirizzo IP valido", + "lookupFailed": "Ricerca non riuscita (stato {status})" + } } } diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index dee40c60..ba449841 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod 計算機", + "codeScreenshot": "コードスクリーンショット", + "cssGenerators": "CSS ジェネレーター", + "exifViewer": "EXIF ビューアー", + "faviconGenerator": "Favicon ジェネレーター", + "jsonDiff": "JSON 差分", + "jsonpathPlayground": "JSONPath プレイグラウンド", + "keycodeInspector": "キーコードインスペクター", + "markdownTableGenerator": "Markdown テーブル", + "timezoneConverter": "タイムゾーン変換", + "tokenCounter": "トークンカウンター", + "webhookTester": "Webhook テスター", + "websocketTester": "WebSocket テスター", + "whoisLookup": "Whois 検索" }, "Help": { "title": "ヘルプとドキュメント", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt 生成・検証", + "description": "bcrypt でパスワードをハッシュ化し、既存のハッシュを検証します。" + }, + "chmodCalculator": { + "title": "Chmod 計算機", + "description": "Unix のパーミッションを 8 進数、シンボリック表記、チェックボックス間で変換します。" + }, + "codeScreenshot": { + "title": "コードスクリーンショット", + "description": "ソースコードを美しく共有しやすい画像に変換します。" + }, + "cssGenerators": { + "title": "CSS ジェネレーター", + "description": "Box-shadow、border-radius、流動的な clamp() のジェネレーター。" + }, + "exifViewer": { + "title": "EXIF ビューアー・除去", + "description": "写真の EXIF メタデータをローカルで確認・除去します。" + }, + "faviconGenerator": { + "title": "Favicon ジェネレーター", + "description": "favicon、アプリアイコン、マニフェストのスニペットを生成します。" + }, + "jsonDiff": { + "title": "JSON 差分", + "description": "2 つの JSON ドキュメントを意味的・構造的に比較します。" + }, + "jsonpathPlayground": { + "title": "JSONPath プレイグラウンド", + "description": "JSONPath または JMESPath で JSON をクエリし、結果をリアルタイムで確認します。" + }, + "keycodeInspector": { + "title": "キーコードインスペクター", + "description": "キーを押して JavaScript のイベントコードと修飾キーを確認します。" + }, + "markdownTableGenerator": { + "title": "Markdown テーブルジェネレーター", + "description": "スプレッドシートのようなグリッドエディターで Markdown と HTML のテーブルを作成します。" + }, + "timezoneConverter": { + "title": "タイムゾーン変換", + "description": "1 つの時刻を複数のタイムゾーンに変換し、日付のずれや勤務時間を表示します。" + }, + "tokenCounter": { + "title": "LLM トークンカウンター", + "description": "LLM のトークンを数え、チャンクを可視化し、API コストを見積もります。" + }, + "webhookTester": { + "title": "Webhook テスター", + "description": "受信した webhook リクエストをリアルタイムでキャプチャして確認します。" + }, + "websocketTester": { + "title": "WebSocket テスター", + "description": "WebSocket エンドポイントに接続し、メッセージをリアルタイムでやり取りします。" + }, + "whoisLookup": { + "title": "Whois 検索", + "description": "RDAP を使ってドメインと IP の登録情報を調べます。" } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt 生成・検証", + "subtitle": "bcrypt でパスワードをハッシュ化し、ハッシュを検証 — すべてブラウザ内で完結します", + "tabs": { + "hash": "ハッシュ", + "verify": "検証" + }, + "showPassword": "パスワードを表示", + "hidePassword": "パスワードを非表示", + "copy": "コピー", + "copied": "コピーしました", + "hash": { + "passwordLabel": "パスワード", + "passwordPlaceholder": "ハッシュ化するパスワードを入力…", + "costLabel": "コストファクター", + "costHint": "コストが高いほど、各ステップの処理量が倍増します。本番環境では 10〜12 が一般的です。", + "slowWarning": "コストファクターが 13 以上になると、計算に数秒かかることがあります。", + "generate": "ハッシュを生成", + "generating": "ハッシュ化中…", + "resultLabel": "Bcrypt ハッシュ", + "timeTaken": "{ms} ミリ秒", + "anatomy": { + "title": "ハッシュの構造", + "algorithm": "アルゴリズム", + "cost": "コスト", + "salt": "ソルト(22 文字)", + "hash": "ハッシュ(31 文字)" + } + }, + "verify": { + "passwordLabel": "パスワード", + "passwordPlaceholder": "確認するパスワードを入力…", + "hashLabel": "Bcrypt ハッシュ", + "hashPlaceholder": "$2b$10$…", + "idle": "パスワードと bcrypt ハッシュを入力して比較します。", + "invalidHash": "これは有効な bcrypt ハッシュではないようです。", + "checking": "比較中…", + "match": "一致 — このパスワードはこのハッシュを生成します。", + "noMatch": "不一致 — このパスワードはこのハッシュを生成しません。" + } + }, + "ChmodCalculator": { + "title": "Chmod 計算機", + "subtitle": "Unix ファイルのパーミッションをチェックボックス、8 進数、シンボリック表記の間で変換します", + "grid": { + "title": "パーミッション", + "read": "読み取り", + "write": "書き込み", + "execute": "実行", + "owner": "所有者", + "group": "グループ", + "others": "その他" + }, + "special": { + "title": "特殊ビット", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "8 進数", + "placeholder": "755 または 4755", + "invalid": "3 桁または 4 桁の 8 進数(0-7)を入力してください。" + }, + "symbolic": { + "label": "シンボリック", + "placeholder": "rwxr-xr-x", + "invalid": "rwxr-xr-x のように 9 文字を入力してください(s/S、t/T も可)。" + }, + "output": { + "title": "コマンド", + "copy": "コピー", + "copied": "コピーしました" + }, + "explain": { + "line": "{cls}は{perms}ことができます。", + "none": "{cls}にはパーミッションがありません。", + "setuid": "Setuid: ファイルは所有者の権限で実行されます。", + "setgid": "Setgid: ファイルはグループの権限で実行されます(ディレクトリ内の新規ファイルはそのグループを継承します)。", + "sticky": "Sticky: このディレクトリ内では、ファイルの所有者のみが削除や名前変更を行えます。" + }, + "presets": { + "title": "よく使うプリセット", + "octal": "8 進数", + "permissions": "パーミッション", + "description": "一般的な用途", + "p644": "通常のファイル: 所有者が書き込み、全員が読み取り。", + "p755": "実行ファイルとディレクトリ: 全員が読み取りと移動が可能。", + "p600": "プライベートファイル: 所有者のみが読み書き。", + "p700": "プライベートな実行ファイルとディレクトリ: 所有者のみ。", + "p664": "グループが書き込み可能なファイル。", + "p775": "グループが書き込み可能なディレクトリと実行ファイル。", + "p400": "所有者のみ読み取り可(例: SSH 秘密鍵)。" + } + }, + "CodeScreenshot": { + "title": "コードスクリーンショット", + "subtitle": "コードを美しく共有しやすい画像に変換", + "languageLabel": "言語", + "autoDetect": "自動検出", + "backgroundLabel": "背景", + "paddingLabel": "パディング", + "filenameLabel": "ファイル名", + "filenamePlaceholder": "example.ts", + "windowLabel": "ウィンドウのドット", + "themeLabel": "ウィンドウのテーマ", + "themeDark": "ダーク", + "themeLight": "ライト", + "scaleLabel": "書き出し倍率", + "copyImage": "画像をコピー", + "copied": "コピーしました", + "exportPng": "PNG を書き出し", + "inputLabel": "コード", + "detected": "検出: {language}", + "clear": "クリア", + "placeholder": "ここにコードを貼り付け…", + "previewLabel": "プレビュー", + "emptyPreview": "コードのプレビューがここに表示されます", + "exportFailed": "画像の書き出しに失敗しました", + "copyUnsupported": "このブラウザでは画像のコピーがサポートされていません", + "copyFailed": "画像のコピーに失敗しました", + "backgrounds": { + "ocean": "オーシャン", + "sunset": "サンセット", + "candy": "キャンディ", + "forest": "フォレスト", + "midnight": "ミッドナイト", + "gold": "ゴールド", + "aurora": "オーロラ", + "slate": "スレート", + "transparent": "透明" + } + }, + "CssGenerators": { + "title": "CSS ジェネレーター", + "subtitle": "Box-shadow、border-radius、流動的な clamp() — CSS をコピー。", + "preview": "プレビュー", + "output": "CSS", + "copy": "コピー", + "copied": "コピーしました", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "流動的な clamp()" + }, + "boxShadow": { + "presets": "プリセット", + "layer": "レイヤー", + "addLayer": "レイヤーを追加", + "removeLayer": "レイヤーを削除", + "x": "X", + "y": "Y", + "blur": "ぼかし", + "spread": "広がり", + "color": "色", + "alpha": "不透明度", + "inset": "内側", + "presetNames": { + "subtleCard": "控えめなカード", + "elevated": "浮き上がり", + "hardOffset": "ハードオフセット", + "neonGlow": "ネオングロー", + "inner": "内側", + "layeredSmooth": "重ねたスムーズ" + } + }, + "borderRadius": { + "modeLabel": "モード", + "modes": { + "simple": "角ごと", + "blob": "オーガニックブロブ" + }, + "unit": "単位", + "linkCorners": "角を連動", + "randomize": "ランダム", + "horizontal": "水平方向の半径", + "vertical": "垂直方向の半径", + "corners": { + "tl": "左上", + "tr": "右上", + "br": "右下", + "bl": "左下" + } + }, + "clamp": { + "minFont": "最小フォント (px)", + "maxFont": "最大フォント (px)", + "minViewport": "最小ビューポート (px)", + "maxViewport": "最大ビューポート (px)", + "rootFont": "ルートフォント (px)", + "viewportWidth": "シミュレートするビューポート", + "computedSize": "計算されたサイズ", + "slope": "傾き", + "intercept": "切片", + "previewText": "The quick brown fox", + "invalid": "有効で異なるビューポートの範囲を入力してください。" + } + }, + "ExifViewer": { + "title": "EXIF ビューアー・除去", + "subtitle": "写真のメタデータを確認し、ブラウザ内で除去します", + "dropTitle": "ここに画像をドロップ", + "dropHint": "またはクリックして選択 — 画像がデバイスから送信されることはありません", + "browse": "画像を選択", + "formats": "JPEG、PNG、WebP、HEIC、TIFF", + "parsing": "メタデータを読み取り中…", + "parseFailed": "この画像の読み取りに失敗しました", + "clear": "画像を削除", + "gpsWarning": "GPS 位置情報を含みます", + "openMap": "OpenStreetMap で表示", + "noMetadata": "この画像に埋め込まれたメタデータは見つかりませんでした。", + "facts": { + "name": "名前", + "type": "種類", + "dimensions": "寸法", + "size": "サイズ" + }, + "groups": { + "camera": "カメラ", + "exposure": "露出", + "timestamps": "タイムスタンプ", + "gps": "GPS 位置情報", + "software": "ソフトウェア・その他" + }, + "gpsCard": { + "latitude": "緯度", + "longitude": "経度", + "altitude": "高度" + }, + "raw": { + "title": "すべてのメタデータ", + "copyJson": "JSON をコピー", + "copied": "コピーしました" + }, + "remove": { + "title": "メタデータを除去", + "jpegDescription": "画像を再圧縮せずに、EXIF、XMP、IPTC、コメントを可逆的に除去します。", + "reencodeDescription": "画像を再エンコードして埋め込まれたメタデータを除去します。ピクセルが再圧縮されます。", + "keepIcc": "カラープロファイル (ICC) を保持", + "stripButton": "メタデータを除去", + "reencodeButton": "再エンコードして除去", + "unsupported": "このファイル形式ではメタデータの除去はサポートされていません。", + "failed": "メタデータの除去に失敗しました", + "before": "変更前", + "after": "変更後", + "download": "クリーンな画像をダウンロード" + } + }, + "FaviconGenerator": { + "title": "Favicon ジェネレーター", + "subtitle": "1 枚の画像から favicon とアプリアイコンを作成", + "dropTitle": "ここに画像をドロップ", + "dropHint": "PNG、JPEG、SVG — クリックして選択", + "browse": "画像を選択", + "clear": "画像を削除", + "backgroundLabel": "背景", + "transparent": "透明", + "solidColor": "単色", + "cornerLabel": "角の形状", + "cornerNone": "四角", + "cornerRounded": "角丸", + "cornerCircle": "円形", + "maskPaddingLabel": "マスカブルの余白", + "maskable": "maskable", + "previewLabel": "プレビュー", + "copy": "コピー", + "headSnippet": "HTML head スニペット", + "downloadIco": "favicon.ico をダウンロード", + "downloadZip": "すべてダウンロード (.zip)" + }, + "JsonDiff": { + "title": "JSON 差分", + "subtitle": "2 つの JSON ドキュメントを意味的・構造的に比較 — キーの順序や書式は無視されます", + "options": { + "ignoreArrayOrder": "配列の順序を無視" + }, + "loadBothSamples": "サンプルを読み込む", + "sample": "サンプル", + "format": "整形", + "clear": "クリア", + "formatError": "整形できません: 入力が有効な JSON ではありません", + "placeholder": "ここに JSON を貼り付けまたは入力…", + "panels": { + "left": "元 (左)", + "right": "変更後 (右)", + "results": "差分" + }, + "summary": { + "added": "{count} 件追加", + "removed": "{count} 件削除", + "changed": "{count} 件変更" + }, + "types": { + "added": "追加", + "removed": "削除", + "changed": "変更" + }, + "copyChanges": "JSON としてコピー", + "copied": "コピーしました", + "empty": "両方のパネルに JSON を貼り付けて差分を確認します。", + "identical": "2 つのドキュメントは意味的に同一です。" + }, + "JsonpathPlayground": { + "title": "JSONPath プレイグラウンド", + "subtitle": "JSONPath または JMESPath で JSON ドキュメントをクエリし、結果をリアルタイムで確認します", + "engineLabel": "エンジン", + "queryLabel": "クエリ", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "チートシート", + "cheats": { + "jpAllTitles": "すべての書籍タイトル", + "jpAllAuthors": "ドキュメント内のすべての著者", + "jpFirstBook": "最初の書籍", + "jpLastBook": "最後の書籍", + "jpSlice": "最初の 2 冊 (スライス)", + "jpFilterPrice": "40 未満の書籍", + "jpFilterInStock": "在庫のある書籍のタイトル", + "jpRecursive": "すべての値を再帰的に取得", + "jmAllTitles": "すべての書籍タイトル", + "jmFirstAuthor": "最初の書籍の著者", + "jmLastBook": "最後の書籍", + "jmSlice": "最初の 2 冊 (スライス)", + "jmFilterPrice": "40 未満の書籍のタイトル", + "jmCount": "在庫のある書籍を集計", + "jmProjection": "各書籍を新しいオブジェクトに再構成", + "jmSort": "価格順に並べたタイトル" + }, + "panels": { + "input": "JSON 入力", + "results": "結果" + }, + "sample": "サンプル", + "format": "整形", + "clear": "クリア", + "formatError": "整形できません: 入力が有効な JSON ではありません", + "placeholder": "ここに JSON を貼り付けまたは入力…", + "outputPlaceholder": "一致した結果がここに表示されます…", + "matchCount": "{count, plural, one {# 件の一致} other {# 件の一致}}", + "copy": "コピー", + "copied": "コピーしました" + }, + "KeycodeInspector": { + "title": "キーコードインスペクター", + "subtitle": "任意のキーを押して JavaScript のキーボードイベントを確認します", + "capture": { + "prompt": "任意のキーを押してください…", + "ready": "待機中 — キーボードの任意のキーを押してください", + "clickToFocus": "ここをクリックしてフォーカスし、任意のキーを押してください" + }, + "hero": { + "spaceKey": "(スペース)", + "keyLabel": "event.key", + "repeat": "リピート" + }, + "copyJson": "JSON をコピー", + "copiedJson": "イベント JSON をクリップボードにコピーしました", + "deprecated": "非推奨", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "標準", + "left": "左", + "right": "右", + "numpad": "テンキー", + "unknown": "不明" + }, + "history": { + "title": "履歴", + "clear": "履歴をクリア", + "empty": "キー入力がここに表示されます。" + } + }, + "MarkdownTableGenerator": { + "title": "Markdown テーブルジェネレーター", + "subtitle": "グリッドを編集し、スプレッドシートから貼り付け、きれいに整列した Markdown または HTML テーブルをコピーします", + "addRow": "行を追加", + "addColumn": "列を追加", + "importLabel": "インポート", + "importPlaceholder": "TSV (Excel / Google スプレッドシート) または CSV をここに貼り付け…", + "importButton": "グリッドにインポート", + "pasteClipboard": "クリップボードから貼り付け", + "importEmpty": "インポートするものがありません — まず行を貼り付けてください", + "clipboardError": "クリップボードから読み取れませんでした", + "clear": "クリア", + "panels": { + "editor": "テーブルエディター", + "output": "出力" + }, + "align": { + "left": "左揃え", + "center": "中央揃え", + "right": "右揃え" + }, + "headerPlaceholder": "ヘッダー", + "cellPlaceholder": "セル", + "removeRow": "行を削除", + "removeColumn": "列を削除", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "コピー", + "copied": "コピーしました" + }, + "TimezoneConverter": { + "title": "タイムゾーン変換", + "subtitle": "会議の計画のために、ある瞬間を世界中の時刻に変換します。", + "dateTimeLabel": "日付と時刻", + "now": "現在", + "sourceZoneLabel": "変換元のタイムゾーン", + "selectZone": "タイムゾーンを選択", + "searchZone": "タイムゾーンを検索...", + "noZone": "タイムゾーンが見つかりません。", + "scrubLabel": "時刻をずらす", + "reset": "リセット", + "targetsLabel": "変換先のタイムゾーン", + "addZone": "タイムゾーンを追加", + "remove": "削除", + "localBadge": "ローカル", + "invalidDate": "有効な日付と時刻を入力してください。", + "bands": { + "work": "勤務時間 (09:00–18:00)", + "edge": "境界の時間帯 (早朝 / 夜)", + "night": "夜間" + } + }, + "TokenCounter": { + "title": "LLM トークンカウンター", + "subtitle": "トークンを数え、チャンクを可視化し、API コストを見積もります。", + "inputLabel": "テキスト", + "sample": "サンプル", + "clear": "クリア", + "placeholder": "トークンを数えるテキスト、コード、JSON を貼り付け...", + "encoding": "エンコーディング", + "loading": "トークナイザーを読み込み中...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o シリーズ)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "トークン", + "characters": "文字", + "words": "単語", + "ratio": "トークン/単語", + "ratioSub": "概算" + }, + "claudeRow": "Claude (概算)", + "estimate": "見積もり", + "viz": { + "title": "トークンの可視化", + "toggle": "表示", + "hint": "切り替えて各トークンをチャンクとして色付けします。", + "empty": "テキストを入力してトークンを可視化します。", + "truncated": "{total} 個中、最初の {limit} 個のトークンを表示しています。" + }, + "cost": { + "title": "コスト計算機", + "disclaimer": "編集可能なデフォルト値 — 最新の料金を確認してください", + "model": "モデル", + "expectedOutput": "想定される出力トークン", + "customInput": "入力 $/MTok", + "customOutput": "出力 $/MTok", + "inputCost": "入力コスト", + "outputCost": "出力コスト", + "total": "合計", + "per1000": "1000 回の呼び出しあたり" + } + }, + "WebhookTester": { + "title": "Webhook テスター", + "subtitle": "一時的な URL を作成し、受信した webhook リクエストをライブで確認します。", + "intro": { + "title": "webhook URL を作成してリクエストのキャプチャを開始", + "hint": "推測できない URL が発行され、受信したすべてのリクエストを記録します。24 時間後に期限切れになります。" + }, + "createBtn": "webhook URL を作成", + "creating": "作成中…", + "urlLabel": "あなたの webhook URL", + "expiresIn": "{countdown} 後に期限切れ", + "copyUrlBtn": "URL をコピー", + "copied": "コピーしました", + "clearBtn": "クリア", + "newBinBtn": "新しい URL", + "requestsTitle": "リクエスト", + "selectPrompt": "リクエストを選択して詳細を確認します。", + "expiredNotice": "この webhook URL は期限切れです。テストを続けるには新しく作成してください。", + "base64Badge": "base64", + "truncatedBadge": "切り詰め", + "empty": { + "title": "リクエストを待機中…", + "hint": "URL にリクエストを送信すると、すぐにここに表示されます。試してみてください:" + }, + "detail": { + "receivedAt": "受信日時", + "source": "送信元 IP", + "contentType": "Content-Type", + "query": "クエリパラメーター", + "headers": "ヘッダー", + "body": "ボディ", + "copyBody": "ボディをコピー", + "emptyBody": "ボディなし" + }, + "errors": { + "createFailed": "webhook URL の作成に失敗しました。もう一度お試しください。", + "clearFailed": "リクエストのクリアに失敗しました。もう一度お試しください。" + } + }, + "WebsocketTester": { + "title": "WebSocket テスター", + "subtitle": "WebSocket エンドポイントに接続し、メッセージの送受信と確認をライブで行います", + "urlLabel": "エンドポイント URL", + "protocolsLabel": "サブプロトコル", + "protocolsPlaceholder": "任意、カンマ区切り", + "connect": "接続", + "disconnect": "切断", + "status": { + "disconnected": "切断済み", + "connecting": "接続中", + "connected": "接続済み", + "error": "エラー" + }, + "messages": { + "connecting": "{url} に接続中…", + "connected": "接続が確立されました", + "connectedWithProtocol": "接続が確立されました (プロトコル: {protocol})", + "invalidUrl": "無効な URL — ws:// または wss:// で始まる必要があります", + "connectFailed": "接続を開けませんでした", + "socketError": "接続エラーが発生しました", + "notConnected": "未接続 — まず接続してください", + "binaryMessage": "バイナリメッセージを受信しました" + }, + "log": { + "title": "メッセージログ", + "empty": "メッセージはまだありません。接続して送信を開始してください。", + "copyAll": "すべてコピー", + "download": ".txt でダウンロード", + "clear": "ログをクリア", + "prettyPrint": "JSON を整形", + "showRaw": "生テキストを表示" + }, + "composer": { + "placeholder": "送信するメッセージを入力…", + "send": "送信", + "formatJson": "JSON として整形", + "invalidJson": "メッセージが有効な JSON ではありません", + "hint": "Cmd/Ctrl+Enter で送信" + }, + "ping": { + "label": "自動 ping", + "seconds": "秒", + "intervalTitle": "Ping の間隔 (秒)", + "payloadPlaceholder": "ping ペイロード" + } + }, + "WhoisLookup": { + "title": "Whois 検索", + "subtitle": "RDAP を使って任意のドメインや IP アドレスの登録情報を調べます", + "placeholder": "example.com または 8.8.8.8", + "lookupBtn": "検索", + "looking": "検索中…", + "refreshBtn": "更新", + "tryLabel": "お試し:", + "copy": "コピー", + "copied": "コピーしました", + "notAvailable": "利用できません", + "summary": { + "registrar": "レジストラ", + "network": "ネットワーク" + }, + "dates": { + "created": "作成日", + "updated": "更新日", + "expires": "有効期限" + }, + "sections": { + "status": "ステータス", + "nameservers": "ネームサーバー", + "contacts": "連絡先", + "rawRdap": "生の RDAP" + }, + "contacts": { + "roles": "役割", + "name": "名前", + "organization": "組織" + }, + "errors": { + "invalidTarget": "有効なドメイン名または IP アドレスを入力してください", + "lookupFailed": "検索に失敗しました (ステータス {status})" + } } } diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index 2ed3698c..537728d6 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod 계산기", + "codeScreenshot": "코드 스크린샷", + "cssGenerators": "CSS 생성기", + "exifViewer": "EXIF 뷰어", + "faviconGenerator": "Favicon 생성기", + "jsonDiff": "JSON 비교", + "jsonpathPlayground": "JSONPath 플레이그라운드", + "keycodeInspector": "키코드 검사기", + "markdownTableGenerator": "Markdown 표", + "timezoneConverter": "시간대 변환기", + "tokenCounter": "토큰 카운터", + "webhookTester": "Webhook 테스터", + "websocketTester": "WebSocket 테스터", + "whoisLookup": "Whois 조회" }, "Help": { "title": "도움말 및 문서", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt 생성 및 검증", + "description": "bcrypt로 비밀번호를 해시하고 기존 해시를 검증합니다." + }, + "chmodCalculator": { + "title": "Chmod 계산기", + "description": "Unix 권한을 8진수, 기호 표기, 체크박스 간에 변환합니다." + }, + "codeScreenshot": { + "title": "코드 스크린샷", + "description": "소스 코드를 아름답고 공유하기 좋은 이미지로 변환합니다." + }, + "cssGenerators": { + "title": "CSS 생성기", + "description": "Box-shadow, border-radius, 유동적 clamp() 생성기입니다." + }, + "exifViewer": { + "title": "EXIF 뷰어 및 제거", + "description": "사진의 EXIF 메타데이터를 로컬에서 확인하고 제거합니다." + }, + "faviconGenerator": { + "title": "Favicon 생성기", + "description": "favicon, 앱 아이콘, 매니페스트 스니펫을 생성합니다." + }, + "jsonDiff": { + "title": "JSON 비교", + "description": "두 JSON 문서를 의미론적, 구조 인식 방식으로 비교합니다." + }, + "jsonpathPlayground": { + "title": "JSONPath 플레이그라운드", + "description": "JSONPath 또는 JMESPath로 JSON을 쿼리하고 결과를 실시간으로 확인합니다." + }, + "keycodeInspector": { + "title": "키코드 검사기", + "description": "키를 눌러 JavaScript 이벤트 코드와 수정 키를 확인합니다." + }, + "markdownTableGenerator": { + "title": "Markdown 표 생성기", + "description": "스프레드시트 같은 그리드 편집기에서 Markdown 및 HTML 표를 만듭니다." + }, + "timezoneConverter": { + "title": "시간대 변환기", + "description": "하나의 시각을 여러 시간대로 변환하고 날짜 변동과 근무 시간을 표시합니다." + }, + "tokenCounter": { + "title": "LLM 토큰 카운터", + "description": "LLM 토큰을 세고 청크를 시각화하며 API 비용을 추정합니다." + }, + "webhookTester": { + "title": "Webhook 테스터", + "description": "수신되는 webhook 요청을 실시간으로 캡처하고 검사합니다." + }, + "websocketTester": { + "title": "WebSocket 테스터", + "description": "WebSocket 엔드포인트에 연결하여 메시지를 실시간으로 주고받습니다." + }, + "whoisLookup": { + "title": "Whois 조회", + "description": "RDAP를 통해 도메인과 IP 등록 정보를 조회합니다." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt 생성 및 검증", + "subtitle": "bcrypt로 비밀번호를 해시하고 해시를 검증 — 모든 작업이 브라우저 안에서 이루어집니다", + "tabs": { + "hash": "해시", + "verify": "검증" + }, + "showPassword": "비밀번호 표시", + "hidePassword": "비밀번호 숨기기", + "copy": "복사", + "copied": "복사됨", + "hash": { + "passwordLabel": "비밀번호", + "passwordPlaceholder": "해시할 비밀번호를 입력하세요…", + "costLabel": "비용 계수", + "costHint": "비용이 높을수록 각 단계의 작업량이 두 배가 됩니다. 프로덕션에서는 10~12가 일반적입니다.", + "slowWarning": "비용 계수가 13 이상이면 계산에 몇 초가 걸릴 수 있습니다.", + "generate": "해시 생성", + "generating": "해시 중…", + "resultLabel": "Bcrypt 해시", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "해시 구조", + "algorithm": "알고리즘", + "cost": "비용", + "salt": "솔트 (22자)", + "hash": "해시 (31자)" + } + }, + "verify": { + "passwordLabel": "비밀번호", + "passwordPlaceholder": "확인할 비밀번호를 입력하세요…", + "hashLabel": "Bcrypt 해시", + "hashPlaceholder": "$2b$10$…", + "idle": "비밀번호와 bcrypt 해시를 입력하여 비교합니다.", + "invalidHash": "유효한 bcrypt 해시가 아닌 것 같습니다.", + "checking": "비교 중…", + "match": "일치 — 이 비밀번호가 이 해시를 생성합니다.", + "noMatch": "불일치 — 이 비밀번호는 이 해시를 생성하지 않습니다." + } + }, + "ChmodCalculator": { + "title": "Chmod 계산기", + "subtitle": "Unix 파일 권한을 체크박스, 8진수, 기호 표기 간에 변환합니다", + "grid": { + "title": "권한", + "read": "읽기", + "write": "쓰기", + "execute": "실행", + "owner": "소유자", + "group": "그룹", + "others": "기타" + }, + "special": { + "title": "특수 비트", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "8진수", + "placeholder": "755 또는 4755", + "invalid": "3자리 또는 4자리 8진수 (0-7)를 입력하세요." + }, + "symbolic": { + "label": "기호 표기", + "placeholder": "rwxr-xr-x", + "invalid": "rwxr-xr-x처럼 9자를 입력하세요 (s/S, t/T 허용)." + }, + "output": { + "title": "명령어", + "copy": "복사", + "copied": "복사됨" + }, + "explain": { + "line": "{cls}은(는) {perms} 수 있습니다.", + "none": "{cls}에는 권한이 없습니다.", + "setuid": "Setuid: 파일이 소유자의 권한으로 실행됩니다.", + "setgid": "Setgid: 파일이 그룹의 권한으로 실행됩니다 (디렉터리의 새 파일은 해당 그룹을 상속합니다).", + "sticky": "Sticky: 이 디렉터리 안에서는 파일 소유자만 파일을 삭제하거나 이름을 바꿀 수 있습니다." + }, + "presets": { + "title": "일반 프리셋", + "octal": "8진수", + "permissions": "권한", + "description": "일반적인 용도", + "p644": "일반 파일: 소유자 쓰기, 모두 읽기.", + "p755": "실행 파일 및 디렉터리: 모두 읽고 진입 가능.", + "p600": "비공개 파일: 소유자만 읽고 쓰기.", + "p700": "비공개 실행 파일 및 디렉터리: 소유자 전용.", + "p664": "그룹 쓰기 가능 파일.", + "p775": "그룹 쓰기 가능 디렉터리 및 실행 파일.", + "p400": "소유자 읽기 전용 (예: SSH 개인 키)." + } + }, + "CodeScreenshot": { + "title": "코드 스크린샷", + "subtitle": "코드를 아름답고 공유하기 좋은 이미지로 변환", + "languageLabel": "언어", + "autoDetect": "자동 감지", + "backgroundLabel": "배경", + "paddingLabel": "여백", + "filenameLabel": "파일 이름", + "filenamePlaceholder": "example.ts", + "windowLabel": "창 점", + "themeLabel": "창 테마", + "themeDark": "다크", + "themeLight": "라이트", + "scaleLabel": "내보내기 배율", + "copyImage": "이미지 복사", + "copied": "복사됨", + "exportPng": "PNG 내보내기", + "inputLabel": "코드", + "detected": "감지됨: {language}", + "clear": "지우기", + "placeholder": "여기에 코드를 붙여넣으세요…", + "previewLabel": "미리보기", + "emptyPreview": "코드 미리보기가 여기에 표시됩니다", + "exportFailed": "이미지 내보내기에 실패했습니다", + "copyUnsupported": "이 브라우저에서는 이미지 복사가 지원되지 않습니다", + "copyFailed": "이미지 복사에 실패했습니다", + "backgrounds": { + "ocean": "오션", + "sunset": "선셋", + "candy": "캔디", + "forest": "포레스트", + "midnight": "미드나이트", + "gold": "골드", + "aurora": "오로라", + "slate": "슬레이트", + "transparent": "투명" + } + }, + "CssGenerators": { + "title": "CSS 생성기", + "subtitle": "Box-shadow, border-radius, 유동적 clamp() — CSS를 복사하세요.", + "preview": "미리보기", + "output": "CSS", + "copy": "복사", + "copied": "복사됨", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "유동적 clamp()" + }, + "boxShadow": { + "presets": "프리셋", + "layer": "레이어", + "addLayer": "레이어 추가", + "removeLayer": "레이어 제거", + "x": "X", + "y": "Y", + "blur": "흐림", + "spread": "확산", + "color": "색상", + "alpha": "투명도", + "inset": "안쪽", + "presetNames": { + "subtleCard": "은은한 카드", + "elevated": "떠 있는", + "hardOffset": "하드 오프셋", + "neonGlow": "네온 글로우", + "inner": "안쪽", + "layeredSmooth": "겹친 부드러움" + } + }, + "borderRadius": { + "modeLabel": "모드", + "modes": { + "simple": "모서리별", + "blob": "유기적 블롭" + }, + "unit": "단위", + "linkCorners": "모서리 연결", + "randomize": "무작위", + "horizontal": "수평 반경", + "vertical": "수직 반경", + "corners": { + "tl": "왼쪽 위", + "tr": "오른쪽 위", + "br": "오른쪽 아래", + "bl": "왼쪽 아래" + } + }, + "clamp": { + "minFont": "최소 글꼴 (px)", + "maxFont": "최대 글꼴 (px)", + "minViewport": "최소 뷰포트 (px)", + "maxViewport": "최대 뷰포트 (px)", + "rootFont": "루트 글꼴 (px)", + "viewportWidth": "시뮬레이션 뷰포트", + "computedSize": "계산된 크기", + "slope": "기울기", + "intercept": "절편", + "previewText": "The quick brown fox", + "invalid": "유효하고 서로 다른 뷰포트 범위를 입력하세요." + } + }, + "ExifViewer": { + "title": "EXIF 뷰어 및 제거", + "subtitle": "사진 메타데이터를 확인하고 브라우저에서 제거합니다", + "dropTitle": "여기에 이미지를 놓으세요", + "dropHint": "또는 클릭하여 찾아보기 — 이미지는 기기를 벗어나지 않습니다", + "browse": "이미지 선택", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "메타데이터 읽는 중…", + "parseFailed": "이 이미지를 읽지 못했습니다", + "clear": "이미지 제거", + "gpsWarning": "GPS 위치 정보 포함", + "openMap": "OpenStreetMap에서 보기", + "noMetadata": "이 이미지에서 삽입된 메타데이터를 찾지 못했습니다.", + "facts": { + "name": "이름", + "type": "형식", + "dimensions": "크기", + "size": "용량" + }, + "groups": { + "camera": "카메라", + "exposure": "노출", + "timestamps": "타임스탬프", + "gps": "GPS 위치", + "software": "소프트웨어 및 기타" + }, + "gpsCard": { + "latitude": "위도", + "longitude": "경도", + "altitude": "고도" + }, + "raw": { + "title": "모든 메타데이터", + "copyJson": "JSON 복사", + "copied": "복사됨" + }, + "remove": { + "title": "메타데이터 제거", + "jpegDescription": "이미지를 재압축하지 않고 EXIF, XMP, IPTC, 주석을 무손실로 제거합니다.", + "reencodeDescription": "이미지를 다시 인코딩하여 삽입된 메타데이터를 제거합니다. 픽셀이 재압축됩니다.", + "keepIcc": "색상 프로필 (ICC) 유지", + "stripButton": "메타데이터 제거", + "reencodeButton": "다시 인코딩 후 제거", + "unsupported": "이 파일 형식에서는 메타데이터 제거가 지원되지 않습니다.", + "failed": "메타데이터 제거에 실패했습니다", + "before": "이전", + "after": "이후", + "download": "정리된 이미지 다운로드" + } + }, + "FaviconGenerator": { + "title": "Favicon 생성기", + "subtitle": "하나의 이미지로 favicon과 앱 아이콘을 만듭니다", + "dropTitle": "여기에 이미지를 놓으세요", + "dropHint": "PNG, JPEG 또는 SVG — 클릭하여 찾아보기", + "browse": "이미지 선택", + "clear": "이미지 제거", + "backgroundLabel": "배경", + "transparent": "투명", + "solidColor": "단색", + "cornerLabel": "모서리 모양", + "cornerNone": "사각형", + "cornerRounded": "둥근 모서리", + "cornerCircle": "원형", + "maskPaddingLabel": "마스커블 여백", + "maskable": "maskable", + "previewLabel": "미리보기", + "copy": "복사", + "headSnippet": "HTML head 스니펫", + "downloadIco": "favicon.ico 다운로드", + "downloadZip": "모두 다운로드 (.zip)" + }, + "JsonDiff": { + "title": "JSON 비교", + "subtitle": "두 JSON 문서를 의미론적, 구조 인식 방식으로 비교 — 키 순서와 서식은 무시됩니다", + "options": { + "ignoreArrayOrder": "배열 순서 무시" + }, + "loadBothSamples": "샘플 불러오기", + "sample": "샘플", + "format": "포맷", + "clear": "지우기", + "formatError": "포맷할 수 없음: 입력이 유효한 JSON이 아닙니다", + "placeholder": "여기에 JSON을 붙여넣거나 입력하세요…", + "panels": { + "left": "원본 (왼쪽)", + "right": "수정됨 (오른쪽)", + "results": "차이" + }, + "summary": { + "added": "{count}개 추가", + "removed": "{count}개 제거", + "changed": "{count}개 변경" + }, + "types": { + "added": "추가됨", + "removed": "제거됨", + "changed": "변경됨" + }, + "copyChanges": "JSON으로 복사", + "copied": "복사됨", + "empty": "두 패널에 JSON을 붙여넣어 차이를 확인하세요.", + "identical": "두 문서는 의미론적으로 동일합니다." + }, + "JsonpathPlayground": { + "title": "JSONPath 플레이그라운드", + "subtitle": "JSONPath 또는 JMESPath로 JSON 문서를 쿼리하고 결과를 실시간으로 확인합니다", + "engineLabel": "엔진", + "queryLabel": "쿼리", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "치트시트", + "cheats": { + "jpAllTitles": "모든 책 제목", + "jpAllAuthors": "문서 어디에나 있는 모든 저자", + "jpFirstBook": "첫 번째 책", + "jpLastBook": "마지막 책", + "jpSlice": "처음 두 권 (슬라이스)", + "jpFilterPrice": "40 미만인 책", + "jpFilterInStock": "재고 있는 책의 제목", + "jpRecursive": "모든 값 재귀적으로", + "jmAllTitles": "모든 책 제목", + "jmFirstAuthor": "첫 번째 책의 저자", + "jmLastBook": "마지막 책", + "jmSlice": "처음 두 권 (슬라이스)", + "jmFilterPrice": "40 미만인 책의 제목", + "jmCount": "재고 있는 책 세기", + "jmProjection": "각 책을 새 객체로 재구성", + "jmSort": "가격순으로 정렬한 제목" + }, + "panels": { + "input": "JSON 입력", + "results": "결과" + }, + "sample": "샘플", + "format": "포맷", + "clear": "지우기", + "formatError": "포맷할 수 없음: 입력이 유효한 JSON이 아닙니다", + "placeholder": "여기에 JSON을 붙여넣거나 입력하세요…", + "outputPlaceholder": "일치 항목이 여기에 표시됩니다…", + "matchCount": "{count, plural, one {일치 #개} other {일치 #개}}", + "copy": "복사", + "copied": "복사됨" + }, + "KeycodeInspector": { + "title": "키코드 검사기", + "subtitle": "아무 키나 눌러 JavaScript 키보드 이벤트를 검사합니다", + "capture": { + "prompt": "아무 키나 누르세요…", + "ready": "수신 중 — 키보드에서 아무 키나 누르세요", + "clickToFocus": "여기를 클릭하여 포커스한 다음 아무 키나 누르세요" + }, + "hero": { + "spaceKey": "(스페이스)", + "keyLabel": "event.key", + "repeat": "반복" + }, + "copyJson": "JSON 복사", + "copiedJson": "이벤트 JSON을 클립보드에 복사했습니다", + "deprecated": "더 이상 사용되지 않음", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "표준", + "left": "왼쪽", + "right": "오른쪽", + "numpad": "숫자 패드", + "unknown": "알 수 없음" + }, + "history": { + "title": "기록", + "clear": "기록 지우기", + "empty": "키 입력이 여기에 표시됩니다." + } + }, + "MarkdownTableGenerator": { + "title": "Markdown 표 생성기", + "subtitle": "그리드를 편집하고, 스프레드시트에서 붙여넣고, 깔끔하게 정렬된 Markdown 또는 HTML 표를 복사합니다", + "addRow": "행 추가", + "addColumn": "열 추가", + "importLabel": "가져오기", + "importPlaceholder": "TSV (Excel / Google Sheets) 또는 CSV를 여기에 붙여넣으세요…", + "importButton": "그리드로 가져오기", + "pasteClipboard": "클립보드에서 붙여넣기", + "importEmpty": "가져올 항목이 없습니다 — 먼저 행을 붙여넣으세요", + "clipboardError": "클립보드에서 읽을 수 없습니다", + "clear": "지우기", + "panels": { + "editor": "표 편집기", + "output": "출력" + }, + "align": { + "left": "왼쪽 정렬", + "center": "가운데 정렬", + "right": "오른쪽 정렬" + }, + "headerPlaceholder": "헤더", + "cellPlaceholder": "셀", + "removeRow": "행 제거", + "removeColumn": "열 제거", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "복사", + "copied": "복사됨" + }, + "TimezoneConverter": { + "title": "시간대 변환기", + "subtitle": "회의 계획을 위해 하나의 순간을 전 세계 시각으로 변환합니다.", + "dateTimeLabel": "날짜 및 시간", + "now": "지금", + "sourceZoneLabel": "원본 시간대", + "selectZone": "시간대 선택", + "searchZone": "시간대 검색...", + "noZone": "시간대를 찾을 수 없습니다.", + "scrubLabel": "시간 이동", + "reset": "재설정", + "targetsLabel": "대상 시간대", + "addZone": "시간대 추가", + "remove": "제거", + "localBadge": "로컬", + "invalidDate": "유효한 날짜와 시간을 입력하세요.", + "bands": { + "work": "근무 시간 (09:00–18:00)", + "edge": "경계 시간 (이른 아침 / 저녁)", + "night": "야간" + } + }, + "TokenCounter": { + "title": "LLM 토큰 카운터", + "subtitle": "토큰을 세고, 청크를 시각화하며, API 비용을 추정합니다.", + "inputLabel": "텍스트", + "sample": "샘플", + "clear": "지우기", + "placeholder": "토큰을 셀 텍스트, 코드 또는 JSON을 붙여넣으세요...", + "encoding": "인코딩", + "loading": "토크나이저 불러오는 중...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o 시리즈)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "토큰", + "characters": "문자", + "words": "단어", + "ratio": "토큰/단어", + "ratioSub": "대략" + }, + "claudeRow": "Claude (근사치)", + "estimate": "추정", + "viz": { + "title": "토큰 시각화", + "toggle": "표시", + "hint": "전환하여 각 토큰을 청크로 색상 표시합니다.", + "empty": "텍스트를 입력하여 토큰을 시각화하세요.", + "truncated": "전체 {total}개 중 처음 {limit}개 토큰을 표시하고 있습니다." + }, + "cost": { + "title": "비용 계산기", + "disclaimer": "편집 가능한 기본값 — 현재 가격을 확인하세요", + "model": "모델", + "expectedOutput": "예상 출력 토큰", + "customInput": "입력 $/MTok", + "customOutput": "출력 $/MTok", + "inputCost": "입력 비용", + "outputCost": "출력 비용", + "total": "합계", + "per1000": "1000회 호출당" + } + }, + "WebhookTester": { + "title": "Webhook 테스터", + "subtitle": "임시 URL을 만들고 수신되는 webhook 요청을 실시간으로 검사합니다.", + "intro": { + "title": "webhook URL을 만들어 요청 캡처를 시작하세요", + "hint": "추측할 수 없는 URL이 발급되어 수신되는 모든 요청을 기록합니다. 24시간 후에 만료됩니다." + }, + "createBtn": "webhook URL 만들기", + "creating": "만드는 중…", + "urlLabel": "내 webhook URL", + "expiresIn": "{countdown} 후 만료", + "copyUrlBtn": "URL 복사", + "copied": "복사됨", + "clearBtn": "지우기", + "newBinBtn": "새 URL", + "requestsTitle": "요청", + "selectPrompt": "요청을 선택하여 세부 정보를 확인하세요.", + "expiredNotice": "이 webhook URL은 만료되었습니다. 계속 테스트하려면 새로 만드세요.", + "base64Badge": "base64", + "truncatedBadge": "잘림", + "empty": { + "title": "요청을 기다리는 중…", + "hint": "URL로 요청을 보내면 즉시 여기에 표시됩니다. 이렇게 해보세요:" + }, + "detail": { + "receivedAt": "수신 시각", + "source": "출발지 IP", + "contentType": "Content-Type", + "query": "쿼리 매개변수", + "headers": "헤더", + "body": "본문", + "copyBody": "본문 복사", + "emptyBody": "본문 없음" + }, + "errors": { + "createFailed": "webhook URL 생성에 실패했습니다. 다시 시도하세요.", + "clearFailed": "요청을 지우지 못했습니다. 다시 시도하세요." + } + }, + "WebsocketTester": { + "title": "WebSocket 테스터", + "subtitle": "WebSocket 엔드포인트에 연결하여 메시지를 실시간으로 전송, 수신, 검사합니다", + "urlLabel": "엔드포인트 URL", + "protocolsLabel": "하위 프로토콜", + "protocolsPlaceholder": "선택 사항, 쉼표로 구분", + "connect": "연결", + "disconnect": "연결 해제", + "status": { + "disconnected": "연결 해제됨", + "connecting": "연결 중", + "connected": "연결됨", + "error": "오류" + }, + "messages": { + "connecting": "{url}에 연결 중…", + "connected": "연결이 설정되었습니다", + "connectedWithProtocol": "연결이 설정되었습니다 (프로토콜: {protocol})", + "invalidUrl": "잘못된 URL — ws:// 또는 wss://로 시작해야 합니다", + "connectFailed": "연결을 열지 못했습니다", + "socketError": "연결 오류가 발생했습니다", + "notConnected": "연결되지 않음 — 먼저 연결하세요", + "binaryMessage": "바이너리 메시지를 수신했습니다" + }, + "log": { + "title": "메시지 로그", + "empty": "아직 메시지가 없습니다. 연결하고 전송을 시작하세요.", + "copyAll": "모두 복사", + "download": ".txt로 다운로드", + "clear": "로그 지우기", + "prettyPrint": "JSON 정렬 표시", + "showRaw": "원본 텍스트 표시" + }, + "composer": { + "placeholder": "전송할 메시지를 입력하세요…", + "send": "전송", + "formatJson": "JSON으로 포맷", + "invalidJson": "메시지가 유효한 JSON이 아닙니다", + "hint": "Cmd/Ctrl+Enter로 전송" + }, + "ping": { + "label": "자동 ping", + "seconds": "초", + "intervalTitle": "Ping 간격 (초)", + "payloadPlaceholder": "ping 페이로드" + } + }, + "WhoisLookup": { + "title": "Whois 조회", + "subtitle": "RDAP를 통해 도메인이나 IP 주소의 등록 정보를 조회합니다", + "placeholder": "example.com 또는 8.8.8.8", + "lookupBtn": "조회", + "looking": "조회 중…", + "refreshBtn": "새로고침", + "tryLabel": "예시:", + "copy": "복사", + "copied": "복사됨", + "notAvailable": "사용할 수 없음", + "summary": { + "registrar": "등록 대행자", + "network": "네트워크" + }, + "dates": { + "created": "생성일", + "updated": "수정일", + "expires": "만료일" + }, + "sections": { + "status": "상태", + "nameservers": "네임서버", + "contacts": "연락처", + "rawRdap": "원본 RDAP" + }, + "contacts": { + "roles": "역할", + "name": "이름", + "organization": "조직" + }, + "errors": { + "invalidTarget": "유효한 도메인 이름이나 IP 주소를 입력하세요", + "lookupFailed": "조회 실패 (상태 {status})" + } } } diff --git a/apps/web/messages/ms.json b/apps/web/messages/ms.json index 509c7f56..837a4b32 100644 --- a/apps/web/messages/ms.json +++ b/apps/web/messages/ms.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Kalkulator Chmod", + "codeScreenshot": "Tangkap Skrin Kod", + "cssGenerators": "Penjana CSS", + "exifViewer": "Pemapar EXIF", + "faviconGenerator": "Penjana Favicon", + "jsonDiff": "Beza JSON", + "jsonpathPlayground": "Ruang Uji JSONPath", + "keycodeInspector": "Pemeriksa Kod Kekunci", + "markdownTableGenerator": "Jadual Markdown", + "timezoneConverter": "Penukar Zon Waktu", + "tokenCounter": "Pengira Token", + "webhookTester": "Penguji Webhook", + "websocketTester": "Penguji WebSocket", + "whoisLookup": "Carian Whois" }, "Help": { "title": "Bantuan & dokumentasi", @@ -532,6 +547,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Penjana & Penyemak Bcrypt", + "description": "Cincang kata laluan dengan bcrypt dan sahkan cincangan sedia ada." + }, + "chmodCalculator": { + "title": "Kalkulator Chmod", + "description": "Tukar keizinan Unix antara oktal, simbolik dan kotak semak." + }, + "codeScreenshot": { + "title": "Tangkap Skrin Kod", + "description": "Tukar kod sumber menjadi imej cantik yang boleh dikongsi." + }, + "cssGenerators": { + "title": "Penjana CSS", + "description": "Penjana box-shadow, border-radius dan clamp() bendalir." + }, + "exifViewer": { + "title": "Pemapar & Pembuang EXIF", + "description": "Periksa dan buang metadata EXIF foto secara setempat." + }, + "faviconGenerator": { + "title": "Penjana Favicon", + "description": "Jana favicon, ikon aplikasi dan cebisan manifest." + }, + "jsonDiff": { + "title": "Beza JSON", + "description": "Perbandingan bermakna dan peka struktur bagi dua dokumen JSON." + }, + "jsonpathPlayground": { + "title": "Ruang Uji JSONPath", + "description": "Kueri JSON dengan JSONPath atau JMESPath dan lihat keputusan secara langsung." + }, + "keycodeInspector": { + "title": "Pemeriksa Kod Kekunci", + "description": "Tekan satu kekunci untuk lihat kod peristiwa JavaScript dan pengubah suainya." + }, + "markdownTableGenerator": { + "title": "Penjana Jadual Markdown", + "description": "Bina jadual Markdown dan HTML dalam penyunting grid seperti hamparan." + }, + "timezoneConverter": { + "title": "Penukar Zon Waktu", + "description": "Tukar satu masa merentas banyak zon dengan petunjuk anjakan hari dan waktu kerja." + }, + "tokenCounter": { + "title": "Pengira Token LLM", + "description": "Kira token LLM, visualkan cebisan dan anggarkan kos API." + }, + "webhookTester": { + "title": "Penguji Webhook", + "description": "Tangkap dan periksa permintaan webhook masuk secara masa nyata." + }, + "websocketTester": { + "title": "Penguji WebSocket", + "description": "Sambung ke titik akhir WebSocket dan bertukar mesej secara masa nyata." + }, + "whoisLookup": { + "title": "Carian Whois", + "description": "Cari butiran pendaftaran domain dan IP melalui RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Penjana & Penyemak Bcrypt", + "subtitle": "Cincang kata laluan dengan bcrypt dan sahkan cincangan — semuanya kekal dalam pelayar anda", + "tabs": { + "hash": "Cincang", + "verify": "Sahkan" + }, + "showPassword": "Tunjukkan kata laluan", + "hidePassword": "Sembunyikan kata laluan", + "copy": "Salin", + "copied": "Disalin", + "hash": { + "passwordLabel": "Kata laluan", + "passwordPlaceholder": "Masukkan kata laluan untuk dicincang…", + "costLabel": "Faktor kos", + "costHint": "Kos lebih tinggi menggandakan kerja setiap langkah. 10-12 ialah pilihan biasa untuk pengeluaran.", + "slowWarning": "Faktor kos 13 dan ke atas boleh mengambil beberapa saat untuk dikira.", + "generate": "Jana cincangan", + "generating": "Mencincang…", + "resultLabel": "Cincangan Bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Anatomi cincangan", + "algorithm": "algoritma", + "cost": "kos", + "salt": "salt (22 aksara)", + "hash": "cincangan (31 aksara)" + } + }, + "verify": { + "passwordLabel": "Kata laluan", + "passwordPlaceholder": "Masukkan kata laluan untuk disemak…", + "hashLabel": "Cincangan Bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Masukkan kata laluan dan cincangan bcrypt untuk membandingkannya.", + "invalidHash": "Ini tidak kelihatan seperti cincangan bcrypt yang sah.", + "checking": "Membandingkan…", + "match": "Padan — kata laluan menghasilkan cincangan ini.", + "noMatch": "Tidak padan — kata laluan tidak menghasilkan cincangan ini." + } + }, + "ChmodCalculator": { + "title": "Kalkulator Chmod", + "subtitle": "Tukar keizinan fail Unix antara kotak semak, oktal dan tatatanda simbolik", + "grid": { + "title": "Keizinan", + "read": "Baca", + "write": "Tulis", + "execute": "Laksana", + "owner": "Pemilik", + "group": "Kumpulan", + "others": "Lain-lain" + }, + "special": { + "title": "Bit khas", + "setuid": "setuid", + "setgid": "setgid", + "sticky": "sticky" + }, + "octal": { + "label": "Oktal", + "placeholder": "755 atau 4755", + "invalid": "Masukkan 3 atau 4 digit oktal (0-7)." + }, + "symbolic": { + "label": "Simbolik", + "placeholder": "rwxr-xr-x", + "invalid": "Masukkan 9 aksara seperti rwxr-xr-x (s/S, t/T dibenarkan)." + }, + "output": { + "title": "Arahan", + "copy": "Salin", + "copied": "Disalin" + }, + "explain": { + "line": "{cls} boleh {perms}.", + "none": "{cls} tiada sebarang keizinan.", + "setuid": "setuid: fail berjalan dengan keistimewaan pemiliknya.", + "setgid": "setgid: fail berjalan dengan keistimewaan kumpulannya (fail baharu dalam direktori mewarisi kumpulannya).", + "sticky": "sticky: hanya pemilik fail boleh memadam atau menamakan semulanya di dalam direktori ini." + }, + "presets": { + "title": "Praset lazim", + "octal": "Oktal", + "permissions": "Keizinan", + "description": "Kegunaan tipikal", + "p644": "Fail biasa: pemilik menulis, semua orang membaca.", + "p755": "Boleh laku dan direktori: semua orang boleh baca dan masuk.", + "p600": "Fail peribadi: hanya pemilik membaca dan menulis.", + "p700": "Boleh laku dan direktori peribadi: pemilik sahaja.", + "p664": "Fail boleh ditulis oleh kumpulan.", + "p775": "Direktori dan boleh laku yang boleh ditulis oleh kumpulan.", + "p400": "Baca sahaja untuk pemilik (cth. kunci peribadi SSH)." + } + }, + "CodeScreenshot": { + "title": "Tangkap Skrin Kod", + "subtitle": "Tukar kod menjadi imej cantik yang boleh dikongsi", + "languageLabel": "Bahasa", + "autoDetect": "Auto kesan", + "backgroundLabel": "Latar belakang", + "paddingLabel": "Pelapik", + "filenameLabel": "Nama fail", + "filenamePlaceholder": "example.ts", + "windowLabel": "Titik tetingkap", + "themeLabel": "Tema tetingkap", + "themeDark": "Gelap", + "themeLight": "Cerah", + "scaleLabel": "Skala eksport", + "copyImage": "Salin imej", + "copied": "Disalin", + "exportPng": "Eksport PNG", + "inputLabel": "Kod", + "detected": "Dikesan: {language}", + "clear": "Kosongkan", + "placeholder": "Tampal kod anda di sini…", + "previewLabel": "Pratonton", + "emptyPreview": "Pratonton kod anda akan muncul di sini", + "exportFailed": "Gagal mengeksport imej", + "copyUnsupported": "Menyalin imej tidak disokong dalam pelayar ini", + "copyFailed": "Gagal menyalin imej", + "backgrounds": { + "ocean": "Lautan", + "sunset": "Senja", + "candy": "Gula-gula", + "forest": "Hutan", + "midnight": "Tengah malam", + "gold": "Emas", + "aurora": "Aurora", + "slate": "Batu tulis", + "transparent": "Lutsinar" + } + }, + "CssGenerators": { + "title": "Penjana CSS", + "subtitle": "box-shadow, border-radius dan clamp() bendalir — salin CSS.", + "preview": "Pratonton", + "output": "CSS", + "copy": "Salin", + "copied": "Disalin", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() bendalir" + }, + "boxShadow": { + "presets": "Praset", + "layer": "Lapisan", + "addLayer": "Tambah lapisan", + "removeLayer": "Buang lapisan", + "x": "X", + "y": "Y", + "blur": "Kabur", + "spread": "Sebaran", + "color": "Warna", + "alpha": "Alfa", + "inset": "Terbenam", + "presetNames": { + "subtleCard": "Kad halus", + "elevated": "Tinggi", + "hardOffset": "Ofset tegas", + "neonGlow": "Sinar neon", + "inner": "Dalaman", + "layeredSmooth": "Berlapis licin" + } + }, + "borderRadius": { + "modeLabel": "Mod", + "modes": { + "simple": "Setiap sudut", + "blob": "Blob organik" + }, + "unit": "Unit", + "linkCorners": "Pautkan sudut", + "randomize": "Rawakkan", + "horizontal": "Jejari mendatar", + "vertical": "Jejari menegak", + "corners": { + "tl": "Kiri atas", + "tr": "Kanan atas", + "br": "Kanan bawah", + "bl": "Kiri bawah" + } + }, + "clamp": { + "minFont": "Fon min (px)", + "maxFont": "Fon maks (px)", + "minViewport": "Viewport min (px)", + "maxViewport": "Viewport maks (px)", + "rootFont": "Fon akar (px)", + "viewportWidth": "Viewport simulasi", + "computedSize": "Saiz terkira", + "slope": "Cerun", + "intercept": "Pintasan", + "previewText": "Teks contoh pratonton", + "invalid": "Masukkan had viewport yang sah dan berbeza." + } + }, + "ExifViewer": { + "title": "Pemapar & Pembuang EXIF", + "subtitle": "Periksa metadata foto dan buang ia dalam pelayar anda", + "dropTitle": "Lepaskan imej di sini", + "dropHint": "atau klik untuk semak lalu — imej anda tidak pernah meninggalkan peranti anda", + "browse": "Pilih imej", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Membaca metadata…", + "parseFailed": "Gagal membaca imej ini", + "clear": "Buang imej", + "gpsWarning": "Mengandungi lokasi GPS", + "openMap": "Lihat pada OpenStreetMap", + "noMetadata": "Tiada metadata terbenam ditemui dalam imej ini.", + "facts": { + "name": "Nama", + "type": "Jenis", + "dimensions": "Dimensi", + "size": "Saiz" + }, + "groups": { + "camera": "Kamera", + "exposure": "Dedahan", + "timestamps": "Cap masa", + "gps": "Lokasi GPS", + "software": "Perisian & Lain-lain" + }, + "gpsCard": { + "latitude": "Latitud", + "longitude": "Longitud", + "altitude": "Altitud" + }, + "raw": { + "title": "Semua metadata", + "copyJson": "Salin JSON", + "copied": "Disalin" + }, + "remove": { + "title": "Buang metadata", + "jpegDescription": "Buang EXIF, XMP, IPTC dan komen tanpa kehilangan dan tanpa memampatkan semula imej.", + "reencodeDescription": "Kod semula imej untuk membuang metadata terbenam. Ini memampatkan semula piksel.", + "keepIcc": "Kekalkan profil warna (ICC)", + "stripButton": "Buang metadata", + "reencodeButton": "Kod semula & buang", + "unsupported": "Pembuangan metadata tidak disokong untuk jenis fail ini.", + "failed": "Gagal membuang metadata", + "before": "Sebelum", + "after": "Selepas", + "download": "Muat turun imej yang dibersihkan" + } + }, + "FaviconGenerator": { + "title": "Penjana Favicon", + "subtitle": "Cipta favicon dan ikon aplikasi daripada satu imej", + "dropTitle": "Lepaskan imej di sini", + "dropHint": "PNG, JPEG atau SVG — klik untuk semak lalu", + "browse": "Pilih imej", + "clear": "Buang imej", + "backgroundLabel": "Latar belakang", + "transparent": "Lutsinar", + "solidColor": "Warna pejal", + "cornerLabel": "Bentuk sudut", + "cornerNone": "Segi empat sama", + "cornerRounded": "Bulat", + "cornerCircle": "Bulatan", + "maskPaddingLabel": "Pelapik boleh topeng", + "maskable": "maskable", + "previewLabel": "Pratonton", + "copy": "Salin", + "headSnippet": "Cebisan HTML head", + "downloadIco": "Muat turun favicon.ico", + "downloadZip": "Muat turun semua (.zip)" + }, + "JsonDiff": { + "title": "Beza JSON", + "subtitle": "Perbandingan bermakna dan peka struktur bagi dua dokumen JSON — susunan kunci dan pemformatan diabaikan", + "options": { + "ignoreArrayOrder": "Abaikan susunan tatasusunan" + }, + "loadBothSamples": "Muat contoh", + "sample": "Contoh", + "format": "Format", + "clear": "Kosongkan", + "formatError": "Tidak boleh format: input bukan JSON yang sah", + "placeholder": "Tampal atau taip JSON di sini…", + "panels": { + "left": "Asal (kiri)", + "right": "Diubah suai (kanan)", + "results": "Perbezaan" + }, + "summary": { + "added": "{count} ditambah", + "removed": "{count} dibuang", + "changed": "{count} berubah" + }, + "types": { + "added": "Ditambah", + "removed": "Dibuang", + "changed": "Berubah" + }, + "copyChanges": "Salin sebagai JSON", + "copied": "Disalin", + "empty": "Tampal JSON ke dalam kedua-dua panel untuk melihat perbezaannya.", + "identical": "Kedua-dua dokumen adalah sama dari segi makna." + }, + "JsonpathPlayground": { + "title": "Ruang Uji JSONPath", + "subtitle": "Kueri dokumen JSON dengan JSONPath atau JMESPath dan lihat keputusan secara langsung", + "engineLabel": "Enjin", + "queryLabel": "Kueri", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Helaian rujukan", + "cheats": { + "jpAllTitles": "Semua tajuk buku", + "jpAllAuthors": "Setiap pengarang, di mana-mana dalam dokumen", + "jpFirstBook": "Buku pertama", + "jpLastBook": "Buku terakhir", + "jpSlice": "Dua buku pertama (hirisan)", + "jpFilterPrice": "Buku lebih murah daripada 40", + "jpFilterInStock": "Tajuk buku yang ada dalam stok", + "jpRecursive": "Semua nilai, secara rekursif", + "jmAllTitles": "Semua tajuk buku", + "jmFirstAuthor": "Pengarang buku pertama", + "jmLastBook": "Buku terakhir", + "jmSlice": "Dua buku pertama (hirisan)", + "jmFilterPrice": "Tajuk buku lebih murah daripada 40", + "jmCount": "Kira buku dalam stok", + "jmProjection": "Bentuk semula setiap buku menjadi objek baharu", + "jmSort": "Tajuk disusun mengikut harga" + }, + "panels": { + "input": "Input JSON", + "results": "Keputusan" + }, + "sample": "Contoh", + "format": "Format", + "clear": "Kosongkan", + "formatError": "Tidak boleh format: input bukan JSON yang sah", + "placeholder": "Tampal atau taip JSON di sini…", + "outputPlaceholder": "Padanan akan muncul di sini…", + "matchCount": "{count, plural, one {# padanan} other {# padanan}}", + "copy": "Salin", + "copied": "Disalin" + }, + "KeycodeInspector": { + "title": "Pemeriksa Kod Kekunci", + "subtitle": "Tekan mana-mana kekunci untuk memeriksa peristiwa papan kekunci JavaScriptnya", + "capture": { + "prompt": "Tekan mana-mana kekunci…", + "ready": "Mendengar — tekan mana-mana kekunci pada papan kekunci anda", + "clickToFocus": "Klik di sini untuk memfokus, kemudian tekan mana-mana kekunci" + }, + "hero": { + "spaceKey": "(Space)", + "keyLabel": "event.key", + "repeat": "ulang" + }, + "copyJson": "Salin JSON", + "copiedJson": "JSON peristiwa disalin ke papan keratan", + "deprecated": "usang", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Piawai", + "left": "Kiri", + "right": "Kanan", + "numpad": "Pad nombor", + "unknown": "Tidak diketahui" + }, + "history": { + "title": "Sejarah", + "clear": "Kosongkan sejarah", + "empty": "Tekanan kekunci akan muncul di sini." + } + }, + "MarkdownTableGenerator": { + "title": "Penjana Jadual Markdown", + "subtitle": "Sunting grid, tampal daripada hamparan dan salin jadual Markdown atau HTML yang tersusun kemas", + "addRow": "Tambah baris", + "addColumn": "Tambah lajur", + "importLabel": "Import", + "importPlaceholder": "Tampal TSV (dari Excel / Google Sheets) atau CSV di sini…", + "importButton": "Import ke dalam grid", + "pasteClipboard": "Tampal dari papan keratan", + "importEmpty": "Tiada apa untuk diimport — tampal beberapa baris dahulu", + "clipboardError": "Tidak dapat membaca daripada papan keratan", + "clear": "Kosongkan", + "panels": { + "editor": "Penyunting jadual", + "output": "Output" + }, + "align": { + "left": "Jajar kiri", + "center": "Jajar tengah", + "right": "Jajar kanan" + }, + "headerPlaceholder": "Pengepala", + "cellPlaceholder": "Sel", + "removeRow": "Buang baris", + "removeColumn": "Buang lajur", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Salin", + "copied": "Disalin" + }, + "TimezoneConverter": { + "title": "Penukar Zon Waktu", + "subtitle": "Tukar satu detik merentas dunia untuk perancangan mesyuarat.", + "dateTimeLabel": "Tarikh & masa", + "now": "Sekarang", + "sourceZoneLabel": "Zon waktu sumber", + "selectZone": "Pilih zon", + "searchZone": "Cari zon waktu...", + "noZone": "Tiada zon waktu ditemui.", + "scrubLabel": "Anjak masa", + "reset": "Set semula", + "targetsLabel": "Zon sasaran", + "addZone": "Tambah zon", + "remove": "Buang", + "localBadge": "Setempat", + "invalidDate": "Masukkan tarikh dan masa yang sah.", + "bands": { + "work": "Waktu kerja (09:00–18:00)", + "edge": "Waktu sempadan (awal pagi / petang)", + "night": "Malam" + } + }, + "TokenCounter": { + "title": "Pengira Token LLM", + "subtitle": "Kira token, visualkan cebisan dan anggarkan kos API.", + "inputLabel": "Teks", + "sample": "Contoh", + "clear": "Kosongkan", + "placeholder": "Tampal teks, kod atau JSON untuk mengira token...", + "encoding": "Pengekodan", + "loading": "Memuatkan tokenizer...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-series)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Token", + "characters": "Aksara", + "words": "Perkataan", + "ratio": "Token/perkataan", + "ratioSub": "anggaran" + }, + "claudeRow": "Claude (anggaran)", + "estimate": "anggaran", + "viz": { + "title": "Visualisasi token", + "toggle": "Tunjuk", + "hint": "Togol untuk mewarnakan setiap token sebagai satu cebisan.", + "empty": "Masukkan teks untuk memvisualkan token.", + "truncated": "Memaparkan {limit} pertama daripada {total} token." + }, + "cost": { + "title": "Kalkulator kos", + "disclaimer": "nilai lalai boleh disunting — sahkan harga semasa", + "model": "Model", + "expectedOutput": "Token output dijangka", + "customInput": "Input $/MTok", + "customOutput": "Output $/MTok", + "inputCost": "Kos input", + "outputCost": "Kos output", + "total": "Jumlah", + "per1000": "Setiap 1000 panggilan" + } + }, + "WebhookTester": { + "title": "Penguji Webhook", + "subtitle": "Cipta URL sementara dan periksa permintaan webhook masuk secara langsung.", + "intro": { + "title": "Cipta URL webhook untuk mula menangkap permintaan", + "hint": "Anda akan mendapat URL yang tidak boleh diteka yang merekod setiap permintaan masuk. Ia luput selepas 24 jam." + }, + "createBtn": "Cipta URL webhook", + "creating": "Mencipta…", + "urlLabel": "URL webhook anda", + "expiresIn": "Luput dalam {countdown}", + "copyUrlBtn": "Salin URL", + "copied": "Disalin", + "clearBtn": "Kosongkan", + "newBinBtn": "URL baharu", + "requestsTitle": "Permintaan", + "selectPrompt": "Pilih satu permintaan untuk memeriksa butirannya.", + "expiredNotice": "URL webhook ini telah luput. Cipta yang baharu untuk terus menguji.", + "base64Badge": "base64", + "truncatedBadge": "dipangkas", + "empty": { + "title": "Menunggu permintaan…", + "hint": "Hantar permintaan ke URL anda dan ia akan muncul di sini serta-merta. Cuba ini:" + }, + "detail": { + "receivedAt": "Diterima", + "source": "IP sumber", + "contentType": "Content-Type", + "query": "Parameter kueri", + "headers": "Pengepala", + "body": "Badan", + "copyBody": "Salin badan", + "emptyBody": "Tiada badan" + }, + "errors": { + "createFailed": "Gagal mencipta URL webhook. Sila cuba lagi.", + "clearFailed": "Gagal mengosongkan permintaan. Sila cuba lagi." + } + }, + "WebsocketTester": { + "title": "Penguji WebSocket", + "subtitle": "Sambung ke titik akhir WebSocket dan hantar, terima serta periksa mesej secara langsung", + "urlLabel": "URL titik akhir", + "protocolsLabel": "Subprotokol", + "protocolsPlaceholder": "pilihan, dipisahkan koma", + "connect": "Sambung", + "disconnect": "Putuskan sambungan", + "status": { + "disconnected": "Terputus", + "connecting": "Menyambung", + "connected": "Tersambung", + "error": "Ralat" + }, + "messages": { + "connecting": "Menyambung ke {url}…", + "connected": "Sambungan diwujudkan", + "connectedWithProtocol": "Sambungan diwujudkan (protokol: {protocol})", + "invalidUrl": "URL tidak sah — mesti bermula dengan ws:// atau wss://", + "connectFailed": "Gagal membuka sambungan", + "socketError": "Ralat sambungan berlaku", + "notConnected": "Tidak tersambung — sambung dahulu", + "binaryMessage": "Mesej binari diterima" + }, + "log": { + "title": "Log Mesej", + "empty": "Belum ada mesej. Sambung dan mula menghantar.", + "copyAll": "Salin semua", + "download": "Muat turun sebagai .txt", + "clear": "Kosongkan log", + "prettyPrint": "Format JSON", + "showRaw": "Tunjuk teks mentah" + }, + "composer": { + "placeholder": "Taip mesej untuk dihantar…", + "send": "Hantar", + "formatJson": "Format sebagai JSON", + "invalidJson": "Mesej bukan JSON yang sah", + "hint": "Cmd/Ctrl+Enter untuk hantar" + }, + "ping": { + "label": "Auto-ping", + "seconds": "saat", + "intervalTitle": "Selang ping dalam saat", + "payloadPlaceholder": "muatan ping" + } + }, + "WhoisLookup": { + "title": "Carian Whois", + "subtitle": "Cari butiran pendaftaran bagi mana-mana domain atau alamat IP melalui RDAP", + "placeholder": "example.com atau 8.8.8.8", + "lookupBtn": "Cari", + "looking": "Mencari…", + "refreshBtn": "Muat semula", + "tryLabel": "Cuba:", + "copy": "Salin", + "copied": "Disalin", + "notAvailable": "Tidak tersedia", + "summary": { + "registrar": "Pendaftar", + "network": "Rangkaian" + }, + "dates": { + "created": "Dicipta", + "updated": "Dikemas kini", + "expires": "Luput" + }, + "sections": { + "status": "Status", + "nameservers": "Pelayan nama", + "contacts": "Kenalan", + "rawRdap": "RDAP mentah" + }, + "contacts": { + "roles": "Peranan", + "name": "Nama", + "organization": "Organisasi" + }, + "errors": { + "invalidTarget": "Masukkan nama domain atau alamat IP yang sah", + "lookupFailed": "Carian gagal (status {status})" + } } } diff --git a/apps/web/messages/nb.json b/apps/web/messages/nb.json index 0844663d..a23e3c69 100644 --- a/apps/web/messages/nb.json +++ b/apps/web/messages/nb.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod-kalkulator", + "codeScreenshot": "Kodeskjermbilde", + "cssGenerators": "CSS-generatorer", + "exifViewer": "EXIF-visning", + "faviconGenerator": "Favicon-generator", + "jsonDiff": "JSON-diff", + "jsonpathPlayground": "JSONPath-lekeplass", + "keycodeInspector": "Tastekode-inspektør", + "markdownTableGenerator": "Markdown-tabell", + "timezoneConverter": "Tidssonekonverterer", + "tokenCounter": "Token-teller", + "webhookTester": "Webhook-tester", + "websocketTester": "WebSocket-tester", + "whoisLookup": "Whois-oppslag" }, "Help": { "title": "Hjelp og dokumentasjon", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt-generator og -kontroll", + "description": "Hash passord med bcrypt, og verifiser eksisterende hasher." + }, + "chmodCalculator": { + "title": "Chmod-kalkulator", + "description": "Konverter Unix-tillatelser mellom oktal, symbolsk notasjon og avkrysningsbokser." + }, + "codeScreenshot": { + "title": "Kodeskjermbilde", + "description": "Gjør kildekode om til vakre, delbare bilder." + }, + "cssGenerators": { + "title": "CSS-generatorer", + "description": "Generatorer for box-shadow, border-radius og flytende clamp()." + }, + "exifViewer": { + "title": "EXIF-visning og -fjerning", + "description": "Inspiser og fjern EXIF-metadata fra bilder lokalt." + }, + "faviconGenerator": { + "title": "Favicon-generator", + "description": "Generer favicons, app-ikoner og manifest-utdrag." + }, + "jsonDiff": { + "title": "JSON-diff", + "description": "Semantisk, strukturbevisst sammenligning av to JSON-dokumenter." + }, + "jsonpathPlayground": { + "title": "JSONPath-lekeplass", + "description": "Spør JSON med JSONPath eller JMESPath, og se resultater live." + }, + "keycodeInspector": { + "title": "Tastekode-inspektør", + "description": "Trykk på en tast for å se JavaScript-hendelseskodene og modifikatorene dens." + }, + "markdownTableGenerator": { + "title": "Markdown-tabellgenerator", + "description": "Bygg Markdown- og HTML-tabeller i en regnearklignende rutenettredigerer." + }, + "timezoneConverter": { + "title": "Tidssonekonverterer", + "description": "Konverter et tidspunkt på tvers av mange soner med dagsforskyvning og arbeidstidsmarkører." + }, + "tokenCounter": { + "title": "LLM-token-teller", + "description": "Tell LLM-tokens, visualiser chunker, og estimer API-kostnad." + }, + "webhookTester": { + "title": "Webhook-tester", + "description": "Fang opp og inspiser innkommende webhook-forespørsler i sanntid." + }, + "websocketTester": { + "title": "WebSocket-tester", + "description": "Koble til WebSocket-endepunkter, og utveksle meldinger i sanntid." + }, + "whoisLookup": { + "title": "Whois-oppslag", + "description": "Slå opp registreringsdetaljer for domene og IP via RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt-generator og -kontroll", + "subtitle": "Hash passord med bcrypt, og verifiser hasher — alt forblir i nettleseren din", + "tabs": { + "hash": "Hash", + "verify": "Verifiser" + }, + "showPassword": "Vis passord", + "hidePassword": "Skjul passord", + "copy": "Kopier", + "copied": "Kopiert", + "hash": { + "passwordLabel": "Passord", + "passwordPlaceholder": "Skriv inn et passord som skal hashes…", + "costLabel": "Kostnadsfaktor", + "costHint": "Høyere kostnad dobler arbeidet per trinn. 10–12 er et vanlig valg for produksjon.", + "slowWarning": "Kostnadsfaktorer på 13 og høyere kan ta flere sekunder å beregne.", + "generate": "Generer hash", + "generating": "Hasher…", + "resultLabel": "Bcrypt-hash", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Hash-anatomi", + "algorithm": "algoritme", + "cost": "kostnad", + "salt": "salt (22 tegn)", + "hash": "hash (31 tegn)" + } + }, + "verify": { + "passwordLabel": "Passord", + "passwordPlaceholder": "Skriv inn passordet som skal kontrolleres…", + "hashLabel": "Bcrypt-hash", + "hashPlaceholder": "$2b$10$…", + "idle": "Skriv inn et passord og en bcrypt-hash for å sammenligne dem.", + "invalidHash": "Dette ser ikke ut som en gyldig bcrypt-hash.", + "checking": "Sammenligner…", + "match": "Treff — passordet produserer denne hashen.", + "noMatch": "Ingen treff — passordet produserer ikke denne hashen." + } + }, + "ChmodCalculator": { + "title": "Chmod-kalkulator", + "subtitle": "Konverter Unix-filtillatelser mellom avkrysningsbokser, oktal og symbolsk notasjon", + "grid": { + "title": "Tillatelser", + "read": "Les", + "write": "Skriv", + "execute": "Kjør", + "owner": "Eier", + "group": "Gruppe", + "others": "Andre" + }, + "special": { + "title": "Spesielle bits", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Oktal", + "placeholder": "755 eller 4755", + "invalid": "Skriv inn 3 eller 4 oktale sifre (0–7)." + }, + "symbolic": { + "label": "Symbolsk", + "placeholder": "rwxr-xr-x", + "invalid": "Skriv inn 9 tegn som rwxr-xr-x (s/S, t/T tillatt)." + }, + "output": { + "title": "Kommando", + "copy": "Kopier", + "copied": "Kopiert" + }, + "explain": { + "line": "{cls} kan {perms}.", + "none": "{cls} har ingen tillatelser.", + "setuid": "Setuid: filen kjører med eierens rettigheter.", + "setgid": "Setgid: filen kjører med gruppens rettigheter (nye filer i en mappe arver gruppen dens).", + "sticky": "Sticky: bare en fils eier kan slette eller gi den nytt navn i denne mappen." + }, + "presets": { + "title": "Vanlige forhåndsinnstillinger", + "octal": "Oktal", + "permissions": "Tillatelser", + "description": "Typisk bruk", + "p644": "Vanlige filer: eieren skriver, alle leser.", + "p755": "Kjørbare filer og mapper: alle kan lese og gå inn.", + "p600": "Private filer: bare eieren leser og skriver.", + "p700": "Private kjørbare filer og mapper: bare eieren.", + "p664": "Gruppe-skrivbare filer.", + "p775": "Gruppe-skrivbare mapper og kjørbare filer.", + "p400": "Skrivebeskyttet for eieren (f.eks. private SSH-nøkler)." + } + }, + "CodeScreenshot": { + "title": "Kodeskjermbilde", + "subtitle": "Gjør kode om til vakre, delbare bilder", + "languageLabel": "Språk", + "autoDetect": "Automatisk gjenkjenning", + "backgroundLabel": "Bakgrunn", + "paddingLabel": "Innvendig marg", + "filenameLabel": "Filnavn", + "filenamePlaceholder": "example.ts", + "windowLabel": "Vindusprikker", + "themeLabel": "Vindustema", + "themeDark": "Mørkt", + "themeLight": "Lyst", + "scaleLabel": "Eksportskala", + "copyImage": "Kopier bilde", + "copied": "Kopiert", + "exportPng": "Eksporter PNG", + "inputLabel": "Kode", + "detected": "Gjenkjent: {language}", + "clear": "Tøm", + "placeholder": "Lim inn koden din her…", + "previewLabel": "Forhåndsvisning", + "emptyPreview": "Forhåndsvisningen av koden din vises her", + "exportFailed": "Kunne ikke eksportere bilde", + "copyUnsupported": "Kopiering av bilder støttes ikke i denne nettleseren", + "copyFailed": "Kunne ikke kopiere bilde", + "backgrounds": { + "ocean": "Hav", + "sunset": "Solnedgang", + "candy": "Godteri", + "forest": "Skog", + "midnight": "Midnatt", + "gold": "Gull", + "aurora": "Nordlys", + "slate": "Skifer", + "transparent": "Gjennomsiktig" + } + }, + "CssGenerators": { + "title": "CSS-generatorer", + "subtitle": "Box-shadow, border-radius og flytende clamp() — kopier CSS-en.", + "preview": "Forhåndsvisning", + "output": "CSS", + "copy": "Kopier", + "copied": "Kopiert", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Flytende clamp()" + }, + "boxShadow": { + "presets": "Forhåndsinnstillinger", + "layer": "Lag", + "addLayer": "Legg til lag", + "removeLayer": "Fjern lag", + "x": "X", + "y": "Y", + "blur": "Uskarphet", + "spread": "Spredning", + "color": "Farge", + "alpha": "Alfa", + "inset": "Innvendig", + "presetNames": { + "subtleCard": "Diskret kort", + "elevated": "Hevet", + "hardOffset": "Hard forskyvning", + "neonGlow": "Neonglød", + "inner": "Innvendig", + "layeredSmooth": "Lagdelt myk" + } + }, + "borderRadius": { + "modeLabel": "Modus", + "modes": { + "simple": "Per hjørne", + "blob": "Organisk klatt" + }, + "unit": "Enhet", + "linkCorners": "Koble hjørner", + "randomize": "Tilfeldiggjør", + "horizontal": "Horisontale radier", + "vertical": "Vertikale radier", + "corners": { + "tl": "Øverst til venstre", + "tr": "Øverst til høyre", + "br": "Nederst til høyre", + "bl": "Nederst til venstre" + } + }, + "clamp": { + "minFont": "Min. skrift (px)", + "maxFont": "Maks. skrift (px)", + "minViewport": "Min. viewport (px)", + "maxViewport": "Maks. viewport (px)", + "rootFont": "Rotskrift (px)", + "viewportWidth": "Simulert viewport", + "computedSize": "Beregnet størrelse", + "slope": "Stigning", + "intercept": "Skjæringspunkt", + "previewText": "Vår sære Zulu fra badeøya", + "invalid": "Skriv inn gyldige, ulike viewport-grenser." + } + }, + "ExifViewer": { + "title": "EXIF-visning og -fjerning", + "subtitle": "Inspiser bilders metadata, og fjern dem i nettleseren din", + "dropTitle": "Slipp et bilde her", + "dropHint": "eller klikk for å bla — bildet ditt forlater aldri enheten din", + "browse": "Velg bilde", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Leser metadata…", + "parseFailed": "Kunne ikke lese dette bildet", + "clear": "Fjern bilde", + "gpsWarning": "Inneholder GPS-posisjon", + "openMap": "Vis på OpenStreetMap", + "noMetadata": "Ingen innebygde metadata ble funnet i dette bildet.", + "facts": { + "name": "Navn", + "type": "Type", + "dimensions": "Dimensjoner", + "size": "Størrelse" + }, + "groups": { + "camera": "Kamera", + "exposure": "Eksponering", + "timestamps": "Tidsstempler", + "gps": "GPS-posisjon", + "software": "Programvare og annet" + }, + "gpsCard": { + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "altitude": "Høyde" + }, + "raw": { + "title": "Alle metadata", + "copyJson": "Kopier JSON", + "copied": "Kopiert" + }, + "remove": { + "title": "Fjern metadata", + "jpegDescription": "Fjern EXIF, XMP, IPTC og kommentarer tapsfritt uten å komprimere bildet på nytt.", + "reencodeDescription": "Kod om bildet for å fjerne innebygde metadata. Dette komprimerer pikslene på nytt.", + "keepIcc": "Behold fargeprofil (ICC)", + "stripButton": "Fjern metadata", + "reencodeButton": "Kod om og fjern", + "unsupported": "Fjerning av metadata støttes ikke for denne filtypen.", + "failed": "Kunne ikke fjerne metadata", + "before": "Før", + "after": "Etter", + "download": "Last ned renset bilde" + } + }, + "FaviconGenerator": { + "title": "Favicon-generator", + "subtitle": "Lag favicons og app-ikoner fra ett bilde", + "dropTitle": "Slipp et bilde her", + "dropHint": "PNG, JPEG eller SVG — klikk for å bla", + "browse": "Velg bilde", + "clear": "Fjern bilde", + "backgroundLabel": "Bakgrunn", + "transparent": "Gjennomsiktig", + "solidColor": "Heldekkende", + "cornerLabel": "Hjørneform", + "cornerNone": "Firkantet", + "cornerRounded": "Avrundet", + "cornerCircle": "Sirkel", + "maskPaddingLabel": "Maskerbar marg", + "maskable": "maskable", + "previewLabel": "Forhåndsvisning", + "copy": "Kopier", + "headSnippet": "HTML head-utdrag", + "downloadIco": "Last ned favicon.ico", + "downloadZip": "Last ned alle (.zip)" + }, + "JsonDiff": { + "title": "JSON-diff", + "subtitle": "Semantisk, strukturbevisst sammenligning av to JSON-dokumenter — nøkkelrekkefølge og formatering ignoreres", + "options": { + "ignoreArrayOrder": "Ignorer array-rekkefølge" + }, + "loadBothSamples": "Last eksempler", + "sample": "Eksempel", + "format": "Formater", + "clear": "Tøm", + "formatError": "Kan ikke formatere: inndata er ikke gyldig JSON", + "placeholder": "Lim inn eller skriv JSON her…", + "panels": { + "left": "Original (venstre)", + "right": "Endret (høyre)", + "results": "Forskjeller" + }, + "summary": { + "added": "{count} lagt til", + "removed": "{count} fjernet", + "changed": "{count} endret" + }, + "types": { + "added": "Lagt til", + "removed": "Fjernet", + "changed": "Endret" + }, + "copyChanges": "Kopier som JSON", + "copied": "Kopiert", + "empty": "Lim inn JSON i begge panelene for å se forskjellene deres.", + "identical": "De to dokumentene er semantisk identiske." + }, + "JsonpathPlayground": { + "title": "JSONPath-lekeplass", + "subtitle": "Spør JSON-dokumenter med JSONPath eller JMESPath, og se resultater live", + "engineLabel": "Motor", + "queryLabel": "Spørring", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Jukselapp", + "cheats": { + "jpAllTitles": "Alle boktitler", + "jpAllAuthors": "Hver forfatter, hvor som helst i dokumentet", + "jpFirstBook": "Første bok", + "jpLastBook": "Siste bok", + "jpSlice": "De to første bøkene (utsnitt)", + "jpFilterPrice": "Bøker billigere enn 40", + "jpFilterInStock": "Titler på bøker på lager", + "jpRecursive": "Alle verdier, rekursivt", + "jmAllTitles": "Alle boktitler", + "jmFirstAuthor": "Forfatter av den første boken", + "jmLastBook": "Siste bok", + "jmSlice": "De to første bøkene (utsnitt)", + "jmFilterPrice": "Titler på bøker billigere enn 40", + "jmCount": "Antall bøker på lager", + "jmProjection": "Omform hver bok til et nytt objekt", + "jmSort": "Titler sortert etter pris" + }, + "panels": { + "input": "JSON-inndata", + "results": "Resultater" + }, + "sample": "Eksempel", + "format": "Formater", + "clear": "Tøm", + "formatError": "Kan ikke formatere: inndata er ikke gyldig JSON", + "placeholder": "Lim inn eller skriv JSON her…", + "outputPlaceholder": "Treff vises her…", + "matchCount": "{count, plural, one {# treff} other {# treff}}", + "copy": "Kopier", + "copied": "Kopiert" + }, + "KeycodeInspector": { + "title": "Tastekode-inspektør", + "subtitle": "Trykk på en hvilken som helst tast for å inspisere JavaScript-tastaturhendelsen dens", + "capture": { + "prompt": "Trykk på en hvilken som helst tast…", + "ready": "Lytter — trykk på en hvilken som helst tast på tastaturet", + "clickToFocus": "Klikk her for å fokusere, og trykk deretter på en hvilken som helst tast" + }, + "hero": { + "spaceKey": "(Mellomrom)", + "keyLabel": "event.key", + "repeat": "gjentakelse" + }, + "copyJson": "Kopier JSON", + "copiedJson": "Hendelses-JSON kopiert til utklippstavlen", + "deprecated": "utdatert", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standard", + "left": "Venstre", + "right": "Høyre", + "numpad": "Numerisk tastatur", + "unknown": "Ukjent" + }, + "history": { + "title": "Historikk", + "clear": "Tøm historikk", + "empty": "Tastetrykk vises her." + } + }, + "MarkdownTableGenerator": { + "title": "Markdown-tabellgenerator", + "subtitle": "Rediger et rutenett, lim inn fra et regneark, og kopier en pent justert Markdown- eller HTML-tabell", + "addRow": "Legg til rad", + "addColumn": "Legg til kolonne", + "importLabel": "Importer", + "importPlaceholder": "Lim inn TSV (fra Excel / Google Sheets) eller CSV her…", + "importButton": "Importer til rutenett", + "pasteClipboard": "Lim inn fra utklippstavlen", + "importEmpty": "Ingenting å importere — lim inn noen rader først", + "clipboardError": "Kunne ikke lese fra utklippstavlen", + "clear": "Tøm", + "panels": { + "editor": "Tabellredigerer", + "output": "Utdata" + }, + "align": { + "left": "Venstrejuster", + "center": "Midtstill", + "right": "Høyrejuster" + }, + "headerPlaceholder": "Overskrift", + "cellPlaceholder": "Celle", + "removeRow": "Fjern rad", + "removeColumn": "Fjern kolonne", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Kopier", + "copied": "Kopiert" + }, + "TimezoneConverter": { + "title": "Tidssonekonverterer", + "subtitle": "Konverter ett øyeblikk på tvers av verden for møteplanlegging.", + "dateTimeLabel": "Dato og tid", + "now": "Nå", + "sourceZoneLabel": "Kilde-tidssone", + "selectZone": "Velg sone", + "searchZone": "Søk tidssoner...", + "noZone": "Ingen tidssone funnet.", + "scrubLabel": "Forskyv tid", + "reset": "Tilbakestill", + "targetsLabel": "Målsoner", + "addZone": "Legg til sone", + "remove": "Fjern", + "localBadge": "Lokal", + "invalidDate": "Skriv inn en gyldig dato og tid.", + "bands": { + "work": "Arbeidstid (09:00–18:00)", + "edge": "Randtimer (tidlig / kveld)", + "night": "Natt" + } + }, + "TokenCounter": { + "title": "LLM-token-teller", + "subtitle": "Tell tokens, visualiser chunker, og estimer API-kostnad.", + "inputLabel": "Tekst", + "sample": "Eksempel", + "clear": "Tøm", + "placeholder": "Lim inn tekst, kode eller JSON for å telle tokens...", + "encoding": "Koding", + "loading": "Laster tokenizer...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-serien)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Tegn", + "words": "Ord", + "ratio": "Tokens/ord", + "ratioSub": "omtrent" + }, + "claudeRow": "Claude (omtrentlig)", + "estimate": "estimat", + "viz": { + "title": "Token-visualisering", + "toggle": "Vis", + "hint": "Slå på for å farge hvert token som en chunk.", + "empty": "Skriv inn tekst for å visualisere tokens.", + "truncated": "Viser de første {limit} av {total} tokens." + }, + "cost": { + "title": "Kostnadskalkulator", + "disclaimer": "redigerbare standardverdier — verifiser gjeldende priser", + "model": "Modell", + "expectedOutput": "Forventede output-tokens", + "customInput": "Input $/MTok", + "customOutput": "Output $/MTok", + "inputCost": "Input-kostnad", + "outputCost": "Output-kostnad", + "total": "Totalt", + "per1000": "Per 1000 kall" + } + }, + "WebhookTester": { + "title": "Webhook-tester", + "subtitle": "Opprett en midlertidig URL, og inspiser innkommende webhook-forespørsler live.", + "intro": { + "title": "Opprett en webhook-URL for å begynne å fange opp forespørsler", + "hint": "Du får en ugjettbar URL som registrerer hver innkommende forespørsel. Den utløper etter 24 timer." + }, + "createBtn": "Opprett webhook-URL", + "creating": "Oppretter…", + "urlLabel": "Din webhook-URL", + "expiresIn": "Utløper om {countdown}", + "copyUrlBtn": "Kopier URL", + "copied": "Kopiert", + "clearBtn": "Tøm", + "newBinBtn": "Ny URL", + "requestsTitle": "Forespørsler", + "selectPrompt": "Velg en forespørsel for å inspisere detaljene.", + "expiredNotice": "Denne webhook-URL-en har utløpt. Opprett en ny for å fortsette å teste.", + "base64Badge": "base64", + "truncatedBadge": "avkortet", + "empty": { + "title": "Venter på forespørsler…", + "hint": "Send en forespørsel til URL-en din, så vises den her umiddelbart. Prøv dette:" + }, + "detail": { + "receivedAt": "Mottatt", + "source": "Kilde-IP", + "contentType": "Content-Type", + "query": "Spørringsparametere", + "headers": "Headere", + "body": "Body", + "copyBody": "Kopier body", + "emptyBody": "Ingen body" + }, + "errors": { + "createFailed": "Kunne ikke opprette webhook-URL. Prøv igjen.", + "clearFailed": "Kunne ikke tømme forespørsler. Prøv igjen." + } + }, + "WebsocketTester": { + "title": "WebSocket-tester", + "subtitle": "Koble til et WebSocket-endepunkt, og send, motta og inspiser meldinger live", + "urlLabel": "Endepunkt-URL", + "protocolsLabel": "Underprotokoller", + "protocolsPlaceholder": "valgfritt, kommaseparert", + "connect": "Koble til", + "disconnect": "Koble fra", + "status": { + "disconnected": "Frakoblet", + "connecting": "Kobler til", + "connected": "Tilkoblet", + "error": "Feil" + }, + "messages": { + "connecting": "Kobler til {url}…", + "connected": "Tilkobling opprettet", + "connectedWithProtocol": "Tilkobling opprettet (protokoll: {protocol})", + "invalidUrl": "Ugyldig URL — må begynne med ws:// eller wss://", + "connectFailed": "Kunne ikke åpne tilkoblingen", + "socketError": "Det oppstod en tilkoblingsfeil", + "notConnected": "Ikke tilkoblet — koble til først", + "binaryMessage": "Mottok en binær melding" + }, + "log": { + "title": "Meldingslogg", + "empty": "Ingen meldinger ennå. Koble til, og begynn å sende.", + "copyAll": "Kopier alle", + "download": "Last ned som .txt", + "clear": "Tøm logg", + "prettyPrint": "Formater JSON pent", + "showRaw": "Vis rå tekst" + }, + "composer": { + "placeholder": "Skriv en melding som skal sendes…", + "send": "Send", + "formatJson": "Formater som JSON", + "invalidJson": "Meldingen er ikke gyldig JSON", + "hint": "Cmd/Ctrl+Enter for å sende" + }, + "ping": { + "label": "Auto-ping", + "seconds": "sek", + "intervalTitle": "Ping-intervall i sekunder", + "payloadPlaceholder": "ping-payload" + } + }, + "WhoisLookup": { + "title": "Whois-oppslag", + "subtitle": "Slå opp registreringsdetaljer for et hvilket som helst domene eller IP-adresse via RDAP", + "placeholder": "example.com eller 8.8.8.8", + "lookupBtn": "Slå opp", + "looking": "Slår opp…", + "refreshBtn": "Oppdater", + "tryLabel": "Prøv:", + "copy": "Kopier", + "copied": "Kopiert", + "notAvailable": "Ikke tilgjengelig", + "summary": { + "registrar": "Registrar", + "network": "Nettverk" + }, + "dates": { + "created": "Opprettet", + "updated": "Oppdatert", + "expires": "Utløper" + }, + "sections": { + "status": "Status", + "nameservers": "Navnetjenere", + "contacts": "Kontakter", + "rawRdap": "Rå RDAP" + }, + "contacts": { + "roles": "Roller", + "name": "Navn", + "organization": "Organisasjon" + }, + "errors": { + "invalidTarget": "Skriv inn et gyldig domenenavn eller IP-adresse", + "lookupFailed": "Oppslag mislyktes (status {status})" + } } } diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index 18e20392..a99f1b76 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod-calculator", + "codeScreenshot": "Code-screenshot", + "cssGenerators": "CSS-generatoren", + "exifViewer": "EXIF-viewer", + "faviconGenerator": "Favicon-generator", + "jsonDiff": "JSON-diff", + "jsonpathPlayground": "JSONPath-playground", + "keycodeInspector": "Keycode-inspecteur", + "markdownTableGenerator": "Markdown-tabel", + "timezoneConverter": "Tijdzone-converter", + "tokenCounter": "Token-teller", + "webhookTester": "Webhook-tester", + "websocketTester": "WebSocket-tester", + "whoisLookup": "Whois-opzoeking" }, "Help": { "title": "Help en documentatie", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt-generator en -controle", + "description": "Hash wachtwoorden met bcrypt en verifieer bestaande hashes." + }, + "chmodCalculator": { + "title": "Chmod-calculator", + "description": "Zet Unix-rechten om tussen octaal, symbolisch en selectievakjes." + }, + "codeScreenshot": { + "title": "Code-screenshot", + "description": "Zet broncode om in mooie, deelbare afbeeldingen." + }, + "cssGenerators": { + "title": "CSS-generatoren", + "description": "Generatoren voor box-shadow, border-radius en vloeiende clamp()." + }, + "exifViewer": { + "title": "EXIF-viewer en -verwijderaar", + "description": "Inspecteer en verwijder EXIF-metadata van foto's lokaal." + }, + "faviconGenerator": { + "title": "Favicon-generator", + "description": "Genereer favicons, app-iconen en manifest-snippets." + }, + "jsonDiff": { + "title": "JSON-diff", + "description": "Semantische, structuurbewuste vergelijking van twee JSON-documenten." + }, + "jsonpathPlayground": { + "title": "JSONPath-playground", + "description": "Bevraag JSON met JSONPath of JMESPath en zie de resultaten live." + }, + "keycodeInspector": { + "title": "Keycode-inspecteur", + "description": "Druk op een toets om de JavaScript-event-codes en modifiers te zien." + }, + "markdownTableGenerator": { + "title": "Markdown-tabelgenerator", + "description": "Maak Markdown- en HTML-tabellen in een spreadsheetachtige rasteditor." + }, + "timezoneConverter": { + "title": "Tijdzone-converter", + "description": "Zet een tijd om naar veel zones, met aanwijzingen voor dagverschuiving en werktijden." + }, + "tokenCounter": { + "title": "LLM-token-teller", + "description": "Tel LLM-tokens, visualiseer chunks en schat de API-kosten." + }, + "webhookTester": { + "title": "Webhook-tester", + "description": "Vang inkomende webhook-verzoeken op en inspecteer ze in realtime." + }, + "websocketTester": { + "title": "WebSocket-tester", + "description": "Verbind met WebSocket-endpoints en wissel berichten uit in realtime." + }, + "whoisLookup": { + "title": "Whois-opzoeking", + "description": "Zoek registratiegegevens van een domein of IP op via RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt-generator en -controle", + "subtitle": "Hash wachtwoorden met bcrypt en verifieer hashes — alles blijft in je browser", + "tabs": { + "hash": "Hashen", + "verify": "Verifiëren" + }, + "showPassword": "Wachtwoord tonen", + "hidePassword": "Wachtwoord verbergen", + "copy": "Kopiëren", + "copied": "Gekopieerd", + "hash": { + "passwordLabel": "Wachtwoord", + "passwordPlaceholder": "Voer een wachtwoord in om te hashen…", + "costLabel": "Kostenfactor", + "costHint": "Een hogere kostenfactor verdubbelt het werk per stap. 10-12 is een gangbare keuze voor productie.", + "slowWarning": "Kostenfactoren van 13 en hoger kunnen enkele seconden duren om te berekenen.", + "generate": "Hash genereren", + "generating": "Bezig met hashen…", + "resultLabel": "Bcrypt-hash", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Opbouw van de hash", + "algorithm": "algoritme", + "cost": "kosten", + "salt": "salt (22 tekens)", + "hash": "hash (31 tekens)" + } + }, + "verify": { + "passwordLabel": "Wachtwoord", + "passwordPlaceholder": "Voer het te controleren wachtwoord in…", + "hashLabel": "Bcrypt-hash", + "hashPlaceholder": "$2b$10$…", + "idle": "Voer een wachtwoord en een bcrypt-hash in om ze te vergelijken.", + "invalidHash": "Dit lijkt geen geldige bcrypt-hash.", + "checking": "Bezig met vergelijken…", + "match": "Komt overeen — het wachtwoord levert deze hash op.", + "noMatch": "Komt niet overeen — het wachtwoord levert deze hash niet op." + } + }, + "ChmodCalculator": { + "title": "Chmod-calculator", + "subtitle": "Zet Unix-bestandsrechten om tussen selectievakjes, octaal en symbolische notatie", + "grid": { + "title": "Rechten", + "read": "Lezen", + "write": "Schrijven", + "execute": "Uitvoeren", + "owner": "Eigenaar", + "group": "Groep", + "others": "Anderen" + }, + "special": { + "title": "Speciale bits", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Octaal", + "placeholder": "755 of 4755", + "invalid": "Voer 3 of 4 octale cijfers (0-7) in." + }, + "symbolic": { + "label": "Symbolisch", + "placeholder": "rwxr-xr-x", + "invalid": "Voer 9 tekens in zoals rwxr-xr-x (s/S, t/T toegestaan)." + }, + "output": { + "title": "Commando", + "copy": "Kopiëren", + "copied": "Gekopieerd" + }, + "explain": { + "line": "{cls} kan {perms}.", + "none": "{cls} heeft geen rechten.", + "setuid": "Setuid: het bestand draait met de rechten van de eigenaar.", + "setgid": "Setgid: het bestand draait met de rechten van zijn groep (nieuwe bestanden in een map erven de groep ervan).", + "sticky": "Sticky: alleen de eigenaar van een bestand kan het verwijderen of hernoemen binnen deze map." + }, + "presets": { + "title": "Veelgebruikte presets", + "octal": "Octaal", + "permissions": "Rechten", + "description": "Typisch gebruik", + "p644": "Gewone bestanden: eigenaar schrijft, iedereen leest.", + "p755": "Uitvoerbare bestanden en mappen: iedereen kan lezen en openen.", + "p600": "Privébestanden: alleen de eigenaar leest en schrijft.", + "p700": "Privé uitvoerbare bestanden en mappen: alleen de eigenaar.", + "p664": "Bestanden die door de groep beschrijfbaar zijn.", + "p775": "Mappen en uitvoerbare bestanden die door de groep beschrijfbaar zijn.", + "p400": "Alleen-lezen voor de eigenaar (bijv. SSH-privésleutels)." + } + }, + "CodeScreenshot": { + "title": "Code-screenshot", + "subtitle": "Zet code om in mooie, deelbare afbeeldingen", + "languageLabel": "Taal", + "autoDetect": "Automatisch detecteren", + "backgroundLabel": "Achtergrond", + "paddingLabel": "Opvulling", + "filenameLabel": "Bestandsnaam", + "filenamePlaceholder": "example.ts", + "windowLabel": "Vensterstippen", + "themeLabel": "Vensterthema", + "themeDark": "Donker", + "themeLight": "Licht", + "scaleLabel": "Exportschaal", + "copyImage": "Afbeelding kopiëren", + "copied": "Gekopieerd", + "exportPng": "PNG exporteren", + "inputLabel": "Code", + "detected": "Gedetecteerd: {language}", + "clear": "Wissen", + "placeholder": "Plak hier je code…", + "previewLabel": "Voorbeeld", + "emptyPreview": "Het voorbeeld van je code verschijnt hier", + "exportFailed": "Afbeelding exporteren mislukt", + "copyUnsupported": "Afbeeldingen kopiëren wordt niet ondersteund in deze browser", + "copyFailed": "Afbeelding kopiëren mislukt", + "backgrounds": { + "ocean": "Oceaan", + "sunset": "Zonsondergang", + "candy": "Snoep", + "forest": "Bos", + "midnight": "Middernacht", + "gold": "Goud", + "aurora": "Poollicht", + "slate": "Leisteen", + "transparent": "Transparant" + } + }, + "CssGenerators": { + "title": "CSS-generatoren", + "subtitle": "Box-shadow, border-radius en vloeiende clamp() — kopieer de CSS.", + "preview": "Voorbeeld", + "output": "CSS", + "copy": "Kopiëren", + "copied": "Gekopieerd", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Vloeiende clamp()" + }, + "boxShadow": { + "presets": "Presets", + "layer": "Laag", + "addLayer": "Laag toevoegen", + "removeLayer": "Laag verwijderen", + "x": "X", + "y": "Y", + "blur": "Vervaging", + "spread": "Spreiding", + "color": "Kleur", + "alpha": "Alfa", + "inset": "Binnen", + "presetNames": { + "subtleCard": "Subtiele kaart", + "elevated": "Verhoogd", + "hardOffset": "Harde verschuiving", + "neonGlow": "Neongloed", + "inner": "Binnen", + "layeredSmooth": "Gelaagd zacht" + } + }, + "borderRadius": { + "modeLabel": "Modus", + "modes": { + "simple": "Per hoek", + "blob": "Organische blob" + }, + "unit": "Eenheid", + "linkCorners": "Hoeken koppelen", + "randomize": "Willekeurig", + "horizontal": "Horizontale radii", + "vertical": "Verticale radii", + "corners": { + "tl": "Linksboven", + "tr": "Rechtsboven", + "br": "Rechtsonder", + "bl": "Linksonder" + } + }, + "clamp": { + "minFont": "Min. lettergrootte (px)", + "maxFont": "Max. lettergrootte (px)", + "minViewport": "Min. viewport (px)", + "maxViewport": "Max. viewport (px)", + "rootFont": "Root-lettergrootte (px)", + "viewportWidth": "Gesimuleerde viewport", + "computedSize": "Berekende grootte", + "slope": "Helling", + "intercept": "Snijpunt", + "previewText": "De snelle bruine vos", + "invalid": "Voer geldige, verschillende viewport-grenzen in." + } + }, + "ExifViewer": { + "title": "EXIF-viewer en -verwijderaar", + "subtitle": "Inspecteer foto-metadata en verwijder ze in je browser", + "dropTitle": "Sleep hier een afbeelding", + "dropHint": "of klik om te bladeren — je afbeelding verlaat nooit je apparaat", + "browse": "Afbeelding kiezen", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Metadata lezen…", + "parseFailed": "Deze afbeelding kon niet worden gelezen", + "clear": "Afbeelding verwijderen", + "gpsWarning": "Bevat GPS-locatie", + "openMap": "Bekijk op OpenStreetMap", + "noMetadata": "Er is geen ingesloten metadata gevonden in deze afbeelding.", + "facts": { + "name": "Naam", + "type": "Type", + "dimensions": "Afmetingen", + "size": "Grootte" + }, + "groups": { + "camera": "Camera", + "exposure": "Belichting", + "timestamps": "Tijdstempels", + "gps": "GPS-locatie", + "software": "Software en overig" + }, + "gpsCard": { + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "altitude": "Hoogte" + }, + "raw": { + "title": "Alle metadata", + "copyJson": "JSON kopiëren", + "copied": "Gekopieerd" + }, + "remove": { + "title": "Metadata verwijderen", + "jpegDescription": "Verwijder EXIF, XMP, IPTC en opmerkingen zonder verlies, zonder de afbeelding opnieuw te comprimeren.", + "reencodeDescription": "Codeer de afbeelding opnieuw om ingesloten metadata te verwijderen. Hierbij worden de pixels opnieuw gecomprimeerd.", + "keepIcc": "Kleurprofiel (ICC) behouden", + "stripButton": "Metadata verwijderen", + "reencodeButton": "Opnieuw coderen en wissen", + "unsupported": "Metadata verwijderen wordt niet ondersteund voor dit bestandstype.", + "failed": "Metadata verwijderen mislukt", + "before": "Voor", + "after": "Na", + "download": "Opgeschoonde afbeelding downloaden" + } + }, + "FaviconGenerator": { + "title": "Favicon-generator", + "subtitle": "Maak favicons en app-iconen uit één afbeelding", + "dropTitle": "Sleep hier een afbeelding", + "dropHint": "PNG, JPEG of SVG — klik om te bladeren", + "browse": "Afbeelding kiezen", + "clear": "Afbeelding verwijderen", + "backgroundLabel": "Achtergrond", + "transparent": "Transparant", + "solidColor": "Effen", + "cornerLabel": "Hoekvorm", + "cornerNone": "Vierkant", + "cornerRounded": "Afgerond", + "cornerCircle": "Cirkel", + "maskPaddingLabel": "Maskable-opvulling", + "maskable": "maskable", + "previewLabel": "Voorbeeld", + "copy": "Kopiëren", + "headSnippet": "HTML-head-snippet", + "downloadIco": "favicon.ico downloaden", + "downloadZip": "Alles downloaden (.zip)" + }, + "JsonDiff": { + "title": "JSON-diff", + "subtitle": "Semantische, structuurbewuste vergelijking van twee JSON-documenten — sleutelvolgorde en opmaak worden genegeerd", + "options": { + "ignoreArrayOrder": "Arrayvolgorde negeren" + }, + "loadBothSamples": "Voorbeelden laden", + "sample": "Voorbeeld laden", + "format": "Formatteren", + "clear": "Wissen", + "formatError": "Kan niet formatteren: de invoer is geen geldige JSON", + "placeholder": "Plak of typ hier JSON…", + "panels": { + "left": "Origineel (links)", + "right": "Gewijzigd (rechts)", + "results": "Verschillen" + }, + "summary": { + "added": "{count} toegevoegd", + "removed": "{count} verwijderd", + "changed": "{count} gewijzigd" + }, + "types": { + "added": "Toegevoegd", + "removed": "Verwijderd", + "changed": "Gewijzigd" + }, + "copyChanges": "Kopiëren als JSON", + "copied": "Gekopieerd", + "empty": "Plak JSON in beide panelen om hun verschillen te zien.", + "identical": "De twee documenten zijn semantisch identiek." + }, + "JsonpathPlayground": { + "title": "JSONPath-playground", + "subtitle": "Bevraag JSON-documenten met JSONPath of JMESPath en zie de resultaten live", + "engineLabel": "Engine", + "queryLabel": "Query", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Spiekbriefje", + "cheats": { + "jpAllTitles": "Alle boektitels", + "jpAllAuthors": "Elke auteur, overal in het document", + "jpFirstBook": "Eerste boek", + "jpLastBook": "Laatste boek", + "jpSlice": "De eerste twee boeken (slice)", + "jpFilterPrice": "Boeken goedkoper dan 40", + "jpFilterInStock": "Titels van boeken op voorraad", + "jpRecursive": "Alle waarden, recursief", + "jmAllTitles": "Alle boektitels", + "jmFirstAuthor": "Auteur van het eerste boek", + "jmLastBook": "Laatste boek", + "jmSlice": "De eerste twee boeken (slice)", + "jmFilterPrice": "Titels van boeken goedkoper dan 40", + "jmCount": "Boeken op voorraad tellen", + "jmProjection": "Elk boek omvormen tot een nieuw object", + "jmSort": "Titels gesorteerd op prijs" + }, + "panels": { + "input": "JSON-invoer", + "results": "Resultaten" + }, + "sample": "Voorbeeld laden", + "format": "Formatteren", + "clear": "Wissen", + "formatError": "Kan niet formatteren: de invoer is geen geldige JSON", + "placeholder": "Plak of typ hier JSON…", + "outputPlaceholder": "Overeenkomsten verschijnen hier…", + "matchCount": "{count, plural, one {# overeenkomst} other {# overeenkomsten}}", + "copy": "Kopiëren", + "copied": "Gekopieerd" + }, + "KeycodeInspector": { + "title": "Keycode-inspecteur", + "subtitle": "Druk op een willekeurige toets om het JavaScript-toetsenbordevent te inspecteren", + "capture": { + "prompt": "Druk op een willekeurige toets…", + "ready": "Aan het luisteren — druk op een willekeurige toets op je toetsenbord", + "clickToFocus": "Klik hier om te focussen en druk daarna op een willekeurige toets" + }, + "hero": { + "spaceKey": "(Spatie)", + "keyLabel": "event.key", + "repeat": "herhaling" + }, + "copyJson": "JSON kopiëren", + "copiedJson": "Event-JSON naar het klembord gekopieerd", + "deprecated": "verouderd", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standaard", + "left": "Links", + "right": "Rechts", + "numpad": "Numeriek toetsenblok", + "unknown": "Onbekend" + }, + "history": { + "title": "Geschiedenis", + "clear": "Geschiedenis wissen", + "empty": "Toetsaanslagen verschijnen hier." + } + }, + "MarkdownTableGenerator": { + "title": "Markdown-tabelgenerator", + "subtitle": "Bewerk een raster, plak uit een spreadsheet en kopieer een netjes uitgelijnde Markdown- of HTML-tabel", + "addRow": "Rij toevoegen", + "addColumn": "Kolom toevoegen", + "importLabel": "Importeren", + "importPlaceholder": "Plak hier TSV (uit Excel / Google Sheets) of CSV…", + "importButton": "Importeren in raster", + "pasteClipboard": "Plakken van klembord", + "importEmpty": "Niets om te importeren — plak eerst een paar rijen", + "clipboardError": "Kon niet van het klembord lezen", + "clear": "Wissen", + "panels": { + "editor": "Tabeleditor", + "output": "Uitvoer" + }, + "align": { + "left": "Links uitlijnen", + "center": "Centreren", + "right": "Rechts uitlijnen" + }, + "headerPlaceholder": "Koptekst", + "cellPlaceholder": "Cel", + "removeRow": "Rij verwijderen", + "removeColumn": "Kolom verwijderen", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Kopiëren", + "copied": "Gekopieerd" + }, + "TimezoneConverter": { + "title": "Tijdzone-converter", + "subtitle": "Zet één moment om over de hele wereld voor het plannen van vergaderingen.", + "dateTimeLabel": "Datum en tijd", + "now": "Nu", + "sourceZoneLabel": "Brontijdzone", + "selectZone": "Zone selecteren", + "searchZone": "Tijdzones zoeken...", + "noZone": "Geen tijdzone gevonden.", + "scrubLabel": "Tijd verschuiven", + "reset": "Opnieuw instellen", + "targetsLabel": "Doeltijdzones", + "addZone": "Zone toevoegen", + "remove": "Verwijderen", + "localBadge": "Lokaal", + "invalidDate": "Voer een geldige datum en tijd in.", + "bands": { + "work": "Werktijden (09:00–18:00)", + "edge": "Randuren (vroeg / avond)", + "night": "Nacht" + } + }, + "TokenCounter": { + "title": "LLM-token-teller", + "subtitle": "Tel tokens, visualiseer chunks en schat de API-kosten.", + "inputLabel": "Tekst", + "sample": "Voorbeeld laden", + "clear": "Wissen", + "placeholder": "Plak tekst, code of JSON om tokens te tellen...", + "encoding": "Codering", + "loading": "Tokenizer laden...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-serie)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Tekens", + "words": "Woorden", + "ratio": "Tokens/woord", + "ratioSub": "ong." + }, + "claudeRow": "Claude (bij benadering)", + "estimate": "schatting", + "viz": { + "title": "Token-visualisatie", + "toggle": "Tonen", + "hint": "Schakel in om elk token als een chunk in te kleuren.", + "empty": "Voer tekst in om tokens te visualiseren.", + "truncated": "De eerste {limit} van {total} tokens worden getoond." + }, + "cost": { + "title": "Kostencalculator", + "disclaimer": "bewerkbare standaardwaarden — controleer de actuele prijzen", + "model": "Model", + "expectedOutput": "Verwachte uitvoertokens", + "customInput": "Invoer $/MTok", + "customOutput": "Uitvoer $/MTok", + "inputCost": "Invoerkosten", + "outputCost": "Uitvoerkosten", + "total": "Totaal", + "per1000": "Per 1000 aanroepen" + } + }, + "WebhookTester": { + "title": "Webhook-tester", + "subtitle": "Maak een tijdelijke URL en inspecteer inkomende webhook-verzoeken live.", + "intro": { + "title": "Maak een webhook-URL om verzoeken te gaan vastleggen", + "hint": "Je krijgt een niet te raden URL die elk inkomend verzoek registreert. Deze verloopt na 24 uur." + }, + "createBtn": "Webhook-URL maken", + "creating": "Bezig met maken…", + "urlLabel": "Jouw webhook-URL", + "expiresIn": "Verloopt over {countdown}", + "copyUrlBtn": "URL kopiëren", + "copied": "Gekopieerd", + "clearBtn": "Wissen", + "newBinBtn": "Nieuwe URL", + "requestsTitle": "Verzoeken", + "selectPrompt": "Selecteer een verzoek om de details te inspecteren.", + "expiredNotice": "Deze webhook-URL is verlopen. Maak een nieuwe om verder te testen.", + "base64Badge": "base64", + "truncatedBadge": "afgekapt", + "empty": { + "title": "Wachten op verzoeken…", + "hint": "Stuur een verzoek naar je URL en het verschijnt hier onmiddellijk. Probeer dit:" + }, + "detail": { + "receivedAt": "Ontvangen", + "source": "Bron-IP", + "contentType": "Content-Type", + "query": "Query-parameters", + "headers": "Headers", + "body": "Body", + "copyBody": "Body kopiëren", + "emptyBody": "Geen body" + }, + "errors": { + "createFailed": "Webhook-URL maken mislukt. Probeer het opnieuw.", + "clearFailed": "Verzoeken wissen mislukt. Probeer het opnieuw." + } + }, + "WebsocketTester": { + "title": "WebSocket-tester", + "subtitle": "Verbind met een WebSocket-endpoint en verzend, ontvang en inspecteer berichten live", + "urlLabel": "Endpoint-URL", + "protocolsLabel": "Subprotocollen", + "protocolsPlaceholder": "optioneel, gescheiden door komma's", + "connect": "Verbinden", + "disconnect": "Verbinding verbreken", + "status": { + "disconnected": "Verbinding verbroken", + "connecting": "Verbinden", + "connected": "Verbonden", + "error": "Fout" + }, + "messages": { + "connecting": "Verbinden met {url}…", + "connected": "Verbinding tot stand gebracht", + "connectedWithProtocol": "Verbinding tot stand gebracht (protocol: {protocol})", + "invalidUrl": "Ongeldige URL — moet beginnen met ws:// of wss://", + "connectFailed": "Verbinding openen mislukt", + "socketError": "Er is een verbindingsfout opgetreden", + "notConnected": "Niet verbonden — verbind eerst", + "binaryMessage": "Een binair bericht ontvangen" + }, + "log": { + "title": "Berichtenlogboek", + "empty": "Nog geen berichten. Verbind en begin met verzenden.", + "copyAll": "Alles kopiëren", + "download": "Downloaden als .txt", + "clear": "Logboek wissen", + "prettyPrint": "JSON opmaken", + "showRaw": "Ruwe tekst tonen" + }, + "composer": { + "placeholder": "Typ een bericht om te verzenden…", + "send": "Verzenden", + "formatJson": "Opmaken als JSON", + "invalidJson": "Het bericht is geen geldige JSON", + "hint": "Cmd/Ctrl+Enter om te verzenden" + }, + "ping": { + "label": "Auto-ping", + "seconds": "sec", + "intervalTitle": "Ping-interval in seconden", + "payloadPlaceholder": "ping-payload" + } + }, + "WhoisLookup": { + "title": "Whois-opzoeking", + "subtitle": "Zoek registratiegegevens van elk domein of IP-adres op via RDAP", + "placeholder": "example.com of 8.8.8.8", + "lookupBtn": "Opzoeken", + "looking": "Bezig met opzoeken…", + "refreshBtn": "Vernieuwen", + "tryLabel": "Probeer:", + "copy": "Kopiëren", + "copied": "Gekopieerd", + "notAvailable": "Niet beschikbaar", + "summary": { + "registrar": "Registrar", + "network": "Netwerk" + }, + "dates": { + "created": "Aangemaakt", + "updated": "Bijgewerkt", + "expires": "Verloopt" + }, + "sections": { + "status": "Status", + "nameservers": "Nameservers", + "contacts": "Contacten", + "rawRdap": "Ruwe RDAP" + }, + "contacts": { + "roles": "Rollen", + "name": "Naam", + "organization": "Organisatie" + }, + "errors": { + "invalidTarget": "Voer een geldige domeinnaam of IP-adres in", + "lookupFailed": "Opzoeken mislukt (status {status})" + } } } diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index ad8fab76..30418abf 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Kalkulator Chmod", + "codeScreenshot": "Zrzut kodu", + "cssGenerators": "Generatory CSS", + "exifViewer": "Podgląd EXIF", + "faviconGenerator": "Generator favicon", + "jsonDiff": "Porównanie JSON", + "jsonpathPlayground": "Piaskownica JSONPath", + "keycodeInspector": "Inspektor klawiszy", + "markdownTableGenerator": "Tabela Markdown", + "timezoneConverter": "Konwerter stref czasowych", + "tokenCounter": "Licznik tokenów", + "webhookTester": "Tester webhooków", + "websocketTester": "Tester WebSocket", + "whoisLookup": "Zapytanie Whois" }, "Help": { "title": "Pomoc i dokumentacja", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Generator i weryfikator Bcrypt", + "description": "Haszuj hasła za pomocą bcrypt i weryfikuj istniejące hasze." + }, + "chmodCalculator": { + "title": "Kalkulator Chmod", + "description": "Konwertuj uprawnienia Unix między postacią ósemkową, symboliczną i polami wyboru." + }, + "codeScreenshot": { + "title": "Zrzut kodu", + "description": "Zamień kod źródłowy w piękne obrazy gotowe do udostępnienia." + }, + "cssGenerators": { + "title": "Generatory CSS", + "description": "Generatory box-shadow, border-radius i płynnego clamp()." + }, + "exifViewer": { + "title": "Podgląd i usuwanie EXIF", + "description": "Sprawdzaj i usuwaj metadane EXIF ze zdjęć lokalnie." + }, + "faviconGenerator": { + "title": "Generator favicon", + "description": "Generuj favicony, ikony aplikacji i fragmenty manifestu." + }, + "jsonDiff": { + "title": "Porównanie JSON", + "description": "Semantyczne, uwzględniające strukturę porównanie dwóch dokumentów JSON." + }, + "jsonpathPlayground": { + "title": "Piaskownica JSONPath", + "description": "Odpytuj JSON za pomocą JSONPath lub JMESPath i oglądaj wyniki na żywo." + }, + "keycodeInspector": { + "title": "Inspektor klawiszy", + "description": "Naciśnij klawisz, aby zobaczyć jego kody zdarzenia JavaScript i modyfikatory." + }, + "markdownTableGenerator": { + "title": "Generator tabel Markdown", + "description": "Twórz tabele Markdown i HTML w edytorze siatki na wzór arkusza." + }, + "timezoneConverter": { + "title": "Konwerter stref czasowych", + "description": "Konwertuj czas między wieloma strefami z podpowiedziami o zmianie dnia i godzinach pracy." + }, + "tokenCounter": { + "title": "Licznik tokenów LLM", + "description": "Licz tokeny LLM, wizualizuj chunki i szacuj koszt API." + }, + "webhookTester": { + "title": "Tester webhooków", + "description": "Przechwytuj i sprawdzaj przychodzące żądania webhooków w czasie rzeczywistym." + }, + "websocketTester": { + "title": "Tester WebSocket", + "description": "Łącz się z endpointami WebSocket i wymieniaj wiadomości w czasie rzeczywistym." + }, + "whoisLookup": { + "title": "Zapytanie Whois", + "description": "Sprawdzaj dane rejestracyjne domen i adresów IP przez RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Generator i weryfikator Bcrypt", + "subtitle": "Haszuj hasła za pomocą bcrypt i weryfikuj hasze — wszystko zostaje w Twojej przeglądarce", + "tabs": { + "hash": "Hasz", + "verify": "Weryfikuj" + }, + "showPassword": "Pokaż hasło", + "hidePassword": "Ukryj hasło", + "copy": "Kopiuj", + "copied": "Skopiowano", + "hash": { + "passwordLabel": "Hasło", + "passwordPlaceholder": "Wpisz hasło do zahaszowania…", + "costLabel": "Współczynnik kosztu", + "costHint": "Wyższy koszt podwaja pracę na każdym kroku. 10-12 to typowy wybór dla produkcji.", + "slowWarning": "Współczynniki kosztu 13 i wyższe mogą obliczać się kilka sekund.", + "generate": "Wygeneruj hasz", + "generating": "Haszowanie…", + "resultLabel": "Hasz bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Budowa hasza", + "algorithm": "algorytm", + "cost": "koszt", + "salt": "sól (22 znaki)", + "hash": "hasz (31 znaków)" + } + }, + "verify": { + "passwordLabel": "Hasło", + "passwordPlaceholder": "Wpisz hasło do sprawdzenia…", + "hashLabel": "Hasz bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Wpisz hasło i hasz bcrypt, aby je porównać.", + "invalidHash": "To nie wygląda na prawidłowy hasz bcrypt.", + "checking": "Porównywanie…", + "match": "Zgodne — hasło daje ten hasz.", + "noMatch": "Niezgodne — hasło nie daje tego hasza." + } + }, + "ChmodCalculator": { + "title": "Kalkulator Chmod", + "subtitle": "Konwertuj uprawnienia plików Unix między polami wyboru, postacią ósemkową i symboliczną", + "grid": { + "title": "Uprawnienia", + "read": "Odczyt", + "write": "Zapis", + "execute": "Wykonanie", + "owner": "Właściciel", + "group": "Grupa", + "others": "Pozostali" + }, + "special": { + "title": "Bity specjalne", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Ósemkowo", + "placeholder": "755 lub 4755", + "invalid": "Wpisz 3 lub 4 cyfry ósemkowe (0-7)." + }, + "symbolic": { + "label": "Symbolicznie", + "placeholder": "rwxr-xr-x", + "invalid": "Wpisz 9 znaków w rodzaju rwxr-xr-x (dozwolone s/S, t/T)." + }, + "output": { + "title": "Polecenie", + "copy": "Kopiuj", + "copied": "Skopiowano" + }, + "explain": { + "line": "{cls} może {perms}.", + "none": "{cls} nie ma uprawnień.", + "setuid": "Setuid: plik jest uruchamiany z uprawnieniami swojego właściciela.", + "setgid": "Setgid: plik jest uruchamiany z uprawnieniami swojej grupy (nowe pliki w katalogu dziedziczą jego grupę).", + "sticky": "Sticky: tylko właściciel pliku może go usunąć lub zmienić jego nazwę w tym katalogu." + }, + "presets": { + "title": "Popularne ustawienia", + "octal": "Ósemkowo", + "permissions": "Uprawnienia", + "description": "Typowe zastosowanie", + "p644": "Zwykłe pliki: właściciel zapisuje, wszyscy odczytują.", + "p755": "Pliki wykonywalne i katalogi: wszyscy mogą odczytać i wejść.", + "p600": "Pliki prywatne: tylko właściciel odczytuje i zapisuje.", + "p700": "Prywatne pliki wykonywalne i katalogi: tylko właściciel.", + "p664": "Pliki zapisywalne dla grupy.", + "p775": "Katalogi i pliki wykonywalne zapisywalne dla grupy.", + "p400": "Tylko do odczytu dla właściciela (np. klucze prywatne SSH)." + } + }, + "CodeScreenshot": { + "title": "Zrzut kodu", + "subtitle": "Zamień kod w piękne obrazy gotowe do udostępnienia", + "languageLabel": "Język", + "autoDetect": "Wykrywanie automatyczne", + "backgroundLabel": "Tło", + "paddingLabel": "Odstęp", + "filenameLabel": "Nazwa pliku", + "filenamePlaceholder": "example.ts", + "windowLabel": "Kropki okna", + "themeLabel": "Motyw okna", + "themeDark": "Ciemny", + "themeLight": "Jasny", + "scaleLabel": "Skala eksportu", + "copyImage": "Kopiuj obraz", + "copied": "Skopiowano", + "exportPng": "Eksportuj PNG", + "inputLabel": "Kod", + "detected": "Wykryto: {language}", + "clear": "Wyczyść", + "placeholder": "Wklej swój kod tutaj…", + "previewLabel": "Podgląd", + "emptyPreview": "Podgląd Twojego kodu pojawi się tutaj", + "exportFailed": "Nie udało się wyeksportować obrazu", + "copyUnsupported": "Kopiowanie obrazów nie jest obsługiwane w tej przeglądarce", + "copyFailed": "Nie udało się skopiować obrazu", + "backgrounds": { + "ocean": "Ocean", + "sunset": "Zachód słońca", + "candy": "Cukierek", + "forest": "Las", + "midnight": "Północ", + "gold": "Złoto", + "aurora": "Zorza", + "slate": "Łupek", + "transparent": "Przezroczysty" + } + }, + "CssGenerators": { + "title": "Generatory CSS", + "subtitle": "Box-shadow, border-radius i płynny clamp() — skopiuj CSS.", + "preview": "Podgląd", + "output": "CSS", + "copy": "Kopiuj", + "copied": "Skopiowano", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Płynny clamp()" + }, + "boxShadow": { + "presets": "Ustawienia", + "layer": "Warstwa", + "addLayer": "Dodaj warstwę", + "removeLayer": "Usuń warstwę", + "x": "X", + "y": "Y", + "blur": "Rozmycie", + "spread": "Rozszerzenie", + "color": "Kolor", + "alpha": "Alfa", + "inset": "Wewnętrzny", + "presetNames": { + "subtleCard": "Subtelna karta", + "elevated": "Uniesiony", + "hardOffset": "Twarde przesunięcie", + "neonGlow": "Neonowa poświata", + "inner": "Wewnętrzny", + "layeredSmooth": "Miękkie warstwy" + } + }, + "borderRadius": { + "modeLabel": "Tryb", + "modes": { + "simple": "Na róg", + "blob": "Organiczna plama" + }, + "unit": "Jednostka", + "linkCorners": "Powiąż rogi", + "randomize": "Losuj", + "horizontal": "Promienie poziome", + "vertical": "Promienie pionowe", + "corners": { + "tl": "Górny lewy", + "tr": "Górny prawy", + "br": "Dolny prawy", + "bl": "Dolny lewy" + } + }, + "clamp": { + "minFont": "Min. czcionka (px)", + "maxFont": "Maks. czcionka (px)", + "minViewport": "Min. viewport (px)", + "maxViewport": "Maks. viewport (px)", + "rootFont": "Czcionka główna (px)", + "viewportWidth": "Symulowany viewport", + "computedSize": "Obliczony rozmiar", + "slope": "Nachylenie", + "intercept": "Punkt przecięcia", + "previewText": "Szybki brązowy lis", + "invalid": "Wpisz prawidłowe i różniące się granice viewportu." + } + }, + "ExifViewer": { + "title": "Podgląd i usuwanie EXIF", + "subtitle": "Sprawdzaj metadane zdjęć i usuwaj je w swojej przeglądarce", + "dropTitle": "Upuść obraz tutaj", + "dropHint": "lub kliknij, aby przeglądać — Twój obraz nigdy nie opuszcza urządzenia", + "browse": "Wybierz obraz", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Odczytywanie metadanych…", + "parseFailed": "Nie udało się odczytać tego obrazu", + "clear": "Usuń obraz", + "gpsWarning": "Zawiera lokalizację GPS", + "openMap": "Zobacz na OpenStreetMap", + "noMetadata": "Nie znaleziono osadzonych metadanych w tym obrazie.", + "facts": { + "name": "Nazwa", + "type": "Typ", + "dimensions": "Wymiary", + "size": "Rozmiar" + }, + "groups": { + "camera": "Aparat", + "exposure": "Ekspozycja", + "timestamps": "Znaczniki czasu", + "gps": "Lokalizacja GPS", + "software": "Oprogramowanie i inne" + }, + "gpsCard": { + "latitude": "Szerokość geogr.", + "longitude": "Długość geogr.", + "altitude": "Wysokość" + }, + "raw": { + "title": "Wszystkie metadane", + "copyJson": "Kopiuj JSON", + "copied": "Skopiowano" + }, + "remove": { + "title": "Usuń metadane", + "jpegDescription": "Bezstratnie usuń EXIF, XMP, IPTC i komentarze bez ponownej kompresji obrazu.", + "reencodeDescription": "Przekoduj obraz, aby usunąć osadzone metadane. Powoduje to ponowną kompresję pikseli.", + "keepIcc": "Zachowaj profil kolorów (ICC)", + "stripButton": "Usuń metadane", + "reencodeButton": "Przekoduj i usuń", + "unsupported": "Usuwanie metadanych nie jest obsługiwane dla tego typu pliku.", + "failed": "Nie udało się usunąć metadanych", + "before": "Przed", + "after": "Po", + "download": "Pobierz oczyszczony obraz" + } + }, + "FaviconGenerator": { + "title": "Generator favicon", + "subtitle": "Twórz favicony i ikony aplikacji z jednego obrazu", + "dropTitle": "Upuść obraz tutaj", + "dropHint": "PNG, JPEG lub SVG — kliknij, aby przeglądać", + "browse": "Wybierz obraz", + "clear": "Usuń obraz", + "backgroundLabel": "Tło", + "transparent": "Przezroczysty", + "solidColor": "Jednolity", + "cornerLabel": "Kształt rogów", + "cornerNone": "Kwadrat", + "cornerRounded": "Zaokrąglony", + "cornerCircle": "Koło", + "maskPaddingLabel": "Odstęp maskable", + "maskable": "maskable", + "previewLabel": "Podgląd", + "copy": "Kopiuj", + "headSnippet": "Fragment head HTML", + "downloadIco": "Pobierz favicon.ico", + "downloadZip": "Pobierz wszystko (.zip)" + }, + "JsonDiff": { + "title": "Porównanie JSON", + "subtitle": "Semantyczne, uwzględniające strukturę porównanie dwóch dokumentów JSON — kolejność kluczy i formatowanie są ignorowane", + "options": { + "ignoreArrayOrder": "Ignoruj kolejność tablic" + }, + "loadBothSamples": "Wczytaj przykłady", + "sample": "Przykład", + "format": "Formatuj", + "clear": "Wyczyść", + "formatError": "Nie można sformatować: dane wejściowe nie są prawidłowym JSON", + "placeholder": "Wklej lub wpisz JSON tutaj…", + "panels": { + "left": "Oryginał (lewa)", + "right": "Zmodyfikowany (prawa)", + "results": "Różnice" + }, + "summary": { + "added": "{count} dodano", + "removed": "{count} usunięto", + "changed": "{count} zmieniono" + }, + "types": { + "added": "Dodano", + "removed": "Usunięto", + "changed": "Zmieniono" + }, + "copyChanges": "Kopiuj jako JSON", + "copied": "Skopiowano", + "empty": "Wklej JSON do obu paneli, aby zobaczyć ich różnice.", + "identical": "Oba dokumenty są semantycznie identyczne." + }, + "JsonpathPlayground": { + "title": "Piaskownica JSONPath", + "subtitle": "Odpytuj dokumenty JSON za pomocą JSONPath lub JMESPath i oglądaj wyniki na żywo", + "engineLabel": "Silnik", + "queryLabel": "Zapytanie", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Ściąga", + "cheats": { + "jpAllTitles": "Wszystkie tytuły książek", + "jpAllAuthors": "Wszyscy autorzy, w dowolnym miejscu dokumentu", + "jpFirstBook": "Pierwsza książka", + "jpLastBook": "Ostatnia książka", + "jpSlice": "Pierwsze dwie książki (wycinek)", + "jpFilterPrice": "Książki tańsze niż 40", + "jpFilterInStock": "Tytuły książek dostępnych w magazynie", + "jpRecursive": "Wszystkie wartości, rekurencyjnie", + "jmAllTitles": "Wszystkie tytuły książek", + "jmFirstAuthor": "Autor pierwszej książki", + "jmLastBook": "Ostatnia książka", + "jmSlice": "Pierwsze dwie książki (wycinek)", + "jmFilterPrice": "Tytuły książek tańszych niż 40", + "jmCount": "Policz książki dostępne w magazynie", + "jmProjection": "Przekształć każdą książkę w nowy obiekt", + "jmSort": "Tytuły posortowane według ceny" + }, + "panels": { + "input": "Wejście JSON", + "results": "Wyniki" + }, + "sample": "Przykład", + "format": "Formatuj", + "clear": "Wyczyść", + "formatError": "Nie można sformatować: dane wejściowe nie są prawidłowym JSON", + "placeholder": "Wklej lub wpisz JSON tutaj…", + "outputPlaceholder": "Dopasowania pojawią się tutaj…", + "matchCount": "{count, plural, one {# dopasowanie} few {# dopasowania} many {# dopasowań} other {# dopasowania}}", + "copy": "Kopiuj", + "copied": "Skopiowano" + }, + "KeycodeInspector": { + "title": "Inspektor klawiszy", + "subtitle": "Naciśnij dowolny klawisz, aby zbadać jego zdarzenie klawiatury JavaScript", + "capture": { + "prompt": "Naciśnij dowolny klawisz…", + "ready": "Nasłuchiwanie — naciśnij dowolny klawisz na klawiaturze", + "clickToFocus": "Kliknij tutaj, aby ustawić fokus, a następnie naciśnij dowolny klawisz" + }, + "hero": { + "spaceKey": "(Spacja)", + "keyLabel": "event.key", + "repeat": "powtórzenie" + }, + "copyJson": "Kopiuj JSON", + "copiedJson": "JSON zdarzenia skopiowano do schowka", + "deprecated": "przestarzałe", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standardowa", + "left": "Lewa", + "right": "Prawa", + "numpad": "Klawiatura numeryczna", + "unknown": "Nieznana" + }, + "history": { + "title": "Historia", + "clear": "Wyczyść historię", + "empty": "Naciśnięcia klawiszy pojawią się tutaj." + } + }, + "MarkdownTableGenerator": { + "title": "Generator tabel Markdown", + "subtitle": "Edytuj siatkę, wklej z arkusza i skopiuj ładnie wyrównaną tabelę Markdown lub HTML", + "addRow": "Dodaj wiersz", + "addColumn": "Dodaj kolumnę", + "importLabel": "Importuj", + "importPlaceholder": "Wklej TSV (z Excela / Arkuszy Google) lub CSV tutaj…", + "importButton": "Importuj do siatki", + "pasteClipboard": "Wklej ze schowka", + "importEmpty": "Nie ma czego importować — najpierw wklej kilka wierszy", + "clipboardError": "Nie udało się odczytać ze schowka", + "clear": "Wyczyść", + "panels": { + "editor": "Edytor tabeli", + "output": "Wynik" + }, + "align": { + "left": "Wyrównaj do lewej", + "center": "Wyśrodkuj", + "right": "Wyrównaj do prawej" + }, + "headerPlaceholder": "Nagłówek", + "cellPlaceholder": "Komórka", + "removeRow": "Usuń wiersz", + "removeColumn": "Usuń kolumnę", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Kopiuj", + "copied": "Skopiowano" + }, + "TimezoneConverter": { + "title": "Konwerter stref czasowych", + "subtitle": "Konwertuj jeden moment na całym świecie, aby planować spotkania.", + "dateTimeLabel": "Data i godzina", + "now": "Teraz", + "sourceZoneLabel": "Źródłowa strefa czasowa", + "selectZone": "Wybierz strefę", + "searchZone": "Szukaj stref czasowych...", + "noZone": "Nie znaleziono strefy czasowej.", + "scrubLabel": "Przesuń czas", + "reset": "Resetuj", + "targetsLabel": "Strefy docelowe", + "addZone": "Dodaj strefę", + "remove": "Usuń", + "localBadge": "Lokalna", + "invalidDate": "Wpisz prawidłową datę i godzinę.", + "bands": { + "work": "Godziny pracy (09:00–18:00)", + "edge": "Godziny brzegowe (wczesny ranek / wieczór)", + "night": "Noc" + } + }, + "TokenCounter": { + "title": "Licznik tokenów LLM", + "subtitle": "Licz tokeny, wizualizuj chunki i szacuj koszt API.", + "inputLabel": "Tekst", + "sample": "Przykład", + "clear": "Wyczyść", + "placeholder": "Wklej tekst, kod lub JSON, aby policzyć tokeny...", + "encoding": "Kodowanie", + "loading": "Ładowanie tokenizera...", + "encodings": { + "o200k": "o200k_base (GPT-4o / seria o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokeny", + "characters": "Znaki", + "words": "Słowa", + "ratio": "Tokeny/słowo", + "ratioSub": "ok." + }, + "claudeRow": "Claude (przybliżone)", + "estimate": "szacunek", + "viz": { + "title": "Wizualizacja tokenów", + "toggle": "Pokaż", + "hint": "Włącz, aby pokolorować każdy token jako chunk.", + "empty": "Wpisz tekst, aby zwizualizować tokeny.", + "truncated": "Pokazano pierwsze {limit} z {total} tokenów." + }, + "cost": { + "title": "Kalkulator kosztów", + "disclaimer": "edytowalne wartości domyślne — sprawdź aktualne ceny", + "model": "Model", + "expectedOutput": "Oczekiwane tokeny wyjściowe", + "customInput": "Wejście $/MTok", + "customOutput": "Wyjście $/MTok", + "inputCost": "Koszt wejścia", + "outputCost": "Koszt wyjścia", + "total": "Razem", + "per1000": "Na 1000 wywołań" + } + }, + "WebhookTester": { + "title": "Tester webhooków", + "subtitle": "Utwórz tymczasowy URL i sprawdzaj przychodzące żądania webhooków na żywo.", + "intro": { + "title": "Utwórz URL webhooka, aby zacząć przechwytywać żądania", + "hint": "Otrzymasz niemożliwy do odgadnięcia URL, który rejestruje każde przychodzące żądanie. Wygasa po 24 godzinach." + }, + "createBtn": "Utwórz URL webhooka", + "creating": "Tworzenie…", + "urlLabel": "Twój URL webhooka", + "expiresIn": "Wygasa za {countdown}", + "copyUrlBtn": "Kopiuj URL", + "copied": "Skopiowano", + "clearBtn": "Wyczyść", + "newBinBtn": "Nowy URL", + "requestsTitle": "Żądania", + "selectPrompt": "Wybierz żądanie, aby sprawdzić jego szczegóły.", + "expiredNotice": "Ten URL webhooka wygasł. Utwórz nowy, aby kontynuować testowanie.", + "base64Badge": "base64", + "truncatedBadge": "obcięte", + "empty": { + "title": "Oczekiwanie na żądania…", + "hint": "Wyślij żądanie na swój URL, a pojawi się tutaj natychmiast. Spróbuj tak:" + }, + "detail": { + "receivedAt": "Odebrano", + "source": "IP źródłowe", + "contentType": "Content-Type", + "query": "Parametry query", + "headers": "Nagłówki", + "body": "Treść", + "copyBody": "Kopiuj treść", + "emptyBody": "Brak treści" + }, + "errors": { + "createFailed": "Nie udało się utworzyć URL webhooka. Spróbuj ponownie.", + "clearFailed": "Nie udało się wyczyścić żądań. Spróbuj ponownie." + } + }, + "WebsocketTester": { + "title": "Tester WebSocket", + "subtitle": "Połącz się z endpointem WebSocket i wysyłaj, odbieraj oraz sprawdzaj wiadomości na żywo", + "urlLabel": "URL endpointu", + "protocolsLabel": "Podprotokoły", + "protocolsPlaceholder": "opcjonalnie, oddzielone przecinkami", + "connect": "Połącz", + "disconnect": "Rozłącz", + "status": { + "disconnected": "Rozłączono", + "connecting": "Łączenie", + "connected": "Połączono", + "error": "Błąd" + }, + "messages": { + "connecting": "Łączenie z {url}…", + "connected": "Połączenie nawiązane", + "connectedWithProtocol": "Połączenie nawiązane (protokół: {protocol})", + "invalidUrl": "Nieprawidłowy URL — musi zaczynać się od ws:// lub wss://", + "connectFailed": "Nie udało się otworzyć połączenia", + "socketError": "Wystąpił błąd połączenia", + "notConnected": "Nie połączono — najpierw się połącz", + "binaryMessage": "Odebrano wiadomość binarną" + }, + "log": { + "title": "Dziennik wiadomości", + "empty": "Brak wiadomości. Połącz się i zacznij wysyłać.", + "copyAll": "Kopiuj wszystko", + "download": "Pobierz jako .txt", + "clear": "Wyczyść dziennik", + "prettyPrint": "Formatuj JSON", + "showRaw": "Pokaż surowy tekst" + }, + "composer": { + "placeholder": "Wpisz wiadomość do wysłania…", + "send": "Wyślij", + "formatJson": "Formatuj jako JSON", + "invalidJson": "Wiadomość nie jest prawidłowym JSON", + "hint": "Cmd/Ctrl+Enter, aby wysłać" + }, + "ping": { + "label": "Auto-ping", + "seconds": "s", + "intervalTitle": "Interwał pingu w sekundach", + "payloadPlaceholder": "payload pingu" + } + }, + "WhoisLookup": { + "title": "Zapytanie Whois", + "subtitle": "Sprawdzaj dane rejestracyjne dowolnej domeny lub adresu IP przez RDAP", + "placeholder": "example.com lub 8.8.8.8", + "lookupBtn": "Sprawdź", + "looking": "Sprawdzanie…", + "refreshBtn": "Odśwież", + "tryLabel": "Spróbuj:", + "copy": "Kopiuj", + "copied": "Skopiowano", + "notAvailable": "Niedostępne", + "summary": { + "registrar": "Rejestrator", + "network": "Sieć" + }, + "dates": { + "created": "Utworzono", + "updated": "Zaktualizowano", + "expires": "Wygasa" + }, + "sections": { + "status": "Status", + "nameservers": "Serwery nazw", + "contacts": "Kontakty", + "rawRdap": "Surowy RDAP" + }, + "contacts": { + "roles": "Role", + "name": "Nazwa", + "organization": "Organizacja" + }, + "errors": { + "invalidTarget": "Wpisz prawidłową nazwę domeny lub adres IP", + "lookupFailed": "Zapytanie nie powiodło się (status {status})" + } } } diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json index 5cf620ae..a5171541 100644 --- a/apps/web/messages/pt-BR.json +++ b/apps/web/messages/pt-BR.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Calculadora Chmod", + "codeScreenshot": "Captura de Código", + "cssGenerators": "Geradores CSS", + "exifViewer": "Visualizador EXIF", + "faviconGenerator": "Gerador de Favicon", + "jsonDiff": "Diff JSON", + "jsonpathPlayground": "Playground JSONPath", + "keycodeInspector": "Inspetor de Keycode", + "markdownTableGenerator": "Tabela Markdown", + "timezoneConverter": "Conversor de Fuso Horário", + "tokenCounter": "Contador de Tokens", + "webhookTester": "Testador de Webhook", + "websocketTester": "Testador de WebSocket", + "whoisLookup": "Consulta Whois" }, "Help": { "title": "Ajuda e documentação", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Gerador e Verificador Bcrypt", + "description": "Gere hash de senhas com bcrypt e verifique hashes existentes." + }, + "chmodCalculator": { + "title": "Calculadora Chmod", + "description": "Converta permissões Unix entre octal, simbólico e caixas de seleção." + }, + "codeScreenshot": { + "title": "Captura de Código", + "description": "Transforme código-fonte em belas imagens compartilháveis." + }, + "cssGenerators": { + "title": "Geradores CSS", + "description": "Geradores de box-shadow, border-radius e clamp() fluido." + }, + "exifViewer": { + "title": "Visualizador e Removedor EXIF", + "description": "Inspecione e remova metadados EXIF de fotos localmente." + }, + "faviconGenerator": { + "title": "Gerador de Favicon", + "description": "Gere favicons, ícones de app e trechos de manifest." + }, + "jsonDiff": { + "title": "Diff JSON", + "description": "Diff semântico e ciente de estrutura de dois documentos JSON." + }, + "jsonpathPlayground": { + "title": "Playground JSONPath", + "description": "Consulte JSON com JSONPath ou JMESPath e veja resultados ao vivo." + }, + "keycodeInspector": { + "title": "Inspetor de Keycode", + "description": "Pressione uma tecla para ver seus códigos de evento JavaScript e modificadores." + }, + "markdownTableGenerator": { + "title": "Gerador de Tabela Markdown", + "description": "Crie tabelas Markdown e HTML em um editor de grade tipo planilha." + }, + "timezoneConverter": { + "title": "Conversor de Fuso Horário", + "description": "Converta um horário entre vários fusos com indicações de mudança de dia e horário comercial." + }, + "tokenCounter": { + "title": "Contador de Tokens LLM", + "description": "Conte tokens de LLM, visualize chunks e estime o custo de API." + }, + "webhookTester": { + "title": "Testador de Webhook", + "description": "Capture e inspecione requisições de webhook recebidas em tempo real." + }, + "websocketTester": { + "title": "Testador de WebSocket", + "description": "Conecte-se a endpoints WebSocket e troque mensagens em tempo real." + }, + "whoisLookup": { + "title": "Consulta Whois", + "description": "Consulte detalhes de registro de domínio e IP via RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Gerador e Verificador Bcrypt", + "subtitle": "Gere hash de senhas com bcrypt e verifique hashes — tudo permanece no seu navegador", + "tabs": { + "hash": "Hash", + "verify": "Verificar" + }, + "showPassword": "Mostrar senha", + "hidePassword": "Ocultar senha", + "copy": "Copiar", + "copied": "Copiado", + "hash": { + "passwordLabel": "Senha", + "passwordPlaceholder": "Digite uma senha para gerar o hash…", + "costLabel": "Fator de custo", + "costHint": "Um custo maior dobra o trabalho por etapa. 10-12 é uma escolha comum para produção.", + "slowWarning": "Fatores de custo de 13 ou mais podem levar vários segundos para calcular.", + "generate": "Gerar hash", + "generating": "Gerando hash…", + "resultLabel": "Hash bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Anatomia do hash", + "algorithm": "algoritmo", + "cost": "custo", + "salt": "salt (22 caracteres)", + "hash": "hash (31 caracteres)" + } + }, + "verify": { + "passwordLabel": "Senha", + "passwordPlaceholder": "Digite a senha para verificar…", + "hashLabel": "Hash bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Digite uma senha e um hash bcrypt para compará-los.", + "invalidHash": "Isto não parece um hash bcrypt válido.", + "checking": "Comparando…", + "match": "Corresponde — a senha produz este hash.", + "noMatch": "Não corresponde — a senha não produz este hash." + } + }, + "ChmodCalculator": { + "title": "Calculadora Chmod", + "subtitle": "Converta permissões de arquivo Unix entre caixas de seleção, octal e notação simbólica", + "grid": { + "title": "Permissões", + "read": "Leitura", + "write": "Escrita", + "execute": "Execução", + "owner": "Dono", + "group": "Grupo", + "others": "Outros" + }, + "special": { + "title": "Bits especiais", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Octal", + "placeholder": "755 ou 4755", + "invalid": "Digite 3 ou 4 dígitos octais (0-7)." + }, + "symbolic": { + "label": "Simbólico", + "placeholder": "rwxr-xr-x", + "invalid": "Digite 9 caracteres como rwxr-xr-x (s/S, t/T permitidos)." + }, + "output": { + "title": "Comando", + "copy": "Copiar", + "copied": "Copiado" + }, + "explain": { + "line": "{cls} pode {perms}.", + "none": "{cls} não tem permissões.", + "setuid": "Setuid: o arquivo é executado com os privilégios de seu dono.", + "setgid": "Setgid: o arquivo é executado com os privilégios de seu grupo (novos arquivos em um diretório herdam seu grupo).", + "sticky": "Sticky: apenas o dono de um arquivo pode excluí-lo ou renomeá-lo dentro deste diretório." + }, + "presets": { + "title": "Presets comuns", + "octal": "Octal", + "permissions": "Permissões", + "description": "Uso típico", + "p644": "Arquivos comuns: o dono escreve, todos leem.", + "p755": "Executáveis e diretórios: todos podem ler e entrar.", + "p600": "Arquivos privados: apenas o dono lê e escreve.", + "p700": "Executáveis e diretórios privados: apenas o dono.", + "p664": "Arquivos graváveis pelo grupo.", + "p775": "Diretórios e executáveis graváveis pelo grupo.", + "p400": "Somente leitura para o dono (ex.: chaves privadas SSH)." + } + }, + "CodeScreenshot": { + "title": "Captura de Código", + "subtitle": "Transforme código em belas imagens compartilháveis", + "languageLabel": "Linguagem", + "autoDetect": "Detecção automática", + "backgroundLabel": "Plano de fundo", + "paddingLabel": "Espaçamento", + "filenameLabel": "Nome do arquivo", + "filenamePlaceholder": "exemplo.ts", + "windowLabel": "Pontos da janela", + "themeLabel": "Tema da janela", + "themeDark": "Escuro", + "themeLight": "Claro", + "scaleLabel": "Escala de exportação", + "copyImage": "Copiar imagem", + "copied": "Copiado", + "exportPng": "Exportar PNG", + "inputLabel": "Código", + "detected": "Detectado: {language}", + "clear": "Limpar", + "placeholder": "Cole seu código aqui…", + "previewLabel": "Pré-visualização", + "emptyPreview": "A pré-visualização do seu código aparecerá aqui", + "exportFailed": "Falha ao exportar a imagem", + "copyUnsupported": "A cópia de imagens não é suportada neste navegador", + "copyFailed": "Falha ao copiar a imagem", + "backgrounds": { + "ocean": "Oceano", + "sunset": "Pôr do sol", + "candy": "Doce", + "forest": "Floresta", + "midnight": "Meia-noite", + "gold": "Ouro", + "aurora": "Aurora", + "slate": "Ardósia", + "transparent": "Transparente" + } + }, + "CssGenerators": { + "title": "Geradores CSS", + "subtitle": "Box-shadow, border-radius e clamp() fluido — copie o CSS.", + "preview": "Pré-visualização", + "output": "CSS", + "copy": "Copiar", + "copied": "Copiado", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() fluido" + }, + "boxShadow": { + "presets": "Presets", + "layer": "Camada", + "addLayer": "Adicionar camada", + "removeLayer": "Remover camada", + "x": "X", + "y": "Y", + "blur": "Desfoque", + "spread": "Propagação", + "color": "Cor", + "alpha": "Alfa", + "inset": "Interno", + "presetNames": { + "subtleCard": "Cartão sutil", + "elevated": "Elevado", + "hardOffset": "Deslocamento rígido", + "neonGlow": "Brilho neon", + "inner": "Interno", + "layeredSmooth": "Camadas suaves" + } + }, + "borderRadius": { + "modeLabel": "Modo", + "modes": { + "simple": "Por canto", + "blob": "Blob orgânico" + }, + "unit": "Unidade", + "linkCorners": "Vincular cantos", + "randomize": "Aleatorizar", + "horizontal": "Raios horizontais", + "vertical": "Raios verticais", + "corners": { + "tl": "Superior esquerdo", + "tr": "Superior direito", + "br": "Inferior direito", + "bl": "Inferior esquerdo" + } + }, + "clamp": { + "minFont": "Fonte mínima (px)", + "maxFont": "Fonte máxima (px)", + "minViewport": "Viewport mínima (px)", + "maxViewport": "Viewport máxima (px)", + "rootFont": "Fonte raiz (px)", + "viewportWidth": "Viewport simulada", + "computedSize": "Tamanho calculado", + "slope": "Inclinação", + "intercept": "Intercepto", + "previewText": "A rápida raposa marrom", + "invalid": "Digite limites de viewport válidos e diferentes." + } + }, + "ExifViewer": { + "title": "Visualizador e Removedor EXIF", + "subtitle": "Inspecione os metadados de fotos e remova-os no seu navegador", + "dropTitle": "Solte uma imagem aqui", + "dropHint": "ou clique para procurar — sua imagem nunca sai do seu dispositivo", + "browse": "Escolher imagem", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Lendo metadados…", + "parseFailed": "Falha ao ler esta imagem", + "clear": "Remover imagem", + "gpsWarning": "Contém localização GPS", + "openMap": "Ver no OpenStreetMap", + "noMetadata": "Nenhum metadado incorporado foi encontrado nesta imagem.", + "facts": { + "name": "Nome", + "type": "Tipo", + "dimensions": "Dimensões", + "size": "Tamanho" + }, + "groups": { + "camera": "Câmera", + "exposure": "Exposição", + "timestamps": "Carimbos de data/hora", + "gps": "Localização GPS", + "software": "Software e outros" + }, + "gpsCard": { + "latitude": "Latitude", + "longitude": "Longitude", + "altitude": "Altitude" + }, + "raw": { + "title": "Todos os metadados", + "copyJson": "Copiar JSON", + "copied": "Copiado" + }, + "remove": { + "title": "Remover metadados", + "jpegDescription": "Remova EXIF, XMP, IPTC e comentários sem perdas, sem recomprimir a imagem.", + "reencodeDescription": "Recodifique a imagem para remover metadados incorporados. Isso recomprime os pixels.", + "keepIcc": "Manter perfil de cor (ICC)", + "stripButton": "Remover metadados", + "reencodeButton": "Recodificar e remover", + "unsupported": "A remoção de metadados não é suportada para este tipo de arquivo.", + "failed": "Falha ao remover metadados", + "before": "Antes", + "after": "Depois", + "download": "Baixar imagem limpa" + } + }, + "FaviconGenerator": { + "title": "Gerador de Favicon", + "subtitle": "Crie favicons e ícones de app a partir de uma imagem", + "dropTitle": "Solte uma imagem aqui", + "dropHint": "PNG, JPEG ou SVG — clique para procurar", + "browse": "Escolher imagem", + "clear": "Remover imagem", + "backgroundLabel": "Plano de fundo", + "transparent": "Transparente", + "solidColor": "Sólido", + "cornerLabel": "Formato do canto", + "cornerNone": "Quadrado", + "cornerRounded": "Arredondado", + "cornerCircle": "Círculo", + "maskPaddingLabel": "Espaçamento maskable", + "maskable": "maskable", + "previewLabel": "Pré-visualização", + "copy": "Copiar", + "headSnippet": "Trecho do head HTML", + "downloadIco": "Baixar favicon.ico", + "downloadZip": "Baixar tudo (.zip)" + }, + "JsonDiff": { + "title": "Diff JSON", + "subtitle": "Comparação semântica e ciente de estrutura de dois documentos JSON — ordem de chaves e formatação ignoradas", + "options": { + "ignoreArrayOrder": "Ignorar ordem dos arrays" + }, + "loadBothSamples": "Carregar exemplos", + "sample": "Exemplo", + "format": "Formatar", + "clear": "Limpar", + "formatError": "Não é possível formatar: a entrada não é um JSON válido", + "placeholder": "Cole ou digite JSON aqui…", + "panels": { + "left": "Original (esquerda)", + "right": "Modificado (direita)", + "results": "Diferenças" + }, + "summary": { + "added": "{count} adicionado(s)", + "removed": "{count} removido(s)", + "changed": "{count} alterado(s)" + }, + "types": { + "added": "Adicionado", + "removed": "Removido", + "changed": "Alterado" + }, + "copyChanges": "Copiar como JSON", + "copied": "Copiado", + "empty": "Cole JSON nos dois painéis para ver suas diferenças.", + "identical": "Os dois documentos são semanticamente idênticos." + }, + "JsonpathPlayground": { + "title": "Playground JSONPath", + "subtitle": "Consulte documentos JSON com JSONPath ou JMESPath e veja resultados ao vivo", + "engineLabel": "Motor", + "queryLabel": "Consulta", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Referência rápida", + "cheats": { + "jpAllTitles": "Todos os títulos de livros", + "jpAllAuthors": "Todos os autores, em qualquer lugar do documento", + "jpFirstBook": "Primeiro livro", + "jpLastBook": "Último livro", + "jpSlice": "Primeiros dois livros (slice)", + "jpFilterPrice": "Livros mais baratos que 40", + "jpFilterInStock": "Títulos de livros em estoque", + "jpRecursive": "Todos os valores, recursivamente", + "jmAllTitles": "Todos os títulos de livros", + "jmFirstAuthor": "Autor do primeiro livro", + "jmLastBook": "Último livro", + "jmSlice": "Primeiros dois livros (slice)", + "jmFilterPrice": "Títulos de livros mais baratos que 40", + "jmCount": "Contar livros em estoque", + "jmProjection": "Remodelar cada livro em um novo objeto", + "jmSort": "Títulos ordenados por preço" + }, + "panels": { + "input": "Entrada JSON", + "results": "Resultados" + }, + "sample": "Exemplo", + "format": "Formatar", + "clear": "Limpar", + "formatError": "Não é possível formatar: a entrada não é um JSON válido", + "placeholder": "Cole ou digite JSON aqui…", + "outputPlaceholder": "As correspondências aparecerão aqui…", + "matchCount": "{count, plural, one {# correspondência} other {# correspondências}}", + "copy": "Copiar", + "copied": "Copiado" + }, + "KeycodeInspector": { + "title": "Inspetor de Keycode", + "subtitle": "Pressione qualquer tecla para inspecionar seu evento de teclado JavaScript", + "capture": { + "prompt": "Pressione qualquer tecla…", + "ready": "Ouvindo — pressione qualquer tecla no seu teclado", + "clickToFocus": "Clique aqui para focar e depois pressione qualquer tecla" + }, + "hero": { + "spaceKey": "(Espaço)", + "keyLabel": "event.key", + "repeat": "repetição" + }, + "copyJson": "Copiar JSON", + "copiedJson": "JSON do evento copiado para a área de transferência", + "deprecated": "obsoleto", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Padrão", + "left": "Esquerda", + "right": "Direita", + "numpad": "Teclado numérico", + "unknown": "Desconhecido" + }, + "history": { + "title": "Histórico", + "clear": "Limpar histórico", + "empty": "As teclas pressionadas aparecerão aqui." + } + }, + "MarkdownTableGenerator": { + "title": "Gerador de Tabela Markdown", + "subtitle": "Edite uma grade, cole de uma planilha e copie uma tabela Markdown ou HTML bem alinhada", + "addRow": "Adicionar linha", + "addColumn": "Adicionar coluna", + "importLabel": "Importar", + "importPlaceholder": "Cole TSV (do Excel / Google Sheets) ou CSV aqui…", + "importButton": "Importar para a grade", + "pasteClipboard": "Colar da área de transferência", + "importEmpty": "Nada para importar — cole algumas linhas primeiro", + "clipboardError": "Não foi possível ler da área de transferência", + "clear": "Limpar", + "panels": { + "editor": "Editor de tabela", + "output": "Saída" + }, + "align": { + "left": "Alinhar à esquerda", + "center": "Centralizar", + "right": "Alinhar à direita" + }, + "headerPlaceholder": "Cabeçalho", + "cellPlaceholder": "Célula", + "removeRow": "Remover linha", + "removeColumn": "Remover coluna", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Copiar", + "copied": "Copiado" + }, + "TimezoneConverter": { + "title": "Conversor de Fuso Horário", + "subtitle": "Converta um momento pelo mundo para planejar reuniões.", + "dateTimeLabel": "Data e hora", + "now": "Agora", + "sourceZoneLabel": "Fuso de origem", + "selectZone": "Selecionar fuso", + "searchZone": "Buscar fusos horários...", + "noZone": "Nenhum fuso horário encontrado.", + "scrubLabel": "Ajustar horário", + "reset": "Redefinir", + "targetsLabel": "Fusos de destino", + "addZone": "Adicionar fuso", + "remove": "Remover", + "localBadge": "Local", + "invalidDate": "Digite uma data e hora válidas.", + "bands": { + "work": "Horário comercial (09:00–18:00)", + "edge": "Horários limítrofes (manhã cedo / noite)", + "night": "Noite" + } + }, + "TokenCounter": { + "title": "Contador de Tokens LLM", + "subtitle": "Conte tokens, visualize chunks e estime o custo de API.", + "inputLabel": "Texto", + "sample": "Exemplo", + "clear": "Limpar", + "placeholder": "Cole texto, código ou JSON para contar tokens...", + "encoding": "Codificação", + "loading": "Carregando tokenizador...", + "encodings": { + "o200k": "o200k_base (GPT-4o / série o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Caracteres", + "words": "Palavras", + "ratio": "Tokens/palavra", + "ratioSub": "aprox." + }, + "claudeRow": "Claude (aproximado)", + "estimate": "estimativa", + "viz": { + "title": "Visualização de tokens", + "toggle": "Mostrar", + "hint": "Alterne para colorir cada token como um chunk.", + "empty": "Digite texto para visualizar os tokens.", + "truncated": "Mostrando os primeiros {limit} de {total} tokens." + }, + "cost": { + "title": "Calculadora de custo", + "disclaimer": "valores padrão editáveis — verifique os preços atuais", + "model": "Modelo", + "expectedOutput": "Tokens de saída esperados", + "customInput": "Entrada $/MTok", + "customOutput": "Saída $/MTok", + "inputCost": "Custo de entrada", + "outputCost": "Custo de saída", + "total": "Total", + "per1000": "Por 1000 chamadas" + } + }, + "WebhookTester": { + "title": "Testador de Webhook", + "subtitle": "Crie uma URL temporária e inspecione requisições de webhook recebidas ao vivo.", + "intro": { + "title": "Crie uma URL de webhook para começar a capturar requisições", + "hint": "Você receberá uma URL impossível de adivinhar que registra cada requisição recebida. Ela expira após 24 horas." + }, + "createBtn": "Criar URL de webhook", + "creating": "Criando…", + "urlLabel": "Sua URL de webhook", + "expiresIn": "Expira em {countdown}", + "copyUrlBtn": "Copiar URL", + "copied": "Copiado", + "clearBtn": "Limpar", + "newBinBtn": "Nova URL", + "requestsTitle": "Requisições", + "selectPrompt": "Selecione uma requisição para inspecionar seus detalhes.", + "expiredNotice": "Esta URL de webhook expirou. Crie uma nova para continuar testando.", + "base64Badge": "base64", + "truncatedBadge": "truncado", + "empty": { + "title": "Aguardando requisições…", + "hint": "Envie uma requisição para sua URL e ela aparecerá aqui instantaneamente. Experimente:" + }, + "detail": { + "receivedAt": "Recebido", + "source": "IP de origem", + "contentType": "Content-Type", + "query": "Parâmetros de query", + "headers": "Cabeçalhos", + "body": "Corpo", + "copyBody": "Copiar corpo", + "emptyBody": "Sem corpo" + }, + "errors": { + "createFailed": "Falha ao criar a URL de webhook. Tente novamente.", + "clearFailed": "Falha ao limpar as requisições. Tente novamente." + } + }, + "WebsocketTester": { + "title": "Testador de WebSocket", + "subtitle": "Conecte-se a um endpoint WebSocket e envie, receba e inspecione mensagens ao vivo", + "urlLabel": "URL do endpoint", + "protocolsLabel": "Subprotocolos", + "protocolsPlaceholder": "opcional, separados por vírgula", + "connect": "Conectar", + "disconnect": "Desconectar", + "status": { + "disconnected": "Desconectado", + "connecting": "Conectando", + "connected": "Conectado", + "error": "Erro" + }, + "messages": { + "connecting": "Conectando a {url}…", + "connected": "Conexão estabelecida", + "connectedWithProtocol": "Conexão estabelecida (protocolo: {protocol})", + "invalidUrl": "URL inválida — deve começar com ws:// ou wss://", + "connectFailed": "Falha ao abrir a conexão", + "socketError": "Ocorreu um erro de conexão", + "notConnected": "Não conectado — conecte-se primeiro", + "binaryMessage": "Mensagem binária recebida" + }, + "log": { + "title": "Registro de mensagens", + "empty": "Nenhuma mensagem ainda. Conecte-se e comece a enviar.", + "copyAll": "Copiar tudo", + "download": "Baixar como .txt", + "clear": "Limpar registro", + "prettyPrint": "Formatar JSON", + "showRaw": "Mostrar texto bruto" + }, + "composer": { + "placeholder": "Digite uma mensagem para enviar…", + "send": "Enviar", + "formatJson": "Formatar como JSON", + "invalidJson": "A mensagem não é um JSON válido", + "hint": "Cmd/Ctrl+Enter para enviar" + }, + "ping": { + "label": "Auto-ping", + "seconds": "seg", + "intervalTitle": "Intervalo de ping em segundos", + "payloadPlaceholder": "payload do ping" + } + }, + "WhoisLookup": { + "title": "Consulta Whois", + "subtitle": "Consulte detalhes de registro de qualquer domínio ou endereço IP via RDAP", + "placeholder": "example.com ou 8.8.8.8", + "lookupBtn": "Consultar", + "looking": "Consultando…", + "refreshBtn": "Atualizar", + "tryLabel": "Experimente:", + "copy": "Copiar", + "copied": "Copiado", + "notAvailable": "Não disponível", + "summary": { + "registrar": "Registrador", + "network": "Rede" + }, + "dates": { + "created": "Criado", + "updated": "Atualizado", + "expires": "Expira" + }, + "sections": { + "status": "Status", + "nameservers": "Servidores de nomes", + "contacts": "Contatos", + "rawRdap": "RDAP bruto" + }, + "contacts": { + "roles": "Funções", + "name": "Nome", + "organization": "Organização" + }, + "errors": { + "invalidTarget": "Digite um nome de domínio ou endereço IP válido", + "lookupFailed": "Falha na consulta (status {status})" + } } } diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index 40eccd82..52daa566 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Calculadora Chmod", + "codeScreenshot": "Captura de código", + "cssGenerators": "Geradores CSS", + "exifViewer": "Visualizador EXIF", + "faviconGenerator": "Gerador de favicon", + "jsonDiff": "Diff JSON", + "jsonpathPlayground": "Playground JSONPath", + "keycodeInspector": "Inspetor de keycode", + "markdownTableGenerator": "Tabela Markdown", + "timezoneConverter": "Conversor de fusos horários", + "tokenCounter": "Contador de tokens", + "webhookTester": "Testador de webhooks", + "websocketTester": "Testador de WebSocket", + "whoisLookup": "Pesquisa Whois" }, "Help": { "title": "Ajuda e documentação", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Gerador e verificador Bcrypt", + "description": "Gere hashes de palavras-passe com bcrypt e verifique hashes existentes." + }, + "chmodCalculator": { + "title": "Calculadora Chmod", + "description": "Converta permissões Unix entre octal, simbólico e caixas de seleção." + }, + "codeScreenshot": { + "title": "Captura de código", + "description": "Transforme código-fonte em imagens bonitas e fáceis de partilhar." + }, + "cssGenerators": { + "title": "Geradores CSS", + "description": "Geradores de box-shadow, border-radius e clamp() fluido." + }, + "exifViewer": { + "title": "Visualizador e removedor EXIF", + "description": "Inspecione e remova os metadados EXIF das fotos localmente." + }, + "faviconGenerator": { + "title": "Gerador de favicon", + "description": "Gere favicons, ícones de app e fragmentos de manifest." + }, + "jsonDiff": { + "title": "Diff JSON", + "description": "Comparação semântica e sensível à estrutura de dois documentos JSON." + }, + "jsonpathPlayground": { + "title": "Playground JSONPath", + "description": "Consulte JSON com JSONPath ou JMESPath e veja os resultados em tempo real." + }, + "keycodeInspector": { + "title": "Inspetor de keycode", + "description": "Prima uma tecla para ver os seus códigos de evento JavaScript e modificadores." + }, + "markdownTableGenerator": { + "title": "Gerador de tabelas Markdown", + "description": "Crie tabelas Markdown e HTML num editor de grelha semelhante a uma folha de cálculo." + }, + "timezoneConverter": { + "title": "Conversor de fusos horários", + "description": "Converta uma hora entre vários fusos com indicações de mudança de dia e horário de trabalho." + }, + "tokenCounter": { + "title": "Contador de tokens de LLM", + "description": "Conte tokens de LLM, visualize chunks e estime o custo da API." + }, + "webhookTester": { + "title": "Testador de webhooks", + "description": "Capture e inspecione pedidos de webhook recebidos em tempo real." + }, + "websocketTester": { + "title": "Testador de WebSocket", + "description": "Ligue-se a endpoints WebSocket e troque mensagens em tempo real." + }, + "whoisLookup": { + "title": "Pesquisa Whois", + "description": "Consulte os dados de registo de um domínio ou IP através de RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Gerador e verificador Bcrypt", + "subtitle": "Gere hashes de palavras-passe com bcrypt e verifique hashes — tudo permanece no seu browser", + "tabs": { + "hash": "Hash", + "verify": "Verificar" + }, + "showPassword": "Mostrar palavra-passe", + "hidePassword": "Ocultar palavra-passe", + "copy": "Copiar", + "copied": "Copiado", + "hash": { + "passwordLabel": "Palavra-passe", + "passwordPlaceholder": "Introduza uma palavra-passe para gerar o hash…", + "costLabel": "Fator de custo", + "costHint": "Um custo mais alto duplica o trabalho por passo. 10-12 é uma escolha comum em produção.", + "slowWarning": "Fatores de custo de 13 ou superiores podem demorar vários segundos a calcular.", + "generate": "Gerar hash", + "generating": "A gerar hash…", + "resultLabel": "Hash bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Anatomia do hash", + "algorithm": "algoritmo", + "cost": "custo", + "salt": "salt (22 caracteres)", + "hash": "hash (31 caracteres)" + } + }, + "verify": { + "passwordLabel": "Palavra-passe", + "passwordPlaceholder": "Introduza a palavra-passe a verificar…", + "hashLabel": "Hash bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Introduza uma palavra-passe e um hash bcrypt para os comparar.", + "invalidHash": "Isto não parece um hash bcrypt válido.", + "checking": "A comparar…", + "match": "Corresponde — a palavra-passe produz este hash.", + "noMatch": "Não corresponde — a palavra-passe não produz este hash." + } + }, + "ChmodCalculator": { + "title": "Calculadora Chmod", + "subtitle": "Converta permissões de ficheiros Unix entre caixas de seleção, octal e notação simbólica", + "grid": { + "title": "Permissões", + "read": "Leitura", + "write": "Escrita", + "execute": "Execução", + "owner": "Proprietário", + "group": "Grupo", + "others": "Outros" + }, + "special": { + "title": "Bits especiais", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Octal", + "placeholder": "755 ou 4755", + "invalid": "Introduza 3 ou 4 dígitos octais (0-7)." + }, + "symbolic": { + "label": "Simbólico", + "placeholder": "rwxr-xr-x", + "invalid": "Introduza 9 caracteres como rwxr-xr-x (s/S, t/T permitidos)." + }, + "output": { + "title": "Comando", + "copy": "Copiar", + "copied": "Copiado" + }, + "explain": { + "line": "{cls} pode {perms}.", + "none": "{cls} não tem permissões.", + "setuid": "Setuid: o ficheiro é executado com os privilégios do proprietário.", + "setgid": "Setgid: o ficheiro é executado com os privilégios do seu grupo (os ficheiros novos numa diretoria herdam o seu grupo).", + "sticky": "Sticky: apenas o proprietário de um ficheiro o pode eliminar ou renomear dentro desta diretoria." + }, + "presets": { + "title": "Predefinições comuns", + "octal": "Octal", + "permissions": "Permissões", + "description": "Uso típico", + "p644": "Ficheiros normais: o proprietário escreve, todos leem.", + "p755": "Executáveis e diretorias: todos podem ler e entrar.", + "p600": "Ficheiros privados: apenas o proprietário lê e escreve.", + "p700": "Executáveis e diretorias privados: apenas o proprietário.", + "p664": "Ficheiros com escrita para o grupo.", + "p775": "Diretorias e executáveis com escrita para o grupo.", + "p400": "Apenas leitura para o proprietário (ex.: chaves privadas SSH)." + } + }, + "CodeScreenshot": { + "title": "Captura de código", + "subtitle": "Transforme código em imagens bonitas e fáceis de partilhar", + "languageLabel": "Linguagem", + "autoDetect": "Deteção automática", + "backgroundLabel": "Fundo", + "paddingLabel": "Espaçamento", + "filenameLabel": "Nome do ficheiro", + "filenamePlaceholder": "example.ts", + "windowLabel": "Pontos da janela", + "themeLabel": "Tema da janela", + "themeDark": "Escuro", + "themeLight": "Claro", + "scaleLabel": "Escala de exportação", + "copyImage": "Copiar imagem", + "copied": "Copiado", + "exportPng": "Exportar PNG", + "inputLabel": "Código", + "detected": "Detetado: {language}", + "clear": "Limpar", + "placeholder": "Cole o seu código aqui…", + "previewLabel": "Pré-visualização", + "emptyPreview": "A pré-visualização do seu código aparecerá aqui", + "exportFailed": "Falha ao exportar a imagem", + "copyUnsupported": "Copiar imagens não é suportado neste browser", + "copyFailed": "Falha ao copiar a imagem", + "backgrounds": { + "ocean": "Oceano", + "sunset": "Pôr do sol", + "candy": "Rebuçado", + "forest": "Floresta", + "midnight": "Meia-noite", + "gold": "Ouro", + "aurora": "Aurora", + "slate": "Ardósia", + "transparent": "Transparente" + } + }, + "CssGenerators": { + "title": "Geradores CSS", + "subtitle": "Box-shadow, border-radius e clamp() fluido — copie o CSS.", + "preview": "Pré-visualização", + "output": "CSS", + "copy": "Copiar", + "copied": "Copiado", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() fluido" + }, + "boxShadow": { + "presets": "Predefinições", + "layer": "Camada", + "addLayer": "Adicionar camada", + "removeLayer": "Remover camada", + "x": "X", + "y": "Y", + "blur": "Desfoque", + "spread": "Expansão", + "color": "Cor", + "alpha": "Alfa", + "inset": "Interior", + "presetNames": { + "subtleCard": "Cartão subtil", + "elevated": "Elevado", + "hardOffset": "Deslocamento nítido", + "neonGlow": "Brilho néon", + "inner": "Interior", + "layeredSmooth": "Camadas suaves" + } + }, + "borderRadius": { + "modeLabel": "Modo", + "modes": { + "simple": "Por canto", + "blob": "Blob orgânico" + }, + "unit": "Unidade", + "linkCorners": "Ligar cantos", + "randomize": "Aleatorizar", + "horizontal": "Raios horizontais", + "vertical": "Raios verticais", + "corners": { + "tl": "Superior esquerdo", + "tr": "Superior direito", + "br": "Inferior direito", + "bl": "Inferior esquerdo" + } + }, + "clamp": { + "minFont": "Fonte mín. (px)", + "maxFont": "Fonte máx. (px)", + "minViewport": "Viewport mín. (px)", + "maxViewport": "Viewport máx. (px)", + "rootFont": "Fonte raiz (px)", + "viewportWidth": "Viewport simulado", + "computedSize": "Tamanho calculado", + "slope": "Declive", + "intercept": "Interceção", + "previewText": "Um pequeno jabuti xereta", + "invalid": "Introduza limites de viewport válidos e diferentes." + } + }, + "ExifViewer": { + "title": "Visualizador e removedor EXIF", + "subtitle": "Inspecione os metadados das fotos e remova-os no seu browser", + "dropTitle": "Largue uma imagem aqui", + "dropHint": "ou clique para procurar — a sua imagem nunca sai do seu dispositivo", + "browse": "Escolher imagem", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "A ler metadados…", + "parseFailed": "Falha ao ler esta imagem", + "clear": "Remover imagem", + "gpsWarning": "Contém localização GPS", + "openMap": "Ver no OpenStreetMap", + "noMetadata": "Não foram encontrados metadados incorporados nesta imagem.", + "facts": { + "name": "Nome", + "type": "Tipo", + "dimensions": "Dimensões", + "size": "Tamanho" + }, + "groups": { + "camera": "Câmara", + "exposure": "Exposição", + "timestamps": "Datas/horas", + "gps": "Localização GPS", + "software": "Software e outros" + }, + "gpsCard": { + "latitude": "Latitude", + "longitude": "Longitude", + "altitude": "Altitude" + }, + "raw": { + "title": "Todos os metadados", + "copyJson": "Copiar JSON", + "copied": "Copiado" + }, + "remove": { + "title": "Remover metadados", + "jpegDescription": "Remova sem perdas EXIF, XMP, IPTC e comentários sem recomprimir a imagem.", + "reencodeDescription": "Recodifique a imagem para remover os metadados incorporados. Isto recomprime os pixels.", + "keepIcc": "Manter o perfil de cor (ICC)", + "stripButton": "Remover metadados", + "reencodeButton": "Recodificar e limpar", + "unsupported": "A remoção de metadados não é suportada para este tipo de ficheiro.", + "failed": "Falha ao remover os metadados", + "before": "Antes", + "after": "Depois", + "download": "Descarregar imagem limpa" + } + }, + "FaviconGenerator": { + "title": "Gerador de favicon", + "subtitle": "Crie favicons e ícones de app a partir de uma única imagem", + "dropTitle": "Largue uma imagem aqui", + "dropHint": "PNG, JPEG ou SVG — clique para procurar", + "browse": "Escolher imagem", + "clear": "Remover imagem", + "backgroundLabel": "Fundo", + "transparent": "Transparente", + "solidColor": "Sólido", + "cornerLabel": "Forma dos cantos", + "cornerNone": "Quadrado", + "cornerRounded": "Arredondado", + "cornerCircle": "Círculo", + "maskPaddingLabel": "Espaçamento maskable", + "maskable": "maskable", + "previewLabel": "Pré-visualização", + "copy": "Copiar", + "headSnippet": "Fragmento para o head HTML", + "downloadIco": "Descarregar favicon.ico", + "downloadZip": "Descarregar tudo (.zip)" + }, + "JsonDiff": { + "title": "Diff JSON", + "subtitle": "Comparação semântica e sensível à estrutura de dois documentos JSON — ordem das chaves e formatação ignoradas", + "options": { + "ignoreArrayOrder": "Ignorar a ordem dos arrays" + }, + "loadBothSamples": "Carregar exemplos", + "sample": "Carregar exemplo", + "format": "Formatar", + "clear": "Limpar", + "formatError": "Não é possível formatar: a entrada não é um JSON válido", + "placeholder": "Cole ou escreva JSON aqui…", + "panels": { + "left": "Original (esquerda)", + "right": "Modificado (direita)", + "results": "Diferenças" + }, + "summary": { + "added": "{count} adicionados", + "removed": "{count} removidos", + "changed": "{count} alterados" + }, + "types": { + "added": "Adicionado", + "removed": "Removido", + "changed": "Alterado" + }, + "copyChanges": "Copiar como JSON", + "copied": "Copiado", + "empty": "Cole JSON em ambos os painéis para ver as suas diferenças.", + "identical": "Os dois documentos são semanticamente idênticos." + }, + "JsonpathPlayground": { + "title": "Playground JSONPath", + "subtitle": "Consulte documentos JSON com JSONPath ou JMESPath e veja os resultados em tempo real", + "engineLabel": "Motor", + "queryLabel": "Consulta", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Referência rápida", + "cheats": { + "jpAllTitles": "Todos os títulos de livros", + "jpAllAuthors": "Todos os autores, em qualquer parte do documento", + "jpFirstBook": "Primeiro livro", + "jpLastBook": "Último livro", + "jpSlice": "Os dois primeiros livros (slice)", + "jpFilterPrice": "Livros abaixo de 40", + "jpFilterInStock": "Títulos dos livros em stock", + "jpRecursive": "Todos os valores, recursivamente", + "jmAllTitles": "Todos os títulos de livros", + "jmFirstAuthor": "Autor do primeiro livro", + "jmLastBook": "Último livro", + "jmSlice": "Os dois primeiros livros (slice)", + "jmFilterPrice": "Títulos dos livros abaixo de 40", + "jmCount": "Contar livros em stock", + "jmProjection": "Reformular cada livro num novo objeto", + "jmSort": "Títulos ordenados por preço" + }, + "panels": { + "input": "Entrada JSON", + "results": "Resultados" + }, + "sample": "Carregar exemplo", + "format": "Formatar", + "clear": "Limpar", + "formatError": "Não é possível formatar: a entrada não é um JSON válido", + "placeholder": "Cole ou escreva JSON aqui…", + "outputPlaceholder": "As correspondências aparecerão aqui…", + "matchCount": "{count, plural, one {# correspondência} other {# correspondências}}", + "copy": "Copiar", + "copied": "Copiado" + }, + "KeycodeInspector": { + "title": "Inspetor de keycode", + "subtitle": "Prima qualquer tecla para inspecionar o seu evento de teclado JavaScript", + "capture": { + "prompt": "Prima qualquer tecla…", + "ready": "A escutar — prima qualquer tecla no seu teclado", + "clickToFocus": "Clique aqui para focar e depois prima qualquer tecla" + }, + "hero": { + "spaceKey": "(Espaço)", + "keyLabel": "event.key", + "repeat": "repetição" + }, + "copyJson": "Copiar JSON", + "copiedJson": "JSON do evento copiado para a área de transferência", + "deprecated": "obsoleto", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Padrão", + "left": "Esquerda", + "right": "Direita", + "numpad": "Teclado numérico", + "unknown": "Desconhecido" + }, + "history": { + "title": "Histórico", + "clear": "Limpar histórico", + "empty": "As teclas premidas aparecerão aqui." + } + }, + "MarkdownTableGenerator": { + "title": "Gerador de tabelas Markdown", + "subtitle": "Edite uma grelha, cole de uma folha de cálculo e copie uma tabela Markdown ou HTML bem alinhada", + "addRow": "Adicionar linha", + "addColumn": "Adicionar coluna", + "importLabel": "Importar", + "importPlaceholder": "Cole TSV (do Excel / Google Sheets) ou CSV aqui…", + "importButton": "Importar para a grelha", + "pasteClipboard": "Colar da área de transferência", + "importEmpty": "Nada para importar — cole primeiro algumas linhas", + "clipboardError": "Não foi possível ler a área de transferência", + "clear": "Limpar", + "panels": { + "editor": "Editor de tabelas", + "output": "Saída" + }, + "align": { + "left": "Alinhar à esquerda", + "center": "Centrar", + "right": "Alinhar à direita" + }, + "headerPlaceholder": "Cabeçalho", + "cellPlaceholder": "Célula", + "removeRow": "Remover linha", + "removeColumn": "Remover coluna", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Copiar", + "copied": "Copiado" + }, + "TimezoneConverter": { + "title": "Conversor de fusos horários", + "subtitle": "Converta um momento por todo o mundo para planear reuniões.", + "dateTimeLabel": "Data e hora", + "now": "Agora", + "sourceZoneLabel": "Fuso horário de origem", + "selectZone": "Selecionar fuso", + "searchZone": "Procurar fusos horários...", + "noZone": "Nenhum fuso horário encontrado.", + "scrubLabel": "Deslocar a hora", + "reset": "Repor", + "targetsLabel": "Fusos de destino", + "addZone": "Adicionar fuso", + "remove": "Remover", + "localBadge": "Local", + "invalidDate": "Introduza uma data e hora válidas.", + "bands": { + "work": "Horário de trabalho (09:00–18:00)", + "edge": "Horas limite (cedo / noite)", + "night": "Noite" + } + }, + "TokenCounter": { + "title": "Contador de tokens de LLM", + "subtitle": "Conte tokens, visualize chunks e estime o custo da API.", + "inputLabel": "Texto", + "sample": "Carregar exemplo", + "clear": "Limpar", + "placeholder": "Cole texto, código ou JSON para contar tokens...", + "encoding": "Codificação", + "loading": "A carregar o tokenizador...", + "encodings": { + "o200k": "o200k_base (GPT-4o / série o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Caracteres", + "words": "Palavras", + "ratio": "Tokens/palavra", + "ratioSub": "aprox." + }, + "claudeRow": "Claude (aproximado)", + "estimate": "estimativa", + "viz": { + "title": "Visualização de tokens", + "toggle": "Mostrar", + "hint": "Ative para colorir cada token como um chunk.", + "empty": "Introduza texto para visualizar os tokens.", + "truncated": "A mostrar os primeiros {limit} de {total} tokens." + }, + "cost": { + "title": "Calculadora de custos", + "disclaimer": "valores predefinidos editáveis — verifique os preços atuais", + "model": "Modelo", + "expectedOutput": "Tokens de saída esperados", + "customInput": "Entrada $/MTok", + "customOutput": "Saída $/MTok", + "inputCost": "Custo de entrada", + "outputCost": "Custo de saída", + "total": "Total", + "per1000": "Por 1000 chamadas" + } + }, + "WebhookTester": { + "title": "Testador de webhooks", + "subtitle": "Crie um URL temporário e inspecione os pedidos de webhook recebidos em tempo real.", + "intro": { + "title": "Crie um URL de webhook para começar a capturar pedidos", + "hint": "Vai obter um URL indecifrável que regista cada pedido recebido. Expira ao fim de 24 horas." + }, + "createBtn": "Criar URL de webhook", + "creating": "A criar…", + "urlLabel": "O seu URL de webhook", + "expiresIn": "Expira em {countdown}", + "copyUrlBtn": "Copiar URL", + "copied": "Copiado", + "clearBtn": "Limpar", + "newBinBtn": "Novo URL", + "requestsTitle": "Pedidos", + "selectPrompt": "Selecione um pedido para inspecionar os seus detalhes.", + "expiredNotice": "Este URL de webhook expirou. Crie um novo para continuar a testar.", + "base64Badge": "base64", + "truncatedBadge": "truncado", + "empty": { + "title": "À espera de pedidos…", + "hint": "Envie um pedido para o seu URL e ele aparecerá aqui instantaneamente. Experimente isto:" + }, + "detail": { + "receivedAt": "Recebido", + "source": "IP de origem", + "contentType": "Content-Type", + "query": "Parâmetros de consulta", + "headers": "Cabeçalhos", + "body": "Corpo", + "copyBody": "Copiar corpo", + "emptyBody": "Sem corpo" + }, + "errors": { + "createFailed": "Falha ao criar o URL de webhook. Tente novamente.", + "clearFailed": "Falha ao limpar os pedidos. Tente novamente." + } + }, + "WebsocketTester": { + "title": "Testador de WebSocket", + "subtitle": "Ligue-se a um endpoint WebSocket e envie, receba e inspecione mensagens em tempo real", + "urlLabel": "URL do endpoint", + "protocolsLabel": "Subprotocolos", + "protocolsPlaceholder": "opcional, separados por vírgulas", + "connect": "Ligar", + "disconnect": "Desligar", + "status": { + "disconnected": "Desligado", + "connecting": "A ligar", + "connected": "Ligado", + "error": "Erro" + }, + "messages": { + "connecting": "A ligar a {url}…", + "connected": "Ligação estabelecida", + "connectedWithProtocol": "Ligação estabelecida (protocolo: {protocol})", + "invalidUrl": "URL inválido — deve começar por ws:// ou wss://", + "connectFailed": "Falha ao abrir a ligação", + "socketError": "Ocorreu um erro de ligação", + "notConnected": "Não ligado — ligue-se primeiro", + "binaryMessage": "Recebida uma mensagem binária" + }, + "log": { + "title": "Registo de mensagens", + "empty": "Ainda não há mensagens. Ligue-se e comece a enviar.", + "copyAll": "Copiar tudo", + "download": "Descarregar como .txt", + "clear": "Limpar registo", + "prettyPrint": "Formatar JSON", + "showRaw": "Mostrar texto simples" + }, + "composer": { + "placeholder": "Escreva uma mensagem para enviar…", + "send": "Enviar", + "formatJson": "Formatar como JSON", + "invalidJson": "A mensagem não é um JSON válido", + "hint": "Cmd/Ctrl+Enter para enviar" + }, + "ping": { + "label": "Auto-ping", + "seconds": "s", + "intervalTitle": "Intervalo de ping em segundos", + "payloadPlaceholder": "payload do ping" + } + }, + "WhoisLookup": { + "title": "Pesquisa Whois", + "subtitle": "Consulte os dados de registo de qualquer domínio ou endereço IP através de RDAP", + "placeholder": "example.com ou 8.8.8.8", + "lookupBtn": "Pesquisar", + "looking": "A pesquisar…", + "refreshBtn": "Atualizar", + "tryLabel": "Experimente:", + "copy": "Copiar", + "copied": "Copiado", + "notAvailable": "Não disponível", + "summary": { + "registrar": "Registrar", + "network": "Rede" + }, + "dates": { + "created": "Criado", + "updated": "Atualizado", + "expires": "Expira" + }, + "sections": { + "status": "Estado", + "nameservers": "Servidores de nomes", + "contacts": "Contactos", + "rawRdap": "RDAP em bruto" + }, + "contacts": { + "roles": "Funções", + "name": "Nome", + "organization": "Organização" + }, + "errors": { + "invalidTarget": "Introduza um nome de domínio ou endereço IP válido", + "lookupFailed": "Pesquisa falhou (estado {status})" + } } } diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 9d4ac723..fdc7c6ef 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Калькулятор Chmod", + "codeScreenshot": "Скриншот кода", + "cssGenerators": "Генераторы CSS", + "exifViewer": "Просмотр EXIF", + "faviconGenerator": "Генератор favicon", + "jsonDiff": "Сравнение JSON", + "jsonpathPlayground": "Песочница JSONPath", + "keycodeInspector": "Инспектор клавиш", + "markdownTableGenerator": "Таблица Markdown", + "timezoneConverter": "Конвертер часовых поясов", + "tokenCounter": "Счётчик токенов", + "webhookTester": "Тестер вебхуков", + "websocketTester": "Тестер WebSocket", + "whoisLookup": "Whois-запрос" }, "Help": { "title": "Справка и документация", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Генератор и проверка Bcrypt", + "description": "Хешируйте пароли с помощью bcrypt и проверяйте существующие хеши." + }, + "chmodCalculator": { + "title": "Калькулятор Chmod", + "description": "Преобразуйте права Unix между восьмеричной, символьной формой и флажками." + }, + "codeScreenshot": { + "title": "Скриншот кода", + "description": "Превращайте исходный код в красивые изображения для публикации." + }, + "cssGenerators": { + "title": "Генераторы CSS", + "description": "Генераторы box-shadow, border-radius и адаптивного clamp()." + }, + "exifViewer": { + "title": "Просмотр и удаление EXIF", + "description": "Просматривайте и удаляйте метаданные EXIF из фото локально." + }, + "faviconGenerator": { + "title": "Генератор favicon", + "description": "Создавайте favicon, иконки приложений и фрагменты манифеста." + }, + "jsonDiff": { + "title": "Сравнение JSON", + "description": "Семантическое, учитывающее структуру сравнение двух документов JSON." + }, + "jsonpathPlayground": { + "title": "Песочница JSONPath", + "description": "Запрашивайте JSON с помощью JSONPath или JMESPath и смотрите результаты вживую." + }, + "keycodeInspector": { + "title": "Инспектор клавиш", + "description": "Нажмите клавишу, чтобы увидеть её коды события JavaScript и модификаторы." + }, + "markdownTableGenerator": { + "title": "Генератор таблиц Markdown", + "description": "Создавайте таблицы Markdown и HTML в табличном редакторе." + }, + "timezoneConverter": { + "title": "Конвертер часовых поясов", + "description": "Преобразуйте время между поясами с подсказками о смене дня и рабочих часах." + }, + "tokenCounter": { + "title": "Счётчик токенов LLM", + "description": "Считайте токены LLM, визуализируйте чанки и оценивайте стоимость API." + }, + "webhookTester": { + "title": "Тестер вебхуков", + "description": "Захватывайте и изучайте входящие запросы вебхуков в реальном времени." + }, + "websocketTester": { + "title": "Тестер WebSocket", + "description": "Подключайтесь к эндпоинтам WebSocket и обменивайтесь сообщениями в реальном времени." + }, + "whoisLookup": { + "title": "Whois-запрос", + "description": "Узнавайте данные регистрации доменов и IP через RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Генератор и проверка Bcrypt", + "subtitle": "Хешируйте пароли с помощью bcrypt и проверяйте хеши — всё остаётся в вашем браузере", + "tabs": { + "hash": "Хеш", + "verify": "Проверка" + }, + "showPassword": "Показать пароль", + "hidePassword": "Скрыть пароль", + "copy": "Копировать", + "copied": "Скопировано", + "hash": { + "passwordLabel": "Пароль", + "passwordPlaceholder": "Введите пароль для хеширования…", + "costLabel": "Фактор стоимости", + "costHint": "Более высокий фактор удваивает работу на каждом шаге. 10-12 — распространённый выбор для продакшена.", + "slowWarning": "Факторы стоимости 13 и выше могут вычисляться несколько секунд.", + "generate": "Создать хеш", + "generating": "Хеширование…", + "resultLabel": "Хеш bcrypt", + "timeTaken": "{ms} мс", + "anatomy": { + "title": "Строение хеша", + "algorithm": "алгоритм", + "cost": "стоимость", + "salt": "соль (22 символа)", + "hash": "хеш (31 символ)" + } + }, + "verify": { + "passwordLabel": "Пароль", + "passwordPlaceholder": "Введите пароль для проверки…", + "hashLabel": "Хеш bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Введите пароль и хеш bcrypt, чтобы сравнить их.", + "invalidHash": "Это не похоже на корректный хеш bcrypt.", + "checking": "Сравнение…", + "match": "Совпадает — пароль даёт этот хеш.", + "noMatch": "Не совпадает — пароль не даёт этот хеш." + } + }, + "ChmodCalculator": { + "title": "Калькулятор Chmod", + "subtitle": "Преобразуйте права файлов Unix между флажками, восьмеричной и символьной формой", + "grid": { + "title": "Права", + "read": "Чтение", + "write": "Запись", + "execute": "Выполнение", + "owner": "Владелец", + "group": "Группа", + "others": "Остальные" + }, + "special": { + "title": "Специальные биты", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Восьмеричная", + "placeholder": "755 или 4755", + "invalid": "Введите 3 или 4 восьмеричные цифры (0-7)." + }, + "symbolic": { + "label": "Символьная", + "placeholder": "rwxr-xr-x", + "invalid": "Введите 9 символов вида rwxr-xr-x (допустимы s/S, t/T)." + }, + "output": { + "title": "Команда", + "copy": "Копировать", + "copied": "Скопировано" + }, + "explain": { + "line": "{cls} может {perms}.", + "none": "{cls} не имеет прав.", + "setuid": "Setuid: файл выполняется с привилегиями своего владельца.", + "setgid": "Setgid: файл выполняется с привилегиями своей группы (новые файлы в каталоге наследуют его группу).", + "sticky": "Sticky: только владелец файла может удалить или переименовать его в этом каталоге." + }, + "presets": { + "title": "Частые пресеты", + "octal": "Восьмеричная", + "permissions": "Права", + "description": "Типичное применение", + "p644": "Обычные файлы: владелец пишет, все читают.", + "p755": "Исполняемые файлы и каталоги: все могут читать и входить.", + "p600": "Приватные файлы: только владелец читает и пишет.", + "p700": "Приватные исполняемые файлы и каталоги: только владелец.", + "p664": "Файлы, доступные группе для записи.", + "p775": "Каталоги и исполняемые файлы, доступные группе для записи.", + "p400": "Только чтение для владельца (например, закрытые ключи SSH)." + } + }, + "CodeScreenshot": { + "title": "Скриншот кода", + "subtitle": "Превращайте код в красивые изображения для публикации", + "languageLabel": "Язык", + "autoDetect": "Автоопределение", + "backgroundLabel": "Фон", + "paddingLabel": "Отступ", + "filenameLabel": "Имя файла", + "filenamePlaceholder": "example.ts", + "windowLabel": "Точки окна", + "themeLabel": "Тема окна", + "themeDark": "Тёмная", + "themeLight": "Светлая", + "scaleLabel": "Масштаб экспорта", + "copyImage": "Копировать изображение", + "copied": "Скопировано", + "exportPng": "Экспорт PNG", + "inputLabel": "Код", + "detected": "Определено: {language}", + "clear": "Очистить", + "placeholder": "Вставьте ваш код сюда…", + "previewLabel": "Предпросмотр", + "emptyPreview": "Здесь появится предпросмотр вашего кода", + "exportFailed": "Не удалось экспортировать изображение", + "copyUnsupported": "Копирование изображений не поддерживается в этом браузере", + "copyFailed": "Не удалось скопировать изображение", + "backgrounds": { + "ocean": "Океан", + "sunset": "Закат", + "candy": "Конфета", + "forest": "Лес", + "midnight": "Полночь", + "gold": "Золото", + "aurora": "Аврора", + "slate": "Сланец", + "transparent": "Прозрачный" + } + }, + "CssGenerators": { + "title": "Генераторы CSS", + "subtitle": "Box-shadow, border-radius и адаптивный clamp() — копируйте CSS.", + "preview": "Предпросмотр", + "output": "CSS", + "copy": "Копировать", + "copied": "Скопировано", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Адаптивный clamp()" + }, + "boxShadow": { + "presets": "Пресеты", + "layer": "Слой", + "addLayer": "Добавить слой", + "removeLayer": "Удалить слой", + "x": "X", + "y": "Y", + "blur": "Размытие", + "spread": "Растяжение", + "color": "Цвет", + "alpha": "Альфа", + "inset": "Внутренняя", + "presetNames": { + "subtleCard": "Лёгкая карточка", + "elevated": "Приподнятая", + "hardOffset": "Жёсткое смещение", + "neonGlow": "Неоновое свечение", + "inner": "Внутренняя", + "layeredSmooth": "Мягкие слои" + } + }, + "borderRadius": { + "modeLabel": "Режим", + "modes": { + "simple": "По углам", + "blob": "Органичная капля" + }, + "unit": "Единица", + "linkCorners": "Связать углы", + "randomize": "Случайно", + "horizontal": "Горизонтальные радиусы", + "vertical": "Вертикальные радиусы", + "corners": { + "tl": "Верхний левый", + "tr": "Верхний правый", + "br": "Нижний правый", + "bl": "Нижний левый" + } + }, + "clamp": { + "minFont": "Мин. шрифт (px)", + "maxFont": "Макс. шрифт (px)", + "minViewport": "Мин. viewport (px)", + "maxViewport": "Макс. viewport (px)", + "rootFont": "Корневой шрифт (px)", + "viewportWidth": "Смоделированный viewport", + "computedSize": "Вычисленный размер", + "slope": "Наклон", + "intercept": "Смещение", + "previewText": "Быстрая рыжая лиса", + "invalid": "Введите корректные и различающиеся границы viewport." + } + }, + "ExifViewer": { + "title": "Просмотр и удаление EXIF", + "subtitle": "Изучайте метаданные фото и удаляйте их в вашем браузере", + "dropTitle": "Перетащите изображение сюда", + "dropHint": "или нажмите для выбора — ваше изображение никогда не покидает устройство", + "browse": "Выбрать изображение", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Чтение метаданных…", + "parseFailed": "Не удалось прочитать это изображение", + "clear": "Удалить изображение", + "gpsWarning": "Содержит GPS-координаты", + "openMap": "Показать на OpenStreetMap", + "noMetadata": "В этом изображении не найдено встроенных метаданных.", + "facts": { + "name": "Имя", + "type": "Тип", + "dimensions": "Размеры", + "size": "Размер" + }, + "groups": { + "camera": "Камера", + "exposure": "Экспозиция", + "timestamps": "Метки времени", + "gps": "GPS-координаты", + "software": "ПО и прочее" + }, + "gpsCard": { + "latitude": "Широта", + "longitude": "Долгота", + "altitude": "Высота" + }, + "raw": { + "title": "Все метаданные", + "copyJson": "Копировать JSON", + "copied": "Скопировано" + }, + "remove": { + "title": "Удалить метаданные", + "jpegDescription": "Без потерь удалите EXIF, XMP, IPTC и комментарии без повторного сжатия изображения.", + "reencodeDescription": "Перекодируйте изображение, чтобы удалить встроенные метаданные. При этом пиксели пересжимаются.", + "keepIcc": "Сохранить цветовой профиль (ICC)", + "stripButton": "Удалить метаданные", + "reencodeButton": "Перекодировать и удалить", + "unsupported": "Удаление метаданных не поддерживается для этого типа файла.", + "failed": "Не удалось удалить метаданные", + "before": "До", + "after": "После", + "download": "Скачать очищенное изображение" + } + }, + "FaviconGenerator": { + "title": "Генератор favicon", + "subtitle": "Создавайте favicon и иконки приложений из одного изображения", + "dropTitle": "Перетащите изображение сюда", + "dropHint": "PNG, JPEG или SVG — нажмите для выбора", + "browse": "Выбрать изображение", + "clear": "Удалить изображение", + "backgroundLabel": "Фон", + "transparent": "Прозрачный", + "solidColor": "Сплошной", + "cornerLabel": "Форма углов", + "cornerNone": "Квадрат", + "cornerRounded": "Скруглённый", + "cornerCircle": "Круг", + "maskPaddingLabel": "Отступ maskable", + "maskable": "maskable", + "previewLabel": "Предпросмотр", + "copy": "Копировать", + "headSnippet": "Фрагмент для head HTML", + "downloadIco": "Скачать favicon.ico", + "downloadZip": "Скачать всё (.zip)" + }, + "JsonDiff": { + "title": "Сравнение JSON", + "subtitle": "Семантическое, учитывающее структуру сравнение двух документов JSON — порядок ключей и форматирование игнорируются", + "options": { + "ignoreArrayOrder": "Игнорировать порядок массивов" + }, + "loadBothSamples": "Загрузить примеры", + "sample": "Пример", + "format": "Формат", + "clear": "Очистить", + "formatError": "Не удаётся отформатировать: ввод не является корректным JSON", + "placeholder": "Вставьте или введите JSON здесь…", + "panels": { + "left": "Оригинал (слева)", + "right": "Изменённый (справа)", + "results": "Различия" + }, + "summary": { + "added": "{count} добавлено", + "removed": "{count} удалено", + "changed": "{count} изменено" + }, + "types": { + "added": "Добавлено", + "removed": "Удалено", + "changed": "Изменено" + }, + "copyChanges": "Копировать как JSON", + "copied": "Скопировано", + "empty": "Вставьте JSON в обе панели, чтобы увидеть их различия.", + "identical": "Оба документа семантически идентичны." + }, + "JsonpathPlayground": { + "title": "Песочница JSONPath", + "subtitle": "Запрашивайте документы JSON с помощью JSONPath или JMESPath и смотрите результаты вживую", + "engineLabel": "Движок", + "queryLabel": "Запрос", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Шпаргалка", + "cheats": { + "jpAllTitles": "Все названия книг", + "jpAllAuthors": "Все авторы в любом месте документа", + "jpFirstBook": "Первая книга", + "jpLastBook": "Последняя книга", + "jpSlice": "Первые две книги (срез)", + "jpFilterPrice": "Книги дешевле 40", + "jpFilterInStock": "Названия книг в наличии", + "jpRecursive": "Все значения рекурсивно", + "jmAllTitles": "Все названия книг", + "jmFirstAuthor": "Автор первой книги", + "jmLastBook": "Последняя книга", + "jmSlice": "Первые две книги (срез)", + "jmFilterPrice": "Названия книг дешевле 40", + "jmCount": "Подсчёт книг в наличии", + "jmProjection": "Преобразовать каждую книгу в новый объект", + "jmSort": "Названия, отсортированные по цене" + }, + "panels": { + "input": "Ввод JSON", + "results": "Результаты" + }, + "sample": "Пример", + "format": "Формат", + "clear": "Очистить", + "formatError": "Не удаётся отформатировать: ввод не является корректным JSON", + "placeholder": "Вставьте или введите JSON здесь…", + "outputPlaceholder": "Совпадения появятся здесь…", + "matchCount": "{count, plural, one {# совпадение} few {# совпадения} many {# совпадений} other {# совпадения}}", + "copy": "Копировать", + "copied": "Скопировано" + }, + "KeycodeInspector": { + "title": "Инспектор клавиш", + "subtitle": "Нажмите любую клавишу, чтобы изучить её событие клавиатуры JavaScript", + "capture": { + "prompt": "Нажмите любую клавишу…", + "ready": "Слушаю — нажмите любую клавишу на клавиатуре", + "clickToFocus": "Нажмите здесь для фокуса, затем нажмите любую клавишу" + }, + "hero": { + "spaceKey": "(Пробел)", + "keyLabel": "event.key", + "repeat": "повтор" + }, + "copyJson": "Копировать JSON", + "copiedJson": "JSON события скопирован в буфер обмена", + "deprecated": "устарело", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Стандартная", + "left": "Слева", + "right": "Справа", + "numpad": "Цифровой блок", + "unknown": "Неизвестно" + }, + "history": { + "title": "История", + "clear": "Очистить историю", + "empty": "Нажатия клавиш появятся здесь." + } + }, + "MarkdownTableGenerator": { + "title": "Генератор таблиц Markdown", + "subtitle": "Редактируйте сетку, вставляйте из таблицы и копируйте красиво выровненную таблицу Markdown или HTML", + "addRow": "Добавить строку", + "addColumn": "Добавить столбец", + "importLabel": "Импорт", + "importPlaceholder": "Вставьте TSV (из Excel / Google Sheets) или CSV сюда…", + "importButton": "Импортировать в сетку", + "pasteClipboard": "Вставить из буфера обмена", + "importEmpty": "Нечего импортировать — сначала вставьте несколько строк", + "clipboardError": "Не удалось прочитать из буфера обмена", + "clear": "Очистить", + "panels": { + "editor": "Редактор таблицы", + "output": "Результат" + }, + "align": { + "left": "Выровнять по левому краю", + "center": "Выровнять по центру", + "right": "Выровнять по правому краю" + }, + "headerPlaceholder": "Заголовок", + "cellPlaceholder": "Ячейка", + "removeRow": "Удалить строку", + "removeColumn": "Удалить столбец", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Копировать", + "copied": "Скопировано" + }, + "TimezoneConverter": { + "title": "Конвертер часовых поясов", + "subtitle": "Преобразуйте один момент по всему миру для планирования встреч.", + "dateTimeLabel": "Дата и время", + "now": "Сейчас", + "sourceZoneLabel": "Исходный часовой пояс", + "selectZone": "Выберите пояс", + "searchZone": "Поиск часовых поясов...", + "noZone": "Часовой пояс не найден.", + "scrubLabel": "Сдвинуть время", + "reset": "Сбросить", + "targetsLabel": "Целевые пояса", + "addZone": "Добавить пояс", + "remove": "Удалить", + "localBadge": "Локальный", + "invalidDate": "Введите корректные дату и время.", + "bands": { + "work": "Рабочие часы (09:00–18:00)", + "edge": "Крайние часы (раннее утро / вечер)", + "night": "Ночь" + } + }, + "TokenCounter": { + "title": "Счётчик токенов LLM", + "subtitle": "Считайте токены, визуализируйте чанки и оценивайте стоимость API.", + "inputLabel": "Текст", + "sample": "Пример", + "clear": "Очистить", + "placeholder": "Вставьте текст, код или JSON для подсчёта токенов...", + "encoding": "Кодировка", + "loading": "Загрузка токенизатора...", + "encodings": { + "o200k": "o200k_base (GPT-4o / серия o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Токены", + "characters": "Символы", + "words": "Слова", + "ratio": "Токенов/слово", + "ratioSub": "прибл." + }, + "claudeRow": "Claude (приблизительно)", + "estimate": "оценка", + "viz": { + "title": "Визуализация токенов", + "toggle": "Показать", + "hint": "Включите, чтобы раскрасить каждый токен как чанк.", + "empty": "Введите текст для визуализации токенов.", + "truncated": "Показаны первые {limit} из {total} токенов." + }, + "cost": { + "title": "Калькулятор стоимости", + "disclaimer": "редактируемые значения по умолчанию — проверьте актуальные цены", + "model": "Модель", + "expectedOutput": "Ожидаемые токены вывода", + "customInput": "Ввод $/MTok", + "customOutput": "Вывод $/MTok", + "inputCost": "Стоимость ввода", + "outputCost": "Стоимость вывода", + "total": "Итого", + "per1000": "За 1000 вызовов" + } + }, + "WebhookTester": { + "title": "Тестер вебхуков", + "subtitle": "Создайте временный URL и изучайте входящие запросы вебхуков вживую.", + "intro": { + "title": "Создайте URL вебхука, чтобы начать захват запросов", + "hint": "Вы получите непредсказуемый URL, который записывает каждый входящий запрос. Он истекает через 24 часа." + }, + "createBtn": "Создать URL вебхука", + "creating": "Создание…", + "urlLabel": "Ваш URL вебхука", + "expiresIn": "Истекает через {countdown}", + "copyUrlBtn": "Копировать URL", + "copied": "Скопировано", + "clearBtn": "Очистить", + "newBinBtn": "Новый URL", + "requestsTitle": "Запросы", + "selectPrompt": "Выберите запрос, чтобы изучить его детали.", + "expiredNotice": "Этот URL вебхука истёк. Создайте новый, чтобы продолжить тестирование.", + "base64Badge": "base64", + "truncatedBadge": "обрезано", + "empty": { + "title": "Ожидание запросов…", + "hint": "Отправьте запрос на ваш URL, и он появится здесь мгновенно. Попробуйте так:" + }, + "detail": { + "receivedAt": "Получено", + "source": "IP источника", + "contentType": "Content-Type", + "query": "Параметры query", + "headers": "Заголовки", + "body": "Тело", + "copyBody": "Копировать тело", + "emptyBody": "Нет тела" + }, + "errors": { + "createFailed": "Не удалось создать URL вебхука. Попробуйте снова.", + "clearFailed": "Не удалось очистить запросы. Попробуйте снова." + } + }, + "WebsocketTester": { + "title": "Тестер WebSocket", + "subtitle": "Подключайтесь к эндпоинту WebSocket и отправляйте, получайте и изучайте сообщения вживую", + "urlLabel": "URL эндпоинта", + "protocolsLabel": "Подпротоколы", + "protocolsPlaceholder": "необязательно, через запятую", + "connect": "Подключить", + "disconnect": "Отключить", + "status": { + "disconnected": "Отключено", + "connecting": "Подключение", + "connected": "Подключено", + "error": "Ошибка" + }, + "messages": { + "connecting": "Подключение к {url}…", + "connected": "Соединение установлено", + "connectedWithProtocol": "Соединение установлено (протокол: {protocol})", + "invalidUrl": "Некорректный URL — должен начинаться с ws:// или wss://", + "connectFailed": "Не удалось открыть соединение", + "socketError": "Произошла ошибка соединения", + "notConnected": "Не подключено — сначала подключитесь", + "binaryMessage": "Получено двоичное сообщение" + }, + "log": { + "title": "Журнал сообщений", + "empty": "Сообщений пока нет. Подключитесь и начните отправлять.", + "copyAll": "Копировать всё", + "download": "Скачать как .txt", + "clear": "Очистить журнал", + "prettyPrint": "Форматировать JSON", + "showRaw": "Показать необработанный текст" + }, + "composer": { + "placeholder": "Введите сообщение для отправки…", + "send": "Отправить", + "formatJson": "Форматировать как JSON", + "invalidJson": "Сообщение не является корректным JSON", + "hint": "Cmd/Ctrl+Enter для отправки" + }, + "ping": { + "label": "Авто-пинг", + "seconds": "с", + "intervalTitle": "Интервал пинга в секундах", + "payloadPlaceholder": "payload пинга" + } + }, + "WhoisLookup": { + "title": "Whois-запрос", + "subtitle": "Узнавайте данные регистрации любого домена или IP-адреса через RDAP", + "placeholder": "example.com или 8.8.8.8", + "lookupBtn": "Запросить", + "looking": "Запрос…", + "refreshBtn": "Обновить", + "tryLabel": "Попробуйте:", + "copy": "Копировать", + "copied": "Скопировано", + "notAvailable": "Недоступно", + "summary": { + "registrar": "Регистратор", + "network": "Сеть" + }, + "dates": { + "created": "Создан", + "updated": "Обновлён", + "expires": "Истекает" + }, + "sections": { + "status": "Статус", + "nameservers": "Серверы имён", + "contacts": "Контакты", + "rawRdap": "Необработанный RDAP" + }, + "contacts": { + "roles": "Роли", + "name": "Имя", + "organization": "Организация" + }, + "errors": { + "invalidTarget": "Введите корректное доменное имя или IP-адрес", + "lookupFailed": "Запрос не удался (статус {status})" + } } } diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index 95872ae6..dc78bc92 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod-kalkylator", + "codeScreenshot": "Kodskärmbild", + "cssGenerators": "CSS-generatorer", + "exifViewer": "EXIF-visare", + "faviconGenerator": "Favicon-generator", + "jsonDiff": "JSON-diff", + "jsonpathPlayground": "JSONPath-lekplats", + "keycodeInspector": "Tangentkodsinspektör", + "markdownTableGenerator": "Markdown-tabell", + "timezoneConverter": "Tidszonskonverterare", + "tokenCounter": "Token-räknare", + "webhookTester": "Webhook-testare", + "websocketTester": "WebSocket-testare", + "whoisLookup": "Whois-uppslag" }, "Help": { "title": "Hjälp och dokumentation", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt-generator och -kontroll", + "description": "Hasha lösenord med bcrypt och verifiera befintliga hashar." + }, + "chmodCalculator": { + "title": "Chmod-kalkylator", + "description": "Konvertera Unix-behörigheter mellan oktal, symbolisk notation och kryssrutor." + }, + "codeScreenshot": { + "title": "Kodskärmbild", + "description": "Förvandla källkod till vackra, delbara bilder." + }, + "cssGenerators": { + "title": "CSS-generatorer", + "description": "Generatorer för box-shadow, border-radius och flytande clamp()." + }, + "exifViewer": { + "title": "EXIF-visare och -borttagare", + "description": "Inspektera och ta bort EXIF-metadata från foton lokalt." + }, + "faviconGenerator": { + "title": "Favicon-generator", + "description": "Generera favicons, appikoner och manifest-utdrag." + }, + "jsonDiff": { + "title": "JSON-diff", + "description": "Semantisk, strukturmedveten jämförelse av två JSON-dokument." + }, + "jsonpathPlayground": { + "title": "JSONPath-lekplats", + "description": "Fråga JSON med JSONPath eller JMESPath och se resultat i realtid." + }, + "keycodeInspector": { + "title": "Tangentkodsinspektör", + "description": "Tryck på en tangent för att se dess JavaScript-händelsekoder och modifierare." + }, + "markdownTableGenerator": { + "title": "Markdown-tabellgenerator", + "description": "Bygg Markdown- och HTML-tabeller i en kalkylbladsliknande rutnätsredigerare." + }, + "timezoneConverter": { + "title": "Tidszonskonverterare", + "description": "Konvertera en tid över många zoner med dagsförskjutning och markeringar för arbetstid." + }, + "tokenCounter": { + "title": "LLM-token-räknare", + "description": "Räkna LLM-tokens, visualisera chunkar och uppskatta API-kostnad." + }, + "webhookTester": { + "title": "Webhook-testare", + "description": "Fånga och inspektera inkommande webhook-förfrågningar i realtid." + }, + "websocketTester": { + "title": "WebSocket-testare", + "description": "Anslut till WebSocket-slutpunkter och utbyt meddelanden i realtid." + }, + "whoisLookup": { + "title": "Whois-uppslag", + "description": "Slå upp registreringsuppgifter för domäner och IP via RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt-generator och -kontroll", + "subtitle": "Hasha lösenord med bcrypt och verifiera hashar — allt stannar i din webbläsare", + "tabs": { + "hash": "Hasha", + "verify": "Verifiera" + }, + "showPassword": "Visa lösenord", + "hidePassword": "Dölj lösenord", + "copy": "Kopiera", + "copied": "Kopierat", + "hash": { + "passwordLabel": "Lösenord", + "passwordPlaceholder": "Ange ett lösenord att hasha…", + "costLabel": "Kostnadsfaktor", + "costHint": "Högre kostnad fördubblar arbetet per steg. 10–12 är ett vanligt val för produktion.", + "slowWarning": "Kostnadsfaktorer på 13 och högre kan ta flera sekunder att beräkna.", + "generate": "Generera hash", + "generating": "Hashar…", + "resultLabel": "Bcrypt-hash", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Hash-anatomi", + "algorithm": "algoritm", + "cost": "kostnad", + "salt": "salt (22 tecken)", + "hash": "hash (31 tecken)" + } + }, + "verify": { + "passwordLabel": "Lösenord", + "passwordPlaceholder": "Ange lösenordet att kontrollera…", + "hashLabel": "Bcrypt-hash", + "hashPlaceholder": "$2b$10$…", + "idle": "Ange ett lösenord och en bcrypt-hash för att jämföra dem.", + "invalidHash": "Detta ser inte ut som en giltig bcrypt-hash.", + "checking": "Jämför…", + "match": "Matchar — lösenordet ger denna hash.", + "noMatch": "Ingen matchning — lösenordet ger inte denna hash." + } + }, + "ChmodCalculator": { + "title": "Chmod-kalkylator", + "subtitle": "Konvertera Unix-filbehörigheter mellan kryssrutor, oktal och symbolisk notation", + "grid": { + "title": "Behörigheter", + "read": "Läs", + "write": "Skriv", + "execute": "Kör", + "owner": "Ägare", + "group": "Grupp", + "others": "Andra" + }, + "special": { + "title": "Specialbitar", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Oktal", + "placeholder": "755 eller 4755", + "invalid": "Ange 3 eller 4 oktala siffror (0–7)." + }, + "symbolic": { + "label": "Symbolisk", + "placeholder": "rwxr-xr-x", + "invalid": "Ange 9 tecken som rwxr-xr-x (s/S, t/T tillåtna)." + }, + "output": { + "title": "Kommando", + "copy": "Kopiera", + "copied": "Kopierat" + }, + "explain": { + "line": "{cls} kan {perms}.", + "none": "{cls} har inga behörigheter.", + "setuid": "Setuid: filen körs med ägarens behörigheter.", + "setgid": "Setgid: filen körs med gruppens behörigheter (nya filer i en katalog ärver dess grupp).", + "sticky": "Sticky: endast en fils ägare kan radera eller byta namn på den i denna katalog." + }, + "presets": { + "title": "Vanliga förinställningar", + "octal": "Oktal", + "permissions": "Behörigheter", + "description": "Typisk användning", + "p644": "Vanliga filer: ägaren skriver, alla läser.", + "p755": "Körbara filer och kataloger: alla kan läsa och gå in.", + "p600": "Privata filer: endast ägaren läser och skriver.", + "p700": "Privata körbara filer och kataloger: endast ägaren.", + "p664": "Grupp-skrivbara filer.", + "p775": "Grupp-skrivbara kataloger och körbara filer.", + "p400": "Skrivskyddad för ägaren (t.ex. privata SSH-nycklar)." + } + }, + "CodeScreenshot": { + "title": "Kodskärmbild", + "subtitle": "Förvandla kod till vackra, delbara bilder", + "languageLabel": "Språk", + "autoDetect": "Automatisk identifiering", + "backgroundLabel": "Bakgrund", + "paddingLabel": "Utfyllnad", + "filenameLabel": "Filnamn", + "filenamePlaceholder": "example.ts", + "windowLabel": "Fönsterprickar", + "themeLabel": "Fönstertema", + "themeDark": "Mörkt", + "themeLight": "Ljust", + "scaleLabel": "Exportskala", + "copyImage": "Kopiera bild", + "copied": "Kopierat", + "exportPng": "Exportera PNG", + "inputLabel": "Kod", + "detected": "Identifierat: {language}", + "clear": "Rensa", + "placeholder": "Klistra in din kod här…", + "previewLabel": "Förhandsgranskning", + "emptyPreview": "Förhandsgranskningen av din kod visas här", + "exportFailed": "Kunde inte exportera bild", + "copyUnsupported": "Kopiering av bilder stöds inte i den här webbläsaren", + "copyFailed": "Kunde inte kopiera bild", + "backgrounds": { + "ocean": "Ocean", + "sunset": "Solnedgång", + "candy": "Godis", + "forest": "Skog", + "midnight": "Midnatt", + "gold": "Guld", + "aurora": "Norrsken", + "slate": "Skiffer", + "transparent": "Transparent" + } + }, + "CssGenerators": { + "title": "CSS-generatorer", + "subtitle": "Box-shadow, border-radius och flytande clamp() — kopiera CSS-koden.", + "preview": "Förhandsgranskning", + "output": "CSS", + "copy": "Kopiera", + "copied": "Kopierat", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Flytande clamp()" + }, + "boxShadow": { + "presets": "Förinställningar", + "layer": "Lager", + "addLayer": "Lägg till lager", + "removeLayer": "Ta bort lager", + "x": "X", + "y": "Y", + "blur": "Oskärpa", + "spread": "Spridning", + "color": "Färg", + "alpha": "Alfa", + "inset": "Inre", + "presetNames": { + "subtleCard": "Diskret kort", + "elevated": "Upphöjd", + "hardOffset": "Hård förskjutning", + "neonGlow": "Neonglöd", + "inner": "Inre", + "layeredSmooth": "Lagrad mjuk" + } + }, + "borderRadius": { + "modeLabel": "Läge", + "modes": { + "simple": "Per hörn", + "blob": "Organisk klick" + }, + "unit": "Enhet", + "linkCorners": "Länka hörn", + "randomize": "Slumpa", + "horizontal": "Horisontella radier", + "vertical": "Vertikala radier", + "corners": { + "tl": "Uppe till vänster", + "tr": "Uppe till höger", + "br": "Nere till höger", + "bl": "Nere till vänster" + } + }, + "clamp": { + "minFont": "Min. typsnitt (px)", + "maxFont": "Max. typsnitt (px)", + "minViewport": "Min. viewport (px)", + "maxViewport": "Max. viewport (px)", + "rootFont": "Rot-typsnitt (px)", + "viewportWidth": "Simulerad viewport", + "computedSize": "Beräknad storlek", + "slope": "Lutning", + "intercept": "Skärningspunkt", + "previewText": "Flygande bäckasiner söka", + "invalid": "Ange giltiga, olika viewport-gränser." + } + }, + "ExifViewer": { + "title": "EXIF-visare och -borttagare", + "subtitle": "Inspektera fotons metadata och ta bort dem i din webbläsare", + "dropTitle": "Släpp en bild här", + "dropHint": "eller klicka för att bläddra — din bild lämnar aldrig din enhet", + "browse": "Välj bild", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Läser metadata…", + "parseFailed": "Kunde inte läsa den här bilden", + "clear": "Ta bort bild", + "gpsWarning": "Innehåller GPS-plats", + "openMap": "Visa på OpenStreetMap", + "noMetadata": "Inga inbäddade metadata hittades i den här bilden.", + "facts": { + "name": "Namn", + "type": "Typ", + "dimensions": "Dimensioner", + "size": "Storlek" + }, + "groups": { + "camera": "Kamera", + "exposure": "Exponering", + "timestamps": "Tidsstämplar", + "gps": "GPS-plats", + "software": "Programvara och övrigt" + }, + "gpsCard": { + "latitude": "Latitud", + "longitude": "Longitud", + "altitude": "Höjd" + }, + "raw": { + "title": "Alla metadata", + "copyJson": "Kopiera JSON", + "copied": "Kopierat" + }, + "remove": { + "title": "Ta bort metadata", + "jpegDescription": "Ta bort EXIF, XMP, IPTC och kommentarer förlustfritt utan att komprimera om bilden.", + "reencodeDescription": "Koda om bilden för att ta bort inbäddade metadata. Detta komprimerar om pixlarna.", + "keepIcc": "Behåll färgprofil (ICC)", + "stripButton": "Ta bort metadata", + "reencodeButton": "Koda om och ta bort", + "unsupported": "Borttagning av metadata stöds inte för den här filtypen.", + "failed": "Kunde inte ta bort metadata", + "before": "Före", + "after": "Efter", + "download": "Ladda ner rensad bild" + } + }, + "FaviconGenerator": { + "title": "Favicon-generator", + "subtitle": "Skapa favicons och appikoner från en enda bild", + "dropTitle": "Släpp en bild här", + "dropHint": "PNG, JPEG eller SVG — klicka för att bläddra", + "browse": "Välj bild", + "clear": "Ta bort bild", + "backgroundLabel": "Bakgrund", + "transparent": "Transparent", + "solidColor": "Enfärgad", + "cornerLabel": "Hörnform", + "cornerNone": "Fyrkantig", + "cornerRounded": "Rundad", + "cornerCircle": "Cirkel", + "maskPaddingLabel": "Maskerbar utfyllnad", + "maskable": "maskable", + "previewLabel": "Förhandsgranskning", + "copy": "Kopiera", + "headSnippet": "HTML head-utdrag", + "downloadIco": "Ladda ner favicon.ico", + "downloadZip": "Ladda ner alla (.zip)" + }, + "JsonDiff": { + "title": "JSON-diff", + "subtitle": "Semantisk, strukturmedveten jämförelse av två JSON-dokument — nyckelordning och formatering ignoreras", + "options": { + "ignoreArrayOrder": "Ignorera array-ordning" + }, + "loadBothSamples": "Ladda exempel", + "sample": "Exempel", + "format": "Formatera", + "clear": "Rensa", + "formatError": "Kan inte formatera: indata är inte giltig JSON", + "placeholder": "Klistra in eller skriv JSON här…", + "panels": { + "left": "Original (vänster)", + "right": "Ändrad (höger)", + "results": "Skillnader" + }, + "summary": { + "added": "{count} tillagda", + "removed": "{count} borttagna", + "changed": "{count} ändrade" + }, + "types": { + "added": "Tillagd", + "removed": "Borttagen", + "changed": "Ändrad" + }, + "copyChanges": "Kopiera som JSON", + "copied": "Kopierat", + "empty": "Klistra in JSON i båda panelerna för att se skillnaderna.", + "identical": "De två dokumenten är semantiskt identiska." + }, + "JsonpathPlayground": { + "title": "JSONPath-lekplats", + "subtitle": "Fråga JSON-dokument med JSONPath eller JMESPath och se resultat i realtid", + "engineLabel": "Motor", + "queryLabel": "Fråga", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Fusklapp", + "cheats": { + "jpAllTitles": "Alla boktitlar", + "jpAllAuthors": "Varje författare, var som helst i dokumentet", + "jpFirstBook": "Första boken", + "jpLastBook": "Sista boken", + "jpSlice": "De två första böckerna (utsnitt)", + "jpFilterPrice": "Böcker billigare än 40", + "jpFilterInStock": "Titlar på böcker i lager", + "jpRecursive": "Alla värden, rekursivt", + "jmAllTitles": "Alla boktitlar", + "jmFirstAuthor": "Författare till den första boken", + "jmLastBook": "Sista boken", + "jmSlice": "De två första böckerna (utsnitt)", + "jmFilterPrice": "Titlar på böcker billigare än 40", + "jmCount": "Antal böcker i lager", + "jmProjection": "Omforma varje bok till ett nytt objekt", + "jmSort": "Titlar sorterade efter pris" + }, + "panels": { + "input": "JSON-indata", + "results": "Resultat" + }, + "sample": "Exempel", + "format": "Formatera", + "clear": "Rensa", + "formatError": "Kan inte formatera: indata är inte giltig JSON", + "placeholder": "Klistra in eller skriv JSON här…", + "outputPlaceholder": "Matchningar visas här…", + "matchCount": "{count, plural, one {# matchning} other {# matchningar}}", + "copy": "Kopiera", + "copied": "Kopierat" + }, + "KeycodeInspector": { + "title": "Tangentkodsinspektör", + "subtitle": "Tryck på valfri tangent för att inspektera dess JavaScript-tangentbordshändelse", + "capture": { + "prompt": "Tryck på valfri tangent…", + "ready": "Lyssnar — tryck på valfri tangent på tangentbordet", + "clickToFocus": "Klicka här för att fokusera, tryck sedan på valfri tangent" + }, + "hero": { + "spaceKey": "(Mellanslag)", + "keyLabel": "event.key", + "repeat": "upprepning" + }, + "copyJson": "Kopiera JSON", + "copiedJson": "Händelse-JSON kopierad till urklipp", + "deprecated": "föråldrad", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standard", + "left": "Vänster", + "right": "Höger", + "numpad": "Numeriskt tangentbord", + "unknown": "Okänd" + }, + "history": { + "title": "Historik", + "clear": "Rensa historik", + "empty": "Tangenttryckningar visas här." + } + }, + "MarkdownTableGenerator": { + "title": "Markdown-tabellgenerator", + "subtitle": "Redigera ett rutnät, klistra in från ett kalkylblad och kopiera en snyggt justerad Markdown- eller HTML-tabell", + "addRow": "Lägg till rad", + "addColumn": "Lägg till kolumn", + "importLabel": "Importera", + "importPlaceholder": "Klistra in TSV (från Excel / Google Kalkylark) eller CSV här…", + "importButton": "Importera till rutnät", + "pasteClipboard": "Klistra in från urklipp", + "importEmpty": "Inget att importera — klistra in några rader först", + "clipboardError": "Kunde inte läsa från urklipp", + "clear": "Rensa", + "panels": { + "editor": "Tabellredigerare", + "output": "Utdata" + }, + "align": { + "left": "Vänsterjustera", + "center": "Centrera", + "right": "Högerjustera" + }, + "headerPlaceholder": "Rubrik", + "cellPlaceholder": "Cell", + "removeRow": "Ta bort rad", + "removeColumn": "Ta bort kolumn", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Kopiera", + "copied": "Kopierat" + }, + "TimezoneConverter": { + "title": "Tidszonskonverterare", + "subtitle": "Konvertera ett ögonblick över hela världen för mötesplanering.", + "dateTimeLabel": "Datum och tid", + "now": "Nu", + "sourceZoneLabel": "Källtidszon", + "selectZone": "Välj zon", + "searchZone": "Sök tidszoner...", + "noZone": "Ingen tidszon hittades.", + "scrubLabel": "Förskjut tid", + "reset": "Återställ", + "targetsLabel": "Målzoner", + "addZone": "Lägg till zon", + "remove": "Ta bort", + "localBadge": "Lokal", + "invalidDate": "Ange ett giltigt datum och tid.", + "bands": { + "work": "Arbetstid (09:00–18:00)", + "edge": "Randtimmar (tidigt / kväll)", + "night": "Natt" + } + }, + "TokenCounter": { + "title": "LLM-token-räknare", + "subtitle": "Räkna tokens, visualisera chunkar och uppskatta API-kostnad.", + "inputLabel": "Text", + "sample": "Exempel", + "clear": "Rensa", + "placeholder": "Klistra in text, kod eller JSON för att räkna tokens...", + "encoding": "Kodning", + "loading": "Laddar tokenizer...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-serien)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Tokens", + "characters": "Tecken", + "words": "Ord", + "ratio": "Tokens/ord", + "ratioSub": "ungefär" + }, + "claudeRow": "Claude (ungefärligt)", + "estimate": "uppskattning", + "viz": { + "title": "Token-visualisering", + "toggle": "Visa", + "hint": "Växla för att färglägga varje token som en chunk.", + "empty": "Ange text för att visualisera tokens.", + "truncated": "Visar de första {limit} av {total} tokens." + }, + "cost": { + "title": "Kostnadskalkylator", + "disclaimer": "redigerbara standardvärden — verifiera aktuella priser", + "model": "Modell", + "expectedOutput": "Förväntade output-tokens", + "customInput": "Input $/MTok", + "customOutput": "Output $/MTok", + "inputCost": "Input-kostnad", + "outputCost": "Output-kostnad", + "total": "Totalt", + "per1000": "Per 1000 anrop" + } + }, + "WebhookTester": { + "title": "Webhook-testare", + "subtitle": "Skapa en tillfällig URL och inspektera inkommande webhook-förfrågningar live.", + "intro": { + "title": "Skapa en webhook-URL för att börja fånga förfrågningar", + "hint": "Du får en ogissningsbar URL som registrerar varje inkommande förfrågan. Den upphör efter 24 timmar." + }, + "createBtn": "Skapa webhook-URL", + "creating": "Skapar…", + "urlLabel": "Din webhook-URL", + "expiresIn": "Upphör om {countdown}", + "copyUrlBtn": "Kopiera URL", + "copied": "Kopierat", + "clearBtn": "Rensa", + "newBinBtn": "Ny URL", + "requestsTitle": "Förfrågningar", + "selectPrompt": "Välj en förfrågan för att inspektera dess detaljer.", + "expiredNotice": "Den här webhook-URL:en har upphört. Skapa en ny för att fortsätta testa.", + "base64Badge": "base64", + "truncatedBadge": "avkortad", + "empty": { + "title": "Väntar på förfrågningar…", + "hint": "Skicka en förfrågan till din URL så visas den här direkt. Prova detta:" + }, + "detail": { + "receivedAt": "Mottagen", + "source": "Käll-IP", + "contentType": "Content-Type", + "query": "Frågeparametrar", + "headers": "Headers", + "body": "Body", + "copyBody": "Kopiera body", + "emptyBody": "Ingen body" + }, + "errors": { + "createFailed": "Kunde inte skapa webhook-URL. Försök igen.", + "clearFailed": "Kunde inte rensa förfrågningar. Försök igen." + } + }, + "WebsocketTester": { + "title": "WebSocket-testare", + "subtitle": "Anslut till en WebSocket-slutpunkt och skicka, ta emot och inspektera meddelanden live", + "urlLabel": "Slutpunkts-URL", + "protocolsLabel": "Underprotokoll", + "protocolsPlaceholder": "valfritt, kommaseparerat", + "connect": "Anslut", + "disconnect": "Koppla från", + "status": { + "disconnected": "Frånkopplad", + "connecting": "Ansluter", + "connected": "Ansluten", + "error": "Fel" + }, + "messages": { + "connecting": "Ansluter till {url}…", + "connected": "Anslutning upprättad", + "connectedWithProtocol": "Anslutning upprättad (protokoll: {protocol})", + "invalidUrl": "Ogiltig URL — måste börja med ws:// eller wss://", + "connectFailed": "Kunde inte öppna anslutningen", + "socketError": "Ett anslutningsfel uppstod", + "notConnected": "Inte ansluten — anslut först", + "binaryMessage": "Tog emot ett binärt meddelande" + }, + "log": { + "title": "Meddelandelogg", + "empty": "Inga meddelanden ännu. Anslut och börja skicka.", + "copyAll": "Kopiera alla", + "download": "Ladda ner som .txt", + "clear": "Rensa logg", + "prettyPrint": "Formatera JSON snyggt", + "showRaw": "Visa rå text" + }, + "composer": { + "placeholder": "Skriv ett meddelande att skicka…", + "send": "Skicka", + "formatJson": "Formatera som JSON", + "invalidJson": "Meddelandet är inte giltig JSON", + "hint": "Cmd/Ctrl+Enter för att skicka" + }, + "ping": { + "label": "Auto-ping", + "seconds": "sek", + "intervalTitle": "Ping-intervall i sekunder", + "payloadPlaceholder": "ping-payload" + } + }, + "WhoisLookup": { + "title": "Whois-uppslag", + "subtitle": "Slå upp registreringsuppgifter för valfri domän eller IP-adress via RDAP", + "placeholder": "example.com eller 8.8.8.8", + "lookupBtn": "Slå upp", + "looking": "Slår upp…", + "refreshBtn": "Uppdatera", + "tryLabel": "Prova:", + "copy": "Kopiera", + "copied": "Kopierat", + "notAvailable": "Ej tillgänglig", + "summary": { + "registrar": "Registrator", + "network": "Nätverk" + }, + "dates": { + "created": "Skapad", + "updated": "Uppdaterad", + "expires": "Upphör" + }, + "sections": { + "status": "Status", + "nameservers": "Namnservrar", + "contacts": "Kontakter", + "rawRdap": "Rå RDAP" + }, + "contacts": { + "roles": "Roller", + "name": "Namn", + "organization": "Organisation" + }, + "errors": { + "invalidTarget": "Ange ett giltigt domännamn eller IP-adress", + "lookupFailed": "Uppslaget misslyckades (status {status})" + } } } diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index 1d11e7a3..5a1b1c27 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod Hesaplayıcı", + "codeScreenshot": "Kod Ekran Görüntüsü", + "cssGenerators": "CSS Üreticileri", + "exifViewer": "EXIF Görüntüleyici", + "faviconGenerator": "Favicon Üreticisi", + "jsonDiff": "JSON Karşılaştırma", + "jsonpathPlayground": "JSONPath Oyun Alanı", + "keycodeInspector": "Tuş Kodu İnceleyici", + "markdownTableGenerator": "Markdown Tablosu", + "timezoneConverter": "Saat Dilimi Dönüştürücü", + "tokenCounter": "Token Sayacı", + "webhookTester": "Webhook Test Aracı", + "websocketTester": "WebSocket Test Aracı", + "whoisLookup": "Whois Sorgulama" }, "Help": { "title": "Yardım ve dokümantasyon", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt Üretici ve Doğrulayıcı", + "description": "Parolaları bcrypt ile hash'leyin ve mevcut hash'leri doğrulayın." + }, + "chmodCalculator": { + "title": "Chmod Hesaplayıcı", + "description": "Unix izinlerini sekizlik, sembolik ve onay kutuları arasında dönüştürün." + }, + "codeScreenshot": { + "title": "Kod Ekran Görüntüsü", + "description": "Kaynak kodu güzel ve paylaşılabilir görsellere dönüştürün." + }, + "cssGenerators": { + "title": "CSS Üreticileri", + "description": "box-shadow, border-radius ve akışkan clamp() üreticileri." + }, + "exifViewer": { + "title": "EXIF Görüntüleyici ve Temizleyici", + "description": "Fotoğraf EXIF meta verilerini yerel olarak inceleyin ve temizleyin." + }, + "faviconGenerator": { + "title": "Favicon Üreticisi", + "description": "Favicon, uygulama simgeleri ve manifest parçacıkları oluşturun." + }, + "jsonDiff": { + "title": "JSON Karşılaştırma", + "description": "İki JSON belgesinin anlamsal, yapı farkında karşılaştırması." + }, + "jsonpathPlayground": { + "title": "JSONPath Oyun Alanı", + "description": "JSON'ı JSONPath veya JMESPath ile sorgulayın ve sonuçları canlı görün." + }, + "keycodeInspector": { + "title": "Tuş Kodu İnceleyici", + "description": "Bir tuşa basarak JavaScript olay kodlarını ve değiştiricileri görün." + }, + "markdownTableGenerator": { + "title": "Markdown Tablosu Üreticisi", + "description": "Markdown ve HTML tablolarını elektronik tablo benzeri bir ızgara düzenleyicide oluşturun." + }, + "timezoneConverter": { + "title": "Saat Dilimi Dönüştürücü", + "description": "Bir zamanı birçok dilime; gün kayması ve çalışma saati ipuçlarıyla dönüştürün." + }, + "tokenCounter": { + "title": "LLM Token Sayacı", + "description": "LLM token'larını sayın, parçaları görselleştirin ve API maliyetini tahmin edin." + }, + "webhookTester": { + "title": "Webhook Test Aracı", + "description": "Gelen webhook isteklerini gerçek zamanlı yakalayın ve inceleyin." + }, + "websocketTester": { + "title": "WebSocket Test Aracı", + "description": "WebSocket uç noktalarına bağlanın ve mesajları gerçek zamanlı alışverişini yapın." + }, + "whoisLookup": { + "title": "Whois Sorgulama", + "description": "Alan adı ve IP kayıt bilgilerini RDAP üzerinden sorgulayın." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt Üretici ve Doğrulayıcı", + "subtitle": "Parolaları bcrypt ile hash'leyin ve hash'leri doğrulayın — her şey tarayıcınızda kalır", + "tabs": { + "hash": "Hash'le", + "verify": "Doğrula" + }, + "showPassword": "Parolayı göster", + "hidePassword": "Parolayı gizle", + "copy": "Kopyala", + "copied": "Kopyalandı", + "hash": { + "passwordLabel": "Parola", + "passwordPlaceholder": "Hash'lenecek bir parola girin…", + "costLabel": "Maliyet faktörü", + "costHint": "Daha yüksek maliyet, her adımdaki işi ikiye katlar. Üretim için genellikle 10-12 tercih edilir.", + "slowWarning": "13 ve üzeri maliyet faktörlerinin hesaplanması birkaç saniye sürebilir.", + "generate": "Hash oluştur", + "generating": "Hash'leniyor…", + "resultLabel": "Bcrypt hash'i", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Hash anatomisi", + "algorithm": "algoritma", + "cost": "maliyet", + "salt": "salt (22 karakter)", + "hash": "hash (31 karakter)" + } + }, + "verify": { + "passwordLabel": "Parola", + "passwordPlaceholder": "Kontrol edilecek parolayı girin…", + "hashLabel": "Bcrypt hash'i", + "hashPlaceholder": "$2b$10$…", + "idle": "Karşılaştırmak için bir parola ve bir bcrypt hash'i girin.", + "invalidHash": "Bu, geçerli bir bcrypt hash'i gibi görünmüyor.", + "checking": "Karşılaştırılıyor…", + "match": "Eşleşti — parola bu hash'i üretiyor.", + "noMatch": "Eşleşmedi — parola bu hash'i üretmiyor." + } + }, + "ChmodCalculator": { + "title": "Chmod Hesaplayıcı", + "subtitle": "Unix dosya izinlerini onay kutuları, sekizlik ve sembolik gösterim arasında dönüştürün", + "grid": { + "title": "İzinler", + "read": "Okuma", + "write": "Yazma", + "execute": "Çalıştırma", + "owner": "Sahip", + "group": "Grup", + "others": "Diğerleri" + }, + "special": { + "title": "Özel bitler", + "setuid": "setuid", + "setgid": "setgid", + "sticky": "sticky" + }, + "octal": { + "label": "Sekizlik", + "placeholder": "755 veya 4755", + "invalid": "3 veya 4 sekizlik basamak girin (0-7)." + }, + "symbolic": { + "label": "Sembolik", + "placeholder": "rwxr-xr-x", + "invalid": "rwxr-xr-x gibi 9 karakter girin (s/S, t/T kabul edilir)." + }, + "output": { + "title": "Komut", + "copy": "Kopyala", + "copied": "Kopyalandı" + }, + "explain": { + "line": "{cls} şunları yapabilir: {perms}.", + "none": "{cls} hiçbir izne sahip değil.", + "setuid": "setuid: dosya, sahibinin ayrıcalıklarıyla çalışır.", + "setgid": "setgid: dosya, grubunun ayrıcalıklarıyla çalışır (bir dizindeki yeni dosyalar onun grubunu devralır).", + "sticky": "sticky: bu dizin içindeki bir dosyayı yalnızca sahibi silebilir veya yeniden adlandırabilir." + }, + "presets": { + "title": "Yaygın ön ayarlar", + "octal": "Sekizlik", + "permissions": "İzinler", + "description": "Tipik kullanım", + "p644": "Normal dosyalar: sahibi yazar, herkes okur.", + "p755": "Çalıştırılabilirler ve dizinler: herkes okuyabilir ve girebilir.", + "p600": "Özel dosyalar: yalnızca sahibi okur ve yazar.", + "p700": "Özel çalıştırılabilirler ve dizinler: yalnızca sahibi.", + "p664": "Grup tarafından yazılabilen dosyalar.", + "p775": "Grup tarafından yazılabilen dizinler ve çalıştırılabilirler.", + "p400": "Sahibi için salt okunur (ör. SSH özel anahtarları)." + } + }, + "CodeScreenshot": { + "title": "Kod Ekran Görüntüsü", + "subtitle": "Kodu güzel, paylaşılabilir görsellere dönüştürün", + "languageLabel": "Dil", + "autoDetect": "Otomatik algıla", + "backgroundLabel": "Arka plan", + "paddingLabel": "İç boşluk", + "filenameLabel": "Dosya adı", + "filenamePlaceholder": "example.ts", + "windowLabel": "Pencere noktaları", + "themeLabel": "Pencere teması", + "themeDark": "Koyu", + "themeLight": "Açık", + "scaleLabel": "Dışa aktarma ölçeği", + "copyImage": "Görseli kopyala", + "copied": "Kopyalandı", + "exportPng": "PNG dışa aktar", + "inputLabel": "Kod", + "detected": "Algılanan: {language}", + "clear": "Temizle", + "placeholder": "Kodunuzu buraya yapıştırın…", + "previewLabel": "Önizleme", + "emptyPreview": "Kod önizlemeniz burada görünecek", + "exportFailed": "Görsel dışa aktarılamadı", + "copyUnsupported": "Görsel kopyalama bu tarayıcıda desteklenmiyor", + "copyFailed": "Görsel kopyalanamadı", + "backgrounds": { + "ocean": "Okyanus", + "sunset": "Gün batımı", + "candy": "Şeker", + "forest": "Orman", + "midnight": "Gece yarısı", + "gold": "Altın", + "aurora": "Kutup ışığı", + "slate": "Arduvaz", + "transparent": "Şeffaf" + } + }, + "CssGenerators": { + "title": "CSS Üreticileri", + "subtitle": "box-shadow, border-radius ve akışkan clamp() — CSS'i kopyalayın.", + "preview": "Önizleme", + "output": "CSS", + "copy": "Kopyala", + "copied": "Kopyalandı", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Akışkan clamp()" + }, + "boxShadow": { + "presets": "Ön ayarlar", + "layer": "Katman", + "addLayer": "Katman ekle", + "removeLayer": "Katmanı kaldır", + "x": "X", + "y": "Y", + "blur": "Bulanıklık", + "spread": "Yayılım", + "color": "Renk", + "alpha": "Alfa", + "inset": "İç gölge", + "presetNames": { + "subtleCard": "İnce kart", + "elevated": "Yükseltilmiş", + "hardOffset": "Sert kaydırma", + "neonGlow": "Neon parıltı", + "inner": "İç", + "layeredSmooth": "Katmanlı yumuşak" + } + }, + "borderRadius": { + "modeLabel": "Mod", + "modes": { + "simple": "Köşe başına", + "blob": "Organik blob" + }, + "unit": "Birim", + "linkCorners": "Köşeleri bağla", + "randomize": "Rastgele", + "horizontal": "Yatay yarıçaplar", + "vertical": "Dikey yarıçaplar", + "corners": { + "tl": "Sol üst", + "tr": "Sağ üst", + "br": "Sağ alt", + "bl": "Sol alt" + } + }, + "clamp": { + "minFont": "Min yazı tipi (px)", + "maxFont": "Maks yazı tipi (px)", + "minViewport": "Min viewport (px)", + "maxViewport": "Maks viewport (px)", + "rootFont": "Kök yazı tipi (px)", + "viewportWidth": "Simüle edilen viewport", + "computedSize": "Hesaplanan boyut", + "slope": "Eğim", + "intercept": "Kesişim", + "previewText": "Önizleme için örnek metin", + "invalid": "Geçerli ve birbirinden farklı viewport sınırları girin." + } + }, + "ExifViewer": { + "title": "EXIF Görüntüleyici ve Temizleyici", + "subtitle": "Fotoğraf meta verilerini inceleyin ve tarayıcınızda temizleyin", + "dropTitle": "Bir görseli buraya bırakın", + "dropHint": "veya göz atmak için tıklayın — görseliniz asla cihazınızdan çıkmaz", + "browse": "Görsel seç", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Meta veri okunuyor…", + "parseFailed": "Bu görsel okunamadı", + "clear": "Görseli kaldır", + "gpsWarning": "GPS konumu içeriyor", + "openMap": "OpenStreetMap'te görüntüle", + "noMetadata": "Bu görselde gömülü meta veri bulunamadı.", + "facts": { + "name": "Ad", + "type": "Tür", + "dimensions": "Boyutlar", + "size": "Boyut" + }, + "groups": { + "camera": "Kamera", + "exposure": "Pozlama", + "timestamps": "Zaman damgaları", + "gps": "GPS Konumu", + "software": "Yazılım ve Diğer" + }, + "gpsCard": { + "latitude": "Enlem", + "longitude": "Boylam", + "altitude": "Rakım" + }, + "raw": { + "title": "Tüm meta veriler", + "copyJson": "JSON kopyala", + "copied": "Kopyalandı" + }, + "remove": { + "title": "Meta veriyi kaldır", + "jpegDescription": "Görseli yeniden sıkıştırmadan EXIF, XMP, IPTC ve yorumları kayıpsız kaldırın.", + "reencodeDescription": "Gömülü meta veriyi temizlemek için görseli yeniden kodlayın. Bu, pikselleri yeniden sıkıştırır.", + "keepIcc": "Renk profilini koru (ICC)", + "stripButton": "Meta veriyi temizle", + "reencodeButton": "Yeniden kodla ve temizle", + "unsupported": "Bu dosya türü için meta veri kaldırma desteklenmiyor.", + "failed": "Meta veri kaldırılamadı", + "before": "Önce", + "after": "Sonra", + "download": "Temizlenmiş görseli indir" + } + }, + "FaviconGenerator": { + "title": "Favicon Üreticisi", + "subtitle": "Tek bir görselden favicon ve uygulama simgeleri oluşturun", + "dropTitle": "Bir görseli buraya bırakın", + "dropHint": "PNG, JPEG veya SVG — göz atmak için tıklayın", + "browse": "Görsel seç", + "clear": "Görseli kaldır", + "backgroundLabel": "Arka plan", + "transparent": "Şeffaf", + "solidColor": "Düz renk", + "cornerLabel": "Köşe şekli", + "cornerNone": "Kare", + "cornerRounded": "Yuvarlatılmış", + "cornerCircle": "Daire", + "maskPaddingLabel": "Maskelenebilir iç boşluk", + "maskable": "maskable", + "previewLabel": "Önizleme", + "copy": "Kopyala", + "headSnippet": "HTML head parçacığı", + "downloadIco": "favicon.ico indir", + "downloadZip": "Tümünü indir (.zip)" + }, + "JsonDiff": { + "title": "JSON Karşılaştırma", + "subtitle": "İki JSON belgesinin anlamsal, yapı farkında karşılaştırması — anahtar sırası ve biçimlendirme yok sayılır", + "options": { + "ignoreArrayOrder": "Dizi sırasını yok say" + }, + "loadBothSamples": "Örnekleri yükle", + "sample": "Örnek", + "format": "Biçimlendir", + "clear": "Temizle", + "formatError": "Biçimlendirilemiyor: giriş geçerli JSON değil", + "placeholder": "JSON'ı buraya yapıştırın veya yazın…", + "panels": { + "left": "Orijinal (sol)", + "right": "Değiştirilmiş (sağ)", + "results": "Farklar" + }, + "summary": { + "added": "{count} eklendi", + "removed": "{count} kaldırıldı", + "changed": "{count} değişti" + }, + "types": { + "added": "Eklendi", + "removed": "Kaldırıldı", + "changed": "Değişti" + }, + "copyChanges": "JSON olarak kopyala", + "copied": "Kopyalandı", + "empty": "Farklarını görmek için her iki panele de JSON yapıştırın.", + "identical": "İki belge anlamsal olarak aynı." + }, + "JsonpathPlayground": { + "title": "JSONPath Oyun Alanı", + "subtitle": "JSON belgelerini JSONPath veya JMESPath ile sorgulayın ve sonuçları canlı görün", + "engineLabel": "Motor", + "queryLabel": "Sorgu", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Kopya kâğıdı", + "cheats": { + "jpAllTitles": "Tüm kitap başlıkları", + "jpAllAuthors": "Belgenin herhangi bir yerindeki her yazar", + "jpFirstBook": "İlk kitap", + "jpLastBook": "Son kitap", + "jpSlice": "İlk iki kitap (dilim)", + "jpFilterPrice": "40'tan ucuz kitaplar", + "jpFilterInStock": "Stoktaki kitapların başlıkları", + "jpRecursive": "Tüm değerler, özyinelemeli", + "jmAllTitles": "Tüm kitap başlıkları", + "jmFirstAuthor": "İlk kitabın yazarı", + "jmLastBook": "Son kitap", + "jmSlice": "İlk iki kitap (dilim)", + "jmFilterPrice": "40'tan ucuz kitapların başlıkları", + "jmCount": "Stoktaki kitapları say", + "jmProjection": "Her kitabı yeni bir nesneye dönüştür", + "jmSort": "Fiyata göre sıralanmış başlıklar" + }, + "panels": { + "input": "JSON girişi", + "results": "Sonuçlar" + }, + "sample": "Örnek", + "format": "Biçimlendir", + "clear": "Temizle", + "formatError": "Biçimlendirilemiyor: giriş geçerli JSON değil", + "placeholder": "JSON'ı buraya yapıştırın veya yazın…", + "outputPlaceholder": "Eşleşmeler burada görünecek…", + "matchCount": "{count, plural, one {# eşleşme} other {# eşleşme}}", + "copy": "Kopyala", + "copied": "Kopyalandı" + }, + "KeycodeInspector": { + "title": "Tuş Kodu İnceleyici", + "subtitle": "JavaScript klavye olayını incelemek için herhangi bir tuşa basın", + "capture": { + "prompt": "Herhangi bir tuşa basın…", + "ready": "Dinleniyor — klavyenizde herhangi bir tuşa basın", + "clickToFocus": "Odaklanmak için buraya tıklayın, sonra herhangi bir tuşa basın" + }, + "hero": { + "spaceKey": "(Boşluk)", + "keyLabel": "event.key", + "repeat": "yineleme" + }, + "copyJson": "JSON kopyala", + "copiedJson": "Olay JSON'ı panoya kopyalandı", + "deprecated": "kullanımdan kaldırıldı", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Standart", + "left": "Sol", + "right": "Sağ", + "numpad": "Sayı tuş takımı", + "unknown": "Bilinmiyor" + }, + "history": { + "title": "Geçmiş", + "clear": "Geçmişi temizle", + "empty": "Tuş basmaları burada görünecek." + } + }, + "MarkdownTableGenerator": { + "title": "Markdown Tablosu Üreticisi", + "subtitle": "Bir ızgarayı düzenleyin, elektronik tablodan yapıştırın ve düzgün hizalanmış bir Markdown veya HTML tablosu kopyalayın", + "addRow": "Satır ekle", + "addColumn": "Sütun ekle", + "importLabel": "İçe aktar", + "importPlaceholder": "TSV (Excel / Google Sheets'ten) veya CSV'yi buraya yapıştırın…", + "importButton": "Izgaraya içe aktar", + "pasteClipboard": "Panodan yapıştır", + "importEmpty": "İçe aktarılacak bir şey yok — önce birkaç satır yapıştırın", + "clipboardError": "Pano okunamadı", + "clear": "Temizle", + "panels": { + "editor": "Tablo düzenleyici", + "output": "Çıktı" + }, + "align": { + "left": "Sola hizala", + "center": "Ortala", + "right": "Sağa hizala" + }, + "headerPlaceholder": "Başlık", + "cellPlaceholder": "Hücre", + "removeRow": "Satırı kaldır", + "removeColumn": "Sütunu kaldır", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Kopyala", + "copied": "Kopyalandı" + }, + "TimezoneConverter": { + "title": "Saat Dilimi Dönüştürücü", + "subtitle": "Toplantı planlaması için bir anı dünya genelinde dönüştürün.", + "dateTimeLabel": "Tarih ve saat", + "now": "Şimdi", + "sourceZoneLabel": "Kaynak saat dilimi", + "selectZone": "Dilim seç", + "searchZone": "Saat dilimlerinde ara...", + "noZone": "Saat dilimi bulunamadı.", + "scrubLabel": "Zamanı kaydır", + "reset": "Sıfırla", + "targetsLabel": "Hedef dilimler", + "addZone": "Dilim ekle", + "remove": "Kaldır", + "localBadge": "Yerel", + "invalidDate": "Geçerli bir tarih ve saat girin.", + "bands": { + "work": "Çalışma saatleri (09:00–18:00)", + "edge": "Sınır saatleri (erken / akşam)", + "night": "Gece" + } + }, + "TokenCounter": { + "title": "LLM Token Sayacı", + "subtitle": "Token'ları sayın, parçaları görselleştirin ve API maliyetini tahmin edin.", + "inputLabel": "Metin", + "sample": "Örnek", + "clear": "Temizle", + "placeholder": "Token saymak için metin, kod veya JSON yapıştırın...", + "encoding": "Kodlama", + "loading": "Tokenizer yükleniyor...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o-series)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Token", + "characters": "Karakter", + "words": "Kelime", + "ratio": "Token/kelime", + "ratioSub": "yaklaşık" + }, + "claudeRow": "Claude (yaklaşık)", + "estimate": "tahmin", + "viz": { + "title": "Token görselleştirme", + "toggle": "Göster", + "hint": "Her token'ı bir parça olarak renklendirmek için değiştirin.", + "empty": "Token'ları görselleştirmek için metin girin.", + "truncated": "{total} token'ın ilk {limit} tanesi gösteriliyor." + }, + "cost": { + "title": "Maliyet hesaplayıcı", + "disclaimer": "düzenlenebilir varsayılanlar — güncel fiyatlandırmayı doğrulayın", + "model": "Model", + "expectedOutput": "Beklenen çıktı token'ları", + "customInput": "Giriş $/MTok", + "customOutput": "Çıkış $/MTok", + "inputCost": "Giriş maliyeti", + "outputCost": "Çıkış maliyeti", + "total": "Toplam", + "per1000": "1000 çağrı başına" + } + }, + "WebhookTester": { + "title": "Webhook Test Aracı", + "subtitle": "Geçici bir URL oluşturun ve gelen webhook isteklerini canlı inceleyin.", + "intro": { + "title": "İstekleri yakalamaya başlamak için bir webhook URL'si oluşturun", + "hint": "Her gelen isteği kaydeden, tahmin edilemez bir URL alacaksınız. 24 saat sonra süresi dolar." + }, + "createBtn": "Webhook URL'si oluştur", + "creating": "Oluşturuluyor…", + "urlLabel": "Webhook URL'niz", + "expiresIn": "Süre bitişi: {countdown}", + "copyUrlBtn": "URL kopyala", + "copied": "Kopyalandı", + "clearBtn": "Temizle", + "newBinBtn": "Yeni URL", + "requestsTitle": "İstekler", + "selectPrompt": "Ayrıntılarını incelemek için bir istek seçin.", + "expiredNotice": "Bu webhook URL'sinin süresi doldu. Test etmeye devam etmek için yeni bir tane oluşturun.", + "base64Badge": "base64", + "truncatedBadge": "kırpıldı", + "empty": { + "title": "İstekler bekleniyor…", + "hint": "URL'nize bir istek gönderin ve anında burada görünsün. Bunu deneyin:" + }, + "detail": { + "receivedAt": "Alındı", + "source": "Kaynak IP", + "contentType": "Content-Type", + "query": "Sorgu parametreleri", + "headers": "Başlıklar", + "body": "Gövde", + "copyBody": "Gövdeyi kopyala", + "emptyBody": "Gövde yok" + }, + "errors": { + "createFailed": "Webhook URL'si oluşturulamadı. Lütfen tekrar deneyin.", + "clearFailed": "İstekler temizlenemedi. Lütfen tekrar deneyin." + } + }, + "WebsocketTester": { + "title": "WebSocket Test Aracı", + "subtitle": "Bir WebSocket uç noktasına bağlanın ve mesajları canlı gönderin, alın ve inceleyin", + "urlLabel": "Uç nokta URL'si", + "protocolsLabel": "Alt protokoller", + "protocolsPlaceholder": "isteğe bağlı, virgülle ayrılmış", + "connect": "Bağlan", + "disconnect": "Bağlantıyı kes", + "status": { + "disconnected": "Bağlı değil", + "connecting": "Bağlanıyor", + "connected": "Bağlandı", + "error": "Hata" + }, + "messages": { + "connecting": "{url} adresine bağlanılıyor…", + "connected": "Bağlantı kuruldu", + "connectedWithProtocol": "Bağlantı kuruldu (protokol: {protocol})", + "invalidUrl": "Geçersiz URL — ws:// veya wss:// ile başlamalı", + "connectFailed": "Bağlantı açılamadı", + "socketError": "Bir bağlantı hatası oluştu", + "notConnected": "Bağlı değil — önce bağlanın", + "binaryMessage": "İkili bir mesaj alındı" + }, + "log": { + "title": "Mesaj Günlüğü", + "empty": "Henüz mesaj yok. Bağlanın ve göndermeye başlayın.", + "copyAll": "Tümünü kopyala", + "download": ".txt olarak indir", + "clear": "Günlüğü temizle", + "prettyPrint": "JSON'ı biçimlendir", + "showRaw": "Ham metni göster" + }, + "composer": { + "placeholder": "Gönderilecek bir mesaj yazın…", + "send": "Gönder", + "formatJson": "JSON olarak biçimlendir", + "invalidJson": "Mesaj geçerli JSON değil", + "hint": "Göndermek için Cmd/Ctrl+Enter" + }, + "ping": { + "label": "Otomatik ping", + "seconds": "sn", + "intervalTitle": "Saniye cinsinden ping aralığı", + "payloadPlaceholder": "ping yükü" + } + }, + "WhoisLookup": { + "title": "Whois Sorgulama", + "subtitle": "Herhangi bir alan adı veya IP adresinin kayıt bilgilerini RDAP üzerinden sorgulayın", + "placeholder": "example.com veya 8.8.8.8", + "lookupBtn": "Sorgula", + "looking": "Sorgulanıyor…", + "refreshBtn": "Yenile", + "tryLabel": "Deneyin:", + "copy": "Kopyala", + "copied": "Kopyalandı", + "notAvailable": "Mevcut değil", + "summary": { + "registrar": "Kayıt kuruluşu", + "network": "Ağ" + }, + "dates": { + "created": "Oluşturuldu", + "updated": "Güncellendi", + "expires": "Süre bitişi" + }, + "sections": { + "status": "Durum", + "nameservers": "Ad sunucuları", + "contacts": "Kişiler", + "rawRdap": "Ham RDAP" + }, + "contacts": { + "roles": "Roller", + "name": "Ad", + "organization": "Kuruluş" + }, + "errors": { + "invalidTarget": "Geçerli bir alan adı veya IP adresi girin", + "lookupFailed": "Sorgulama başarısız (durum {status})" + } } } diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index 26aed165..e7333e7e 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Калькулятор Chmod", + "codeScreenshot": "Скриншот коду", + "cssGenerators": "Генератори CSS", + "exifViewer": "Перегляд EXIF", + "faviconGenerator": "Генератор favicon", + "jsonDiff": "Порівняння JSON", + "jsonpathPlayground": "Пісочниця JSONPath", + "keycodeInspector": "Інспектор клавіш", + "markdownTableGenerator": "Таблиця Markdown", + "timezoneConverter": "Конвертер часових поясів", + "tokenCounter": "Лічильник токенів", + "webhookTester": "Тестер вебхуків", + "websocketTester": "Тестер WebSocket", + "whoisLookup": "Whois-запит" }, "Help": { "title": "Довідка та документація", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Генератор і перевірка Bcrypt", + "description": "Хешуйте паролі за допомогою bcrypt і перевіряйте наявні хеші." + }, + "chmodCalculator": { + "title": "Калькулятор Chmod", + "description": "Перетворюйте права Unix між вісімковою, символьною формою та прапорцями." + }, + "codeScreenshot": { + "title": "Скриншот коду", + "description": "Перетворюйте вихідний код на красиві зображення для публікації." + }, + "cssGenerators": { + "title": "Генератори CSS", + "description": "Генератори box-shadow, border-radius та адаптивного clamp()." + }, + "exifViewer": { + "title": "Перегляд і видалення EXIF", + "description": "Переглядайте та видаляйте метадані EXIF з фото локально." + }, + "faviconGenerator": { + "title": "Генератор favicon", + "description": "Створюйте favicon, іконки застосунків і фрагменти маніфесту." + }, + "jsonDiff": { + "title": "Порівняння JSON", + "description": "Семантичне порівняння двох документів JSON з урахуванням структури." + }, + "jsonpathPlayground": { + "title": "Пісочниця JSONPath", + "description": "Запитуйте JSON за допомогою JSONPath або JMESPath і дивіться результати наживо." + }, + "keycodeInspector": { + "title": "Інспектор клавіш", + "description": "Натисніть клавішу, щоб побачити її коди події JavaScript і модифікатори." + }, + "markdownTableGenerator": { + "title": "Генератор таблиць Markdown", + "description": "Створюйте таблиці Markdown та HTML у табличному редакторі." + }, + "timezoneConverter": { + "title": "Конвертер часових поясів", + "description": "Перетворюйте час між поясами з підказками про зміну дня та робочі години." + }, + "tokenCounter": { + "title": "Лічильник токенів LLM", + "description": "Рахуйте токени LLM, візуалізуйте чанки та оцінюйте вартість API." + }, + "webhookTester": { + "title": "Тестер вебхуків", + "description": "Захоплюйте та досліджуйте вхідні запити вебхуків у реальному часі." + }, + "websocketTester": { + "title": "Тестер WebSocket", + "description": "Підключайтеся до ендпоінтів WebSocket і обмінюйтеся повідомленнями в реальному часі." + }, + "whoisLookup": { + "title": "Whois-запит", + "description": "Дізнавайтеся дані реєстрації доменів та IP через RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Генератор і перевірка Bcrypt", + "subtitle": "Хешуйте паролі за допомогою bcrypt і перевіряйте хеші — усе залишається у вашому браузері", + "tabs": { + "hash": "Хеш", + "verify": "Перевірка" + }, + "showPassword": "Показати пароль", + "hidePassword": "Приховати пароль", + "copy": "Копіювати", + "copied": "Скопійовано", + "hash": { + "passwordLabel": "Пароль", + "passwordPlaceholder": "Введіть пароль для хешування…", + "costLabel": "Фактор вартості", + "costHint": "Вищий фактор подвоює роботу на кожному кроці. 10-12 — поширений вибір для продакшену.", + "slowWarning": "Фактори вартості 13 і вище можуть обчислюватися кілька секунд.", + "generate": "Створити хеш", + "generating": "Хешування…", + "resultLabel": "Хеш bcrypt", + "timeTaken": "{ms} мс", + "anatomy": { + "title": "Будова хешу", + "algorithm": "алгоритм", + "cost": "вартість", + "salt": "сіль (22 символи)", + "hash": "хеш (31 символ)" + } + }, + "verify": { + "passwordLabel": "Пароль", + "passwordPlaceholder": "Введіть пароль для перевірки…", + "hashLabel": "Хеш bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Введіть пароль і хеш bcrypt, щоб порівняти їх.", + "invalidHash": "Це не схоже на коректний хеш bcrypt.", + "checking": "Порівняння…", + "match": "Збігається — пароль дає цей хеш.", + "noMatch": "Не збігається — пароль не дає цей хеш." + } + }, + "ChmodCalculator": { + "title": "Калькулятор Chmod", + "subtitle": "Перетворюйте права файлів Unix між прапорцями, вісімковою та символьною формою", + "grid": { + "title": "Права", + "read": "Читання", + "write": "Запис", + "execute": "Виконання", + "owner": "Власник", + "group": "Група", + "others": "Інші" + }, + "special": { + "title": "Спеціальні біти", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Вісімкова", + "placeholder": "755 або 4755", + "invalid": "Введіть 3 або 4 вісімкові цифри (0-7)." + }, + "symbolic": { + "label": "Символьна", + "placeholder": "rwxr-xr-x", + "invalid": "Введіть 9 символів на кшталт rwxr-xr-x (дозволені s/S, t/T)." + }, + "output": { + "title": "Команда", + "copy": "Копіювати", + "copied": "Скопійовано" + }, + "explain": { + "line": "{cls} може {perms}.", + "none": "{cls} не має прав.", + "setuid": "Setuid: файл виконується з привілеями свого власника.", + "setgid": "Setgid: файл виконується з привілеями своєї групи (нові файли в каталозі успадковують його групу).", + "sticky": "Sticky: лише власник файлу може видалити або перейменувати його в цьому каталозі." + }, + "presets": { + "title": "Поширені пресети", + "octal": "Вісімкова", + "permissions": "Права", + "description": "Типове застосування", + "p644": "Звичайні файли: власник пише, усі читають.", + "p755": "Виконувані файли та каталоги: усі можуть читати й заходити.", + "p600": "Приватні файли: лише власник читає й пише.", + "p700": "Приватні виконувані файли та каталоги: лише власник.", + "p664": "Файли, доступні групі для запису.", + "p775": "Каталоги та виконувані файли, доступні групі для запису.", + "p400": "Лише читання для власника (наприклад, приватні ключі SSH)." + } + }, + "CodeScreenshot": { + "title": "Скриншот коду", + "subtitle": "Перетворюйте код на красиві зображення для публікації", + "languageLabel": "Мова", + "autoDetect": "Автовизначення", + "backgroundLabel": "Фон", + "paddingLabel": "Відступ", + "filenameLabel": "Ім'я файлу", + "filenamePlaceholder": "example.ts", + "windowLabel": "Крапки вікна", + "themeLabel": "Тема вікна", + "themeDark": "Темна", + "themeLight": "Світла", + "scaleLabel": "Масштаб експорту", + "copyImage": "Копіювати зображення", + "copied": "Скопійовано", + "exportPng": "Експорт PNG", + "inputLabel": "Код", + "detected": "Визначено: {language}", + "clear": "Очистити", + "placeholder": "Вставте ваш код сюди…", + "previewLabel": "Перегляд", + "emptyPreview": "Тут з'явиться перегляд вашого коду", + "exportFailed": "Не вдалося експортувати зображення", + "copyUnsupported": "Копіювання зображень не підтримується в цьому браузері", + "copyFailed": "Не вдалося скопіювати зображення", + "backgrounds": { + "ocean": "Океан", + "sunset": "Захід сонця", + "candy": "Цукерка", + "forest": "Ліс", + "midnight": "Північ", + "gold": "Золото", + "aurora": "Аврора", + "slate": "Сланець", + "transparent": "Прозорий" + } + }, + "CssGenerators": { + "title": "Генератори CSS", + "subtitle": "Box-shadow, border-radius та адаптивний clamp() — копіюйте CSS.", + "preview": "Перегляд", + "output": "CSS", + "copy": "Копіювати", + "copied": "Скопійовано", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "Адаптивний clamp()" + }, + "boxShadow": { + "presets": "Пресети", + "layer": "Шар", + "addLayer": "Додати шар", + "removeLayer": "Видалити шар", + "x": "X", + "y": "Y", + "blur": "Розмиття", + "spread": "Розтягнення", + "color": "Колір", + "alpha": "Альфа", + "inset": "Внутрішня", + "presetNames": { + "subtleCard": "Легка картка", + "elevated": "Піднята", + "hardOffset": "Жорстке зміщення", + "neonGlow": "Неонове сяйво", + "inner": "Внутрішня", + "layeredSmooth": "М'які шари" + } + }, + "borderRadius": { + "modeLabel": "Режим", + "modes": { + "simple": "По кутах", + "blob": "Органічна крапля" + }, + "unit": "Одиниця", + "linkCorners": "Зв'язати кути", + "randomize": "Випадково", + "horizontal": "Горизонтальні радіуси", + "vertical": "Вертикальні радіуси", + "corners": { + "tl": "Верхній лівий", + "tr": "Верхній правий", + "br": "Нижній правий", + "bl": "Нижній лівий" + } + }, + "clamp": { + "minFont": "Мін. шрифт (px)", + "maxFont": "Макс. шрифт (px)", + "minViewport": "Мін. viewport (px)", + "maxViewport": "Макс. viewport (px)", + "rootFont": "Кореневий шрифт (px)", + "viewportWidth": "Змодельований viewport", + "computedSize": "Обчислений розмір", + "slope": "Нахил", + "intercept": "Зміщення", + "previewText": "Швидка руда лисиця", + "invalid": "Введіть коректні та різні межі viewport." + } + }, + "ExifViewer": { + "title": "Перегляд і видалення EXIF", + "subtitle": "Досліджуйте метадані фото та видаляйте їх у вашому браузері", + "dropTitle": "Перетягніть зображення сюди", + "dropHint": "або натисніть для вибору — ваше зображення ніколи не залишає ваш пристрій", + "browse": "Вибрати зображення", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Читання метаданих…", + "parseFailed": "Не вдалося прочитати це зображення", + "clear": "Видалити зображення", + "gpsWarning": "Містить GPS-координати", + "openMap": "Показати на OpenStreetMap", + "noMetadata": "У цьому зображенні не знайдено вбудованих метаданих.", + "facts": { + "name": "Ім'я", + "type": "Тип", + "dimensions": "Розміри", + "size": "Розмір" + }, + "groups": { + "camera": "Камера", + "exposure": "Експозиція", + "timestamps": "Мітки часу", + "gps": "GPS-координати", + "software": "ПЗ та інше" + }, + "gpsCard": { + "latitude": "Широта", + "longitude": "Довгота", + "altitude": "Висота" + }, + "raw": { + "title": "Усі метадані", + "copyJson": "Копіювати JSON", + "copied": "Скопійовано" + }, + "remove": { + "title": "Видалити метадані", + "jpegDescription": "Без втрат видаліть EXIF, XMP, IPTC і коментарі без повторного стиснення зображення.", + "reencodeDescription": "Перекодуйте зображення, щоб видалити вбудовані метадані. При цьому пікселі стискаються повторно.", + "keepIcc": "Зберегти колірний профіль (ICC)", + "stripButton": "Видалити метадані", + "reencodeButton": "Перекодувати й видалити", + "unsupported": "Видалення метаданих не підтримується для цього типу файлу.", + "failed": "Не вдалося видалити метадані", + "before": "До", + "after": "Після", + "download": "Завантажити очищене зображення" + } + }, + "FaviconGenerator": { + "title": "Генератор favicon", + "subtitle": "Створюйте favicon та іконки застосунків з одного зображення", + "dropTitle": "Перетягніть зображення сюди", + "dropHint": "PNG, JPEG або SVG — натисніть для вибору", + "browse": "Вибрати зображення", + "clear": "Видалити зображення", + "backgroundLabel": "Фон", + "transparent": "Прозорий", + "solidColor": "Суцільний", + "cornerLabel": "Форма кутів", + "cornerNone": "Квадрат", + "cornerRounded": "Заокруглений", + "cornerCircle": "Коло", + "maskPaddingLabel": "Відступ maskable", + "maskable": "maskable", + "previewLabel": "Перегляд", + "copy": "Копіювати", + "headSnippet": "Фрагмент для head HTML", + "downloadIco": "Завантажити favicon.ico", + "downloadZip": "Завантажити все (.zip)" + }, + "JsonDiff": { + "title": "Порівняння JSON", + "subtitle": "Семантичне порівняння двох документів JSON з урахуванням структури — порядок ключів і форматування ігноруються", + "options": { + "ignoreArrayOrder": "Ігнорувати порядок масивів" + }, + "loadBothSamples": "Завантажити приклади", + "sample": "Приклад", + "format": "Формат", + "clear": "Очистити", + "formatError": "Не вдається відформатувати: введення не є коректним JSON", + "placeholder": "Вставте або введіть JSON тут…", + "panels": { + "left": "Оригінал (ліворуч)", + "right": "Змінений (праворуч)", + "results": "Відмінності" + }, + "summary": { + "added": "{count} додано", + "removed": "{count} вилучено", + "changed": "{count} змінено" + }, + "types": { + "added": "Додано", + "removed": "Вилучено", + "changed": "Змінено" + }, + "copyChanges": "Копіювати як JSON", + "copied": "Скопійовано", + "empty": "Вставте JSON в обидві панелі, щоб побачити їхні відмінності.", + "identical": "Обидва документи семантично ідентичні." + }, + "JsonpathPlayground": { + "title": "Пісочниця JSONPath", + "subtitle": "Запитуйте документи JSON за допомогою JSONPath або JMESPath і дивіться результати наживо", + "engineLabel": "Рушій", + "queryLabel": "Запит", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Шпаргалка", + "cheats": { + "jpAllTitles": "Усі назви книг", + "jpAllAuthors": "Усі автори будь-де в документі", + "jpFirstBook": "Перша книга", + "jpLastBook": "Остання книга", + "jpSlice": "Перші дві книги (зріз)", + "jpFilterPrice": "Книги дешевші за 40", + "jpFilterInStock": "Назви книг у наявності", + "jpRecursive": "Усі значення рекурсивно", + "jmAllTitles": "Усі назви книг", + "jmFirstAuthor": "Автор першої книги", + "jmLastBook": "Остання книга", + "jmSlice": "Перші дві книги (зріз)", + "jmFilterPrice": "Назви книг дешевших за 40", + "jmCount": "Підрахунок книг у наявності", + "jmProjection": "Перетворити кожну книгу на новий об'єкт", + "jmSort": "Назви, відсортовані за ціною" + }, + "panels": { + "input": "Введення JSON", + "results": "Результати" + }, + "sample": "Приклад", + "format": "Формат", + "clear": "Очистити", + "formatError": "Не вдається відформатувати: введення не є коректним JSON", + "placeholder": "Вставте або введіть JSON тут…", + "outputPlaceholder": "Збіги з'являться тут…", + "matchCount": "{count, plural, one {# збіг} few {# збіги} many {# збігів} other {# збігу}}", + "copy": "Копіювати", + "copied": "Скопійовано" + }, + "KeycodeInspector": { + "title": "Інспектор клавіш", + "subtitle": "Натисніть будь-яку клавішу, щоб дослідити її подію клавіатури JavaScript", + "capture": { + "prompt": "Натисніть будь-яку клавішу…", + "ready": "Слухаю — натисніть будь-яку клавішу на клавіатурі", + "clickToFocus": "Натисніть тут для фокуса, потім натисніть будь-яку клавішу" + }, + "hero": { + "spaceKey": "(Пробіл)", + "keyLabel": "event.key", + "repeat": "повтор" + }, + "copyJson": "Копіювати JSON", + "copiedJson": "JSON події скопійовано до буфера обміну", + "deprecated": "застаріле", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Стандартне", + "left": "Ліворуч", + "right": "Праворуч", + "numpad": "Цифровий блок", + "unknown": "Невідоме" + }, + "history": { + "title": "Історія", + "clear": "Очистити історію", + "empty": "Натискання клавіш з'являться тут." + } + }, + "MarkdownTableGenerator": { + "title": "Генератор таблиць Markdown", + "subtitle": "Редагуйте сітку, вставляйте з таблиці та копіюйте гарно вирівняну таблицю Markdown або HTML", + "addRow": "Додати рядок", + "addColumn": "Додати стовпець", + "importLabel": "Імпорт", + "importPlaceholder": "Вставте TSV (з Excel / Google Sheets) або CSV тут…", + "importButton": "Імпортувати в сітку", + "pasteClipboard": "Вставити з буфера обміну", + "importEmpty": "Немає що імпортувати — спершу вставте кілька рядків", + "clipboardError": "Не вдалося прочитати з буфера обміну", + "clear": "Очистити", + "panels": { + "editor": "Редактор таблиці", + "output": "Результат" + }, + "align": { + "left": "Вирівняти ліворуч", + "center": "Вирівняти по центру", + "right": "Вирівняти праворуч" + }, + "headerPlaceholder": "Заголовок", + "cellPlaceholder": "Комірка", + "removeRow": "Видалити рядок", + "removeColumn": "Видалити стовпець", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Копіювати", + "copied": "Скопійовано" + }, + "TimezoneConverter": { + "title": "Конвертер часових поясів", + "subtitle": "Перетворюйте один момент по всьому світу для планування зустрічей.", + "dateTimeLabel": "Дата й час", + "now": "Зараз", + "sourceZoneLabel": "Вихідний часовий пояс", + "selectZone": "Виберіть пояс", + "searchZone": "Пошук часових поясів...", + "noZone": "Часовий пояс не знайдено.", + "scrubLabel": "Зсунути час", + "reset": "Скинути", + "targetsLabel": "Цільові пояси", + "addZone": "Додати пояс", + "remove": "Видалити", + "localBadge": "Локальний", + "invalidDate": "Введіть коректні дату й час.", + "bands": { + "work": "Робочі години (09:00–18:00)", + "edge": "Крайні години (ранній ранок / вечір)", + "night": "Ніч" + } + }, + "TokenCounter": { + "title": "Лічильник токенів LLM", + "subtitle": "Рахуйте токени, візуалізуйте чанки та оцінюйте вартість API.", + "inputLabel": "Текст", + "sample": "Приклад", + "clear": "Очистити", + "placeholder": "Вставте текст, код або JSON для підрахунку токенів...", + "encoding": "Кодування", + "loading": "Завантаження токенізатора...", + "encodings": { + "o200k": "o200k_base (GPT-4o / серія o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Токени", + "characters": "Символи", + "words": "Слова", + "ratio": "Токенів/слово", + "ratioSub": "прибл." + }, + "claudeRow": "Claude (приблизно)", + "estimate": "оцінка", + "viz": { + "title": "Візуалізація токенів", + "toggle": "Показати", + "hint": "Увімкніть, щоб розфарбувати кожен токен як чанк.", + "empty": "Введіть текст для візуалізації токенів.", + "truncated": "Показано перші {limit} з {total} токенів." + }, + "cost": { + "title": "Калькулятор вартості", + "disclaimer": "редаговані значення за замовчуванням — перевірте актуальні ціни", + "model": "Модель", + "expectedOutput": "Очікувані токени виводу", + "customInput": "Введення $/MTok", + "customOutput": "Вивід $/MTok", + "inputCost": "Вартість введення", + "outputCost": "Вартість виводу", + "total": "Разом", + "per1000": "За 1000 викликів" + } + }, + "WebhookTester": { + "title": "Тестер вебхуків", + "subtitle": "Створіть тимчасовий URL і досліджуйте вхідні запити вебхуків наживо.", + "intro": { + "title": "Створіть URL вебхука, щоб почати захоплення запитів", + "hint": "Ви отримаєте непередбачуваний URL, який записує кожен вхідний запит. Він спливає через 24 години." + }, + "createBtn": "Створити URL вебхука", + "creating": "Створення…", + "urlLabel": "Ваш URL вебхука", + "expiresIn": "Спливає через {countdown}", + "copyUrlBtn": "Копіювати URL", + "copied": "Скопійовано", + "clearBtn": "Очистити", + "newBinBtn": "Новий URL", + "requestsTitle": "Запити", + "selectPrompt": "Виберіть запит, щоб дослідити його деталі.", + "expiredNotice": "Цей URL вебхука сплив. Створіть новий, щоб продовжити тестування.", + "base64Badge": "base64", + "truncatedBadge": "обрізано", + "empty": { + "title": "Очікування запитів…", + "hint": "Надішліть запит на ваш URL, і він з'явиться тут миттєво. Спробуйте так:" + }, + "detail": { + "receivedAt": "Отримано", + "source": "IP джерела", + "contentType": "Content-Type", + "query": "Параметри query", + "headers": "Заголовки", + "body": "Тіло", + "copyBody": "Копіювати тіло", + "emptyBody": "Немає тіла" + }, + "errors": { + "createFailed": "Не вдалося створити URL вебхука. Спробуйте ще раз.", + "clearFailed": "Не вдалося очистити запити. Спробуйте ще раз." + } + }, + "WebsocketTester": { + "title": "Тестер WebSocket", + "subtitle": "Підключайтеся до ендпоінта WebSocket і надсилайте, отримуйте та досліджуйте повідомлення наживо", + "urlLabel": "URL ендпоінта", + "protocolsLabel": "Підпротоколи", + "protocolsPlaceholder": "необов'язково, через кому", + "connect": "Підключити", + "disconnect": "Відключити", + "status": { + "disconnected": "Відключено", + "connecting": "Підключення", + "connected": "Підключено", + "error": "Помилка" + }, + "messages": { + "connecting": "Підключення до {url}…", + "connected": "З'єднання встановлено", + "connectedWithProtocol": "З'єднання встановлено (протокол: {protocol})", + "invalidUrl": "Некоректний URL — має починатися з ws:// або wss://", + "connectFailed": "Не вдалося відкрити з'єднання", + "socketError": "Сталася помилка з'єднання", + "notConnected": "Не підключено — спершу підключіться", + "binaryMessage": "Отримано двійкове повідомлення" + }, + "log": { + "title": "Журнал повідомлень", + "empty": "Повідомлень поки немає. Підключіться та почніть надсилати.", + "copyAll": "Копіювати все", + "download": "Завантажити як .txt", + "clear": "Очистити журнал", + "prettyPrint": "Форматувати JSON", + "showRaw": "Показати необроблений текст" + }, + "composer": { + "placeholder": "Введіть повідомлення для надсилання…", + "send": "Надіслати", + "formatJson": "Форматувати як JSON", + "invalidJson": "Повідомлення не є коректним JSON", + "hint": "Cmd/Ctrl+Enter для надсилання" + }, + "ping": { + "label": "Авто-пінг", + "seconds": "с", + "intervalTitle": "Інтервал пінгу в секундах", + "payloadPlaceholder": "payload пінгу" + } + }, + "WhoisLookup": { + "title": "Whois-запит", + "subtitle": "Дізнавайтеся дані реєстрації будь-якого домену або IP-адреси через RDAP", + "placeholder": "example.com або 8.8.8.8", + "lookupBtn": "Запитати", + "looking": "Запит…", + "refreshBtn": "Оновити", + "tryLabel": "Спробуйте:", + "copy": "Копіювати", + "copied": "Скопійовано", + "notAvailable": "Недоступно", + "summary": { + "registrar": "Реєстратор", + "network": "Мережа" + }, + "dates": { + "created": "Створено", + "updated": "Оновлено", + "expires": "Спливає" + }, + "sections": { + "status": "Статус", + "nameservers": "Сервери імен", + "contacts": "Контакти", + "rawRdap": "Необроблений RDAP" + }, + "contacts": { + "roles": "Ролі", + "name": "Ім'я", + "organization": "Організація" + }, + "errors": { + "invalidTarget": "Введіть коректне доменне ім'я або IP-адресу", + "lookupFailed": "Запит не вдався (статус {status})" + } } } diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 9f332fdf..bfea25a5 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Máy tính Chmod", + "codeScreenshot": "Ảnh chụp mã", + "cssGenerators": "Trình tạo CSS", + "exifViewer": "Trình xem EXIF", + "faviconGenerator": "Trình tạo Favicon", + "jsonDiff": "So sánh JSON", + "jsonpathPlayground": "Sân chơi JSONPath", + "keycodeInspector": "Trình kiểm tra mã phím", + "markdownTableGenerator": "Bảng Markdown", + "timezoneConverter": "Chuyển đổi múi giờ", + "tokenCounter": "Bộ đếm token", + "webhookTester": "Trình kiểm tra Webhook", + "websocketTester": "Trình kiểm tra WebSocket", + "whoisLookup": "Tra cứu Whois" }, "Help": { "title": "Trợ giúp & tài liệu", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Trình tạo & kiểm tra Bcrypt", + "description": "Băm mật khẩu bằng bcrypt và xác minh các băm hiện có." + }, + "chmodCalculator": { + "title": "Máy tính Chmod", + "description": "Chuyển đổi quyền Unix giữa bát phân, ký hiệu và hộp kiểm." + }, + "codeScreenshot": { + "title": "Ảnh chụp mã", + "description": "Biến mã nguồn thành hình ảnh đẹp, dễ chia sẻ." + }, + "cssGenerators": { + "title": "Trình tạo CSS", + "description": "Trình tạo box-shadow, border-radius và clamp() linh hoạt." + }, + "exifViewer": { + "title": "Trình xem & xóa EXIF", + "description": "Kiểm tra và xóa metadata EXIF của ảnh ngay trên máy." + }, + "faviconGenerator": { + "title": "Trình tạo Favicon", + "description": "Tạo favicon, biểu tượng ứng dụng và đoạn mã manifest." + }, + "jsonDiff": { + "title": "So sánh JSON", + "description": "So sánh hai tài liệu JSON theo ngữ nghĩa và cấu trúc." + }, + "jsonpathPlayground": { + "title": "Sân chơi JSONPath", + "description": "Truy vấn JSON bằng JSONPath hoặc JMESPath và xem kết quả trực tiếp." + }, + "keycodeInspector": { + "title": "Trình kiểm tra mã phím", + "description": "Nhấn một phím để xem mã sự kiện JavaScript và các phím bổ trợ." + }, + "markdownTableGenerator": { + "title": "Trình tạo bảng Markdown", + "description": "Tạo bảng Markdown và HTML trong trình chỉnh sửa dạng lưới như bảng tính." + }, + "timezoneConverter": { + "title": "Chuyển đổi múi giờ", + "description": "Chuyển đổi một thời điểm qua nhiều múi giờ kèm gợi ý chênh lệch ngày và giờ làm việc." + }, + "tokenCounter": { + "title": "Bộ đếm token LLM", + "description": "Đếm token LLM, trực quan hóa các khối và ước tính chi phí API." + }, + "webhookTester": { + "title": "Trình kiểm tra Webhook", + "description": "Ghi lại và kiểm tra các yêu cầu webhook đến theo thời gian thực." + }, + "websocketTester": { + "title": "Trình kiểm tra WebSocket", + "description": "Kết nối tới các điểm cuối WebSocket và trao đổi tin nhắn theo thời gian thực." + }, + "whoisLookup": { + "title": "Tra cứu Whois", + "description": "Tra cứu thông tin đăng ký tên miền và IP qua RDAP." } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Trình tạo & kiểm tra Bcrypt", + "subtitle": "Băm mật khẩu bằng bcrypt và xác minh băm — mọi thứ diễn ra trong trình duyệt của bạn", + "tabs": { + "hash": "Băm", + "verify": "Xác minh" + }, + "showPassword": "Hiện mật khẩu", + "hidePassword": "Ẩn mật khẩu", + "copy": "Sao chép", + "copied": "Đã sao chép", + "hash": { + "passwordLabel": "Mật khẩu", + "passwordPlaceholder": "Nhập mật khẩu cần băm…", + "costLabel": "Hệ số chi phí", + "costHint": "Chi phí càng cao thì khối lượng tính toán mỗi bước càng tăng gấp đôi. 10-12 là lựa chọn phổ biến cho môi trường sản xuất.", + "slowWarning": "Hệ số chi phí từ 13 trở lên có thể mất vài giây để tính toán.", + "generate": "Tạo băm", + "generating": "Đang băm…", + "resultLabel": "Băm Bcrypt", + "timeTaken": "{ms} ms", + "anatomy": { + "title": "Cấu trúc băm", + "algorithm": "thuật toán", + "cost": "chi phí", + "salt": "salt (22 ký tự)", + "hash": "băm (31 ký tự)" + } + }, + "verify": { + "passwordLabel": "Mật khẩu", + "passwordPlaceholder": "Nhập mật khẩu cần kiểm tra…", + "hashLabel": "Băm Bcrypt", + "hashPlaceholder": "$2b$10$…", + "idle": "Nhập mật khẩu và một băm bcrypt để so sánh chúng.", + "invalidHash": "Đây có vẻ không phải là một băm bcrypt hợp lệ.", + "checking": "Đang so sánh…", + "match": "Khớp — mật khẩu này tạo ra băm này.", + "noMatch": "Không khớp — mật khẩu này không tạo ra băm này." + } + }, + "ChmodCalculator": { + "title": "Máy tính Chmod", + "subtitle": "Chuyển đổi quyền tệp Unix giữa hộp kiểm, bát phân và ký hiệu tượng trưng", + "grid": { + "title": "Quyền", + "read": "Đọc", + "write": "Ghi", + "execute": "Thực thi", + "owner": "Chủ sở hữu", + "group": "Nhóm", + "others": "Người khác" + }, + "special": { + "title": "Bit đặc biệt", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "Bát phân", + "placeholder": "755 hoặc 4755", + "invalid": "Nhập 3 hoặc 4 chữ số bát phân (0-7)." + }, + "symbolic": { + "label": "Ký hiệu", + "placeholder": "rwxr-xr-x", + "invalid": "Nhập 9 ký tự như rwxr-xr-x (cho phép s/S, t/T)." + }, + "output": { + "title": "Lệnh", + "copy": "Sao chép", + "copied": "Đã sao chép" + }, + "explain": { + "line": "{cls} có thể {perms}.", + "none": "{cls} không có quyền nào.", + "setuid": "Setuid: tệp chạy với đặc quyền của chủ sở hữu.", + "setgid": "Setgid: tệp chạy với đặc quyền của nhóm (các tệp mới trong thư mục kế thừa nhóm của nó).", + "sticky": "Sticky: chỉ chủ sở hữu tệp mới có thể xóa hoặc đổi tên tệp trong thư mục này." + }, + "presets": { + "title": "Cài đặt sẵn phổ biến", + "octal": "Bát phân", + "permissions": "Quyền", + "description": "Công dụng điển hình", + "p644": "Tệp thông thường: chủ sở hữu ghi, mọi người đọc.", + "p755": "Tệp thực thi và thư mục: mọi người có thể đọc và truy cập.", + "p600": "Tệp riêng tư: chỉ chủ sở hữu đọc và ghi.", + "p700": "Tệp thực thi và thư mục riêng tư: chỉ chủ sở hữu.", + "p664": "Tệp cho phép nhóm ghi.", + "p775": "Thư mục và tệp thực thi cho phép nhóm ghi.", + "p400": "Chỉ đọc cho chủ sở hữu (ví dụ khóa riêng SSH)." + } + }, + "CodeScreenshot": { + "title": "Ảnh chụp mã", + "subtitle": "Biến mã thành hình ảnh đẹp, dễ chia sẻ", + "languageLabel": "Ngôn ngữ", + "autoDetect": "Tự động nhận diện", + "backgroundLabel": "Nền", + "paddingLabel": "Khoảng đệm", + "filenameLabel": "Tên tệp", + "filenamePlaceholder": "example.ts", + "windowLabel": "Chấm cửa sổ", + "themeLabel": "Chủ đề cửa sổ", + "themeDark": "Tối", + "themeLight": "Sáng", + "scaleLabel": "Tỷ lệ xuất", + "copyImage": "Sao chép hình ảnh", + "copied": "Đã sao chép", + "exportPng": "Xuất PNG", + "inputLabel": "Mã", + "detected": "Đã nhận diện: {language}", + "clear": "Xóa", + "placeholder": "Dán mã của bạn vào đây…", + "previewLabel": "Xem trước", + "emptyPreview": "Bản xem trước mã của bạn sẽ hiển thị ở đây", + "exportFailed": "Không xuất được hình ảnh", + "copyUnsupported": "Trình duyệt này không hỗ trợ sao chép hình ảnh", + "copyFailed": "Không sao chép được hình ảnh", + "backgrounds": { + "ocean": "Đại dương", + "sunset": "Hoàng hôn", + "candy": "Kẹo ngọt", + "forest": "Rừng cây", + "midnight": "Nửa đêm", + "gold": "Vàng kim", + "aurora": "Cực quang", + "slate": "Xám đá", + "transparent": "Trong suốt" + } + }, + "CssGenerators": { + "title": "Trình tạo CSS", + "subtitle": "Box-shadow, border-radius và clamp() linh hoạt — sao chép CSS.", + "preview": "Xem trước", + "output": "CSS", + "copy": "Sao chép", + "copied": "Đã sao chép", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "clamp() linh hoạt" + }, + "boxShadow": { + "presets": "Cài đặt sẵn", + "layer": "Lớp", + "addLayer": "Thêm lớp", + "removeLayer": "Xóa lớp", + "x": "X", + "y": "Y", + "blur": "Độ mờ", + "spread": "Độ lan", + "color": "Màu", + "alpha": "Độ trong", + "inset": "Đổ bóng trong", + "presetNames": { + "subtleCard": "Thẻ nhẹ nhàng", + "elevated": "Nổi lên", + "hardOffset": "Lệch cứng", + "neonGlow": "Ánh neon", + "inner": "Bóng trong", + "layeredSmooth": "Nhiều lớp mượt" + } + }, + "borderRadius": { + "modeLabel": "Chế độ", + "modes": { + "simple": "Theo từng góc", + "blob": "Khối hữu cơ" + }, + "unit": "Đơn vị", + "linkCorners": "Liên kết các góc", + "randomize": "Ngẫu nhiên", + "horizontal": "Bán kính ngang", + "vertical": "Bán kính dọc", + "corners": { + "tl": "Trên trái", + "tr": "Trên phải", + "br": "Dưới phải", + "bl": "Dưới trái" + } + }, + "clamp": { + "minFont": "Cỡ chữ tối thiểu (px)", + "maxFont": "Cỡ chữ tối đa (px)", + "minViewport": "Viewport tối thiểu (px)", + "maxViewport": "Viewport tối đa (px)", + "rootFont": "Cỡ chữ gốc (px)", + "viewportWidth": "Viewport mô phỏng", + "computedSize": "Kích thước tính toán", + "slope": "Độ dốc", + "intercept": "Giao điểm", + "previewText": "The quick brown fox", + "invalid": "Nhập các giới hạn viewport hợp lệ và khác nhau." + } + }, + "ExifViewer": { + "title": "Trình xem & xóa EXIF", + "subtitle": "Kiểm tra metadata của ảnh và xóa chúng ngay trong trình duyệt", + "dropTitle": "Thả một ảnh vào đây", + "dropHint": "hoặc nhấn để duyệt — ảnh của bạn không bao giờ rời khỏi thiết bị", + "browse": "Chọn ảnh", + "formats": "JPEG, PNG, WebP, HEIC, TIFF", + "parsing": "Đang đọc metadata…", + "parseFailed": "Không đọc được ảnh này", + "clear": "Xóa ảnh", + "gpsWarning": "Chứa vị trí GPS", + "openMap": "Xem trên OpenStreetMap", + "noMetadata": "Không tìm thấy metadata nhúng nào trong ảnh này.", + "facts": { + "name": "Tên", + "type": "Loại", + "dimensions": "Kích thước", + "size": "Dung lượng" + }, + "groups": { + "camera": "Máy ảnh", + "exposure": "Phơi sáng", + "timestamps": "Dấu thời gian", + "gps": "Vị trí GPS", + "software": "Phần mềm & khác" + }, + "gpsCard": { + "latitude": "Vĩ độ", + "longitude": "Kinh độ", + "altitude": "Độ cao" + }, + "raw": { + "title": "Toàn bộ metadata", + "copyJson": "Sao chép JSON", + "copied": "Đã sao chép" + }, + "remove": { + "title": "Xóa metadata", + "jpegDescription": "Xóa EXIF, XMP, IPTC và chú thích không mất dữ liệu mà không nén lại ảnh.", + "reencodeDescription": "Mã hóa lại ảnh để loại bỏ metadata nhúng. Thao tác này nén lại các điểm ảnh.", + "keepIcc": "Giữ hồ sơ màu (ICC)", + "stripButton": "Xóa metadata", + "reencodeButton": "Mã hóa lại & xóa", + "unsupported": "Không hỗ trợ xóa metadata cho loại tệp này.", + "failed": "Không xóa được metadata", + "before": "Trước", + "after": "Sau", + "download": "Tải ảnh đã làm sạch" + } + }, + "FaviconGenerator": { + "title": "Trình tạo Favicon", + "subtitle": "Tạo favicon và biểu tượng ứng dụng từ một ảnh", + "dropTitle": "Thả một ảnh vào đây", + "dropHint": "PNG, JPEG hoặc SVG — nhấn để duyệt", + "browse": "Chọn ảnh", + "clear": "Xóa ảnh", + "backgroundLabel": "Nền", + "transparent": "Trong suốt", + "solidColor": "Màu đặc", + "cornerLabel": "Hình dạng góc", + "cornerNone": "Vuông", + "cornerRounded": "Bo tròn", + "cornerCircle": "Hình tròn", + "maskPaddingLabel": "Khoảng đệm maskable", + "maskable": "maskable", + "previewLabel": "Xem trước", + "copy": "Sao chép", + "headSnippet": "Đoạn mã HTML head", + "downloadIco": "Tải favicon.ico", + "downloadZip": "Tải tất cả (.zip)" + }, + "JsonDiff": { + "title": "So sánh JSON", + "subtitle": "So sánh hai tài liệu JSON theo ngữ nghĩa và cấu trúc — bỏ qua thứ tự khóa và định dạng", + "options": { + "ignoreArrayOrder": "Bỏ qua thứ tự mảng" + }, + "loadBothSamples": "Tải ví dụ", + "sample": "Ví dụ", + "format": "Định dạng", + "clear": "Xóa", + "formatError": "Không thể định dạng: đầu vào không phải JSON hợp lệ", + "placeholder": "Dán hoặc nhập JSON vào đây…", + "panels": { + "left": "Bản gốc (trái)", + "right": "Bản sửa (phải)", + "results": "Khác biệt" + }, + "summary": { + "added": "{count} đã thêm", + "removed": "{count} đã xóa", + "changed": "{count} đã thay đổi" + }, + "types": { + "added": "Đã thêm", + "removed": "Đã xóa", + "changed": "Đã thay đổi" + }, + "copyChanges": "Sao chép dưới dạng JSON", + "copied": "Đã sao chép", + "empty": "Dán JSON vào cả hai ô để xem khác biệt của chúng.", + "identical": "Hai tài liệu giống nhau về mặt ngữ nghĩa." + }, + "JsonpathPlayground": { + "title": "Sân chơi JSONPath", + "subtitle": "Truy vấn các tài liệu JSON bằng JSONPath hoặc JMESPath và xem kết quả trực tiếp", + "engineLabel": "Bộ máy", + "queryLabel": "Truy vấn", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "Bảng tra cứu nhanh", + "cheats": { + "jpAllTitles": "Tất cả tựa sách", + "jpAllAuthors": "Mọi tác giả, ở bất kỳ đâu trong tài liệu", + "jpFirstBook": "Cuốn sách đầu tiên", + "jpLastBook": "Cuốn sách cuối cùng", + "jpSlice": "Hai cuốn đầu (slice)", + "jpFilterPrice": "Sách rẻ hơn 40", + "jpFilterInStock": "Tựa của các cuốn sách còn hàng", + "jpRecursive": "Tất cả giá trị, theo đệ quy", + "jmAllTitles": "Tất cả tựa sách", + "jmFirstAuthor": "Tác giả của cuốn sách đầu tiên", + "jmLastBook": "Cuốn sách cuối cùng", + "jmSlice": "Hai cuốn đầu (slice)", + "jmFilterPrice": "Tựa của các cuốn sách rẻ hơn 40", + "jmCount": "Đếm số sách còn hàng", + "jmProjection": "Định hình lại mỗi cuốn sách thành đối tượng mới", + "jmSort": "Tựa sách sắp xếp theo giá" + }, + "panels": { + "input": "Đầu vào JSON", + "results": "Kết quả" + }, + "sample": "Ví dụ", + "format": "Định dạng", + "clear": "Xóa", + "formatError": "Không thể định dạng: đầu vào không phải JSON hợp lệ", + "placeholder": "Dán hoặc nhập JSON vào đây…", + "outputPlaceholder": "Các kết quả khớp sẽ hiển thị ở đây…", + "matchCount": "{count, plural, one {# kết quả khớp} other {# kết quả khớp}}", + "copy": "Sao chép", + "copied": "Đã sao chép" + }, + "KeycodeInspector": { + "title": "Trình kiểm tra mã phím", + "subtitle": "Nhấn phím bất kỳ để kiểm tra sự kiện bàn phím JavaScript của nó", + "capture": { + "prompt": "Nhấn phím bất kỳ…", + "ready": "Đang lắng nghe — nhấn phím bất kỳ trên bàn phím", + "clickToFocus": "Nhấn vào đây để lấy tiêu điểm, rồi nhấn phím bất kỳ" + }, + "hero": { + "spaceKey": "(Dấu cách)", + "keyLabel": "event.key", + "repeat": "lặp lại" + }, + "copyJson": "Sao chép JSON", + "copiedJson": "Đã sao chép JSON sự kiện vào bộ nhớ tạm", + "deprecated": "không dùng nữa", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "Chuẩn", + "left": "Trái", + "right": "Phải", + "numpad": "Bàn phím số", + "unknown": "Không rõ" + }, + "history": { + "title": "Lịch sử", + "clear": "Xóa lịch sử", + "empty": "Các lần nhấn phím sẽ hiển thị ở đây." + } + }, + "MarkdownTableGenerator": { + "title": "Trình tạo bảng Markdown", + "subtitle": "Chỉnh sửa lưới, dán từ bảng tính và sao chép bảng Markdown hoặc HTML được căn chỉnh đẹp mắt", + "addRow": "Thêm hàng", + "addColumn": "Thêm cột", + "importLabel": "Nhập", + "importPlaceholder": "Dán TSV (từ Excel / Google Sheets) hoặc CSV vào đây…", + "importButton": "Nhập vào lưới", + "pasteClipboard": "Dán từ bộ nhớ tạm", + "importEmpty": "Không có gì để nhập — hãy dán một số hàng trước", + "clipboardError": "Không thể đọc từ bộ nhớ tạm", + "clear": "Xóa", + "panels": { + "editor": "Trình chỉnh sửa bảng", + "output": "Kết quả" + }, + "align": { + "left": "Căn trái", + "center": "Căn giữa", + "right": "Căn phải" + }, + "headerPlaceholder": "Tiêu đề", + "cellPlaceholder": "Ô", + "removeRow": "Xóa hàng", + "removeColumn": "Xóa cột", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "Sao chép", + "copied": "Đã sao chép" + }, + "TimezoneConverter": { + "title": "Chuyển đổi múi giờ", + "subtitle": "Chuyển đổi một thời điểm trên khắp thế giới để lên kế hoạch họp.", + "dateTimeLabel": "Ngày & giờ", + "now": "Bây giờ", + "sourceZoneLabel": "Múi giờ nguồn", + "selectZone": "Chọn múi giờ", + "searchZone": "Tìm múi giờ...", + "noZone": "Không tìm thấy múi giờ.", + "scrubLabel": "Dịch chuyển thời gian", + "reset": "Đặt lại", + "targetsLabel": "Múi giờ đích", + "addZone": "Thêm múi giờ", + "remove": "Xóa", + "localBadge": "Địa phương", + "invalidDate": "Nhập ngày và giờ hợp lệ.", + "bands": { + "work": "Giờ làm việc (09:00–18:00)", + "edge": "Giờ giáp ranh (sáng sớm / tối)", + "night": "Ban đêm" + } + }, + "TokenCounter": { + "title": "Bộ đếm token LLM", + "subtitle": "Đếm token, trực quan hóa các khối và ước tính chi phí API.", + "inputLabel": "Văn bản", + "sample": "Ví dụ", + "clear": "Xóa", + "placeholder": "Dán văn bản, mã hoặc JSON để đếm token...", + "encoding": "Mã hóa", + "loading": "Đang tải bộ tách token...", + "encodings": { + "o200k": "o200k_base (GPT-4o / dòng o)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Token", + "characters": "Ký tự", + "words": "Từ", + "ratio": "Token/từ", + "ratioSub": "xấp xỉ" + }, + "claudeRow": "Claude (xấp xỉ)", + "estimate": "ước tính", + "viz": { + "title": "Trực quan hóa token", + "toggle": "Hiển thị", + "hint": "Bật để tô màu mỗi token thành một khối.", + "empty": "Nhập văn bản để trực quan hóa token.", + "truncated": "Đang hiển thị {limit} token đầu tiên trong tổng số {total}." + }, + "cost": { + "title": "Máy tính chi phí", + "disclaimer": "giá trị mặc định có thể sửa — hãy kiểm tra giá hiện tại", + "model": "Mô hình", + "expectedOutput": "Số token đầu ra dự kiến", + "customInput": "Đầu vào $/MTok", + "customOutput": "Đầu ra $/MTok", + "inputCost": "Chi phí đầu vào", + "outputCost": "Chi phí đầu ra", + "total": "Tổng", + "per1000": "Mỗi 1000 lần gọi" + } + }, + "WebhookTester": { + "title": "Trình kiểm tra Webhook", + "subtitle": "Tạo một URL tạm thời và kiểm tra các yêu cầu webhook đến trực tiếp.", + "intro": { + "title": "Tạo một URL webhook để bắt đầu ghi lại các yêu cầu", + "hint": "Bạn sẽ nhận được một URL không thể đoán, ghi lại mọi yêu cầu đến. Nó hết hạn sau 24 giờ." + }, + "createBtn": "Tạo URL webhook", + "creating": "Đang tạo…", + "urlLabel": "URL webhook của bạn", + "expiresIn": "Hết hạn sau {countdown}", + "copyUrlBtn": "Sao chép URL", + "copied": "Đã sao chép", + "clearBtn": "Xóa", + "newBinBtn": "URL mới", + "requestsTitle": "Yêu cầu", + "selectPrompt": "Chọn một yêu cầu để xem chi tiết.", + "expiredNotice": "URL webhook này đã hết hạn. Tạo một URL mới để tiếp tục kiểm tra.", + "base64Badge": "base64", + "truncatedBadge": "đã cắt bớt", + "empty": { + "title": "Đang chờ yêu cầu…", + "hint": "Gửi một yêu cầu đến URL của bạn và nó sẽ hiển thị ngay tại đây. Hãy thử:" + }, + "detail": { + "receivedAt": "Nhận lúc", + "source": "IP nguồn", + "contentType": "Content-Type", + "query": "Tham số truy vấn", + "headers": "Header", + "body": "Nội dung", + "copyBody": "Sao chép nội dung", + "emptyBody": "Không có nội dung" + }, + "errors": { + "createFailed": "Không tạo được URL webhook. Vui lòng thử lại.", + "clearFailed": "Không xóa được các yêu cầu. Vui lòng thử lại." + } + }, + "WebsocketTester": { + "title": "Trình kiểm tra WebSocket", + "subtitle": "Kết nối tới một điểm cuối WebSocket và gửi, nhận, kiểm tra tin nhắn trực tiếp", + "urlLabel": "URL điểm cuối", + "protocolsLabel": "Giao thức phụ", + "protocolsPlaceholder": "tùy chọn, phân tách bằng dấu phẩy", + "connect": "Kết nối", + "disconnect": "Ngắt kết nối", + "status": { + "disconnected": "Đã ngắt kết nối", + "connecting": "Đang kết nối", + "connected": "Đã kết nối", + "error": "Lỗi" + }, + "messages": { + "connecting": "Đang kết nối tới {url}…", + "connected": "Đã thiết lập kết nối", + "connectedWithProtocol": "Đã thiết lập kết nối (giao thức: {protocol})", + "invalidUrl": "URL không hợp lệ — phải bắt đầu bằng ws:// hoặc wss://", + "connectFailed": "Không mở được kết nối", + "socketError": "Đã xảy ra lỗi kết nối", + "notConnected": "Chưa kết nối — hãy kết nối trước", + "binaryMessage": "Đã nhận một tin nhắn nhị phân" + }, + "log": { + "title": "Nhật ký tin nhắn", + "empty": "Chưa có tin nhắn. Hãy kết nối và bắt đầu gửi.", + "copyAll": "Sao chép tất cả", + "download": "Tải xuống dạng .txt", + "clear": "Xóa nhật ký", + "prettyPrint": "Định dạng đẹp JSON", + "showRaw": "Hiện văn bản thô" + }, + "composer": { + "placeholder": "Nhập tin nhắn để gửi…", + "send": "Gửi", + "formatJson": "Định dạng thành JSON", + "invalidJson": "Tin nhắn không phải JSON hợp lệ", + "hint": "Cmd/Ctrl+Enter để gửi" + }, + "ping": { + "label": "Tự động ping", + "seconds": "giây", + "intervalTitle": "Khoảng ping tính bằng giây", + "payloadPlaceholder": "payload ping" + } + }, + "WhoisLookup": { + "title": "Tra cứu Whois", + "subtitle": "Tra cứu thông tin đăng ký cho bất kỳ tên miền hoặc địa chỉ IP nào qua RDAP", + "placeholder": "example.com hoặc 8.8.8.8", + "lookupBtn": "Tra cứu", + "looking": "Đang tra cứu…", + "refreshBtn": "Làm mới", + "tryLabel": "Thử:", + "copy": "Sao chép", + "copied": "Đã sao chép", + "notAvailable": "Không có sẵn", + "summary": { + "registrar": "Nhà đăng ký", + "network": "Mạng" + }, + "dates": { + "created": "Ngày tạo", + "updated": "Ngày cập nhật", + "expires": "Ngày hết hạn" + }, + "sections": { + "status": "Trạng thái", + "nameservers": "Máy chủ tên", + "contacts": "Liên hệ", + "rawRdap": "RDAP thô" + }, + "contacts": { + "roles": "Vai trò", + "name": "Tên", + "organization": "Tổ chức" + }, + "errors": { + "invalidTarget": "Nhập một tên miền hoặc địa chỉ IP hợp lệ", + "lookupFailed": "Tra cứu thất bại (trạng thái {status})" + } } } diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 1d18872c..db0036ec 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -61,7 +61,22 @@ "stringCaseConverter": "String Case Converter", "lineSortDedupe": "Line Sort & Dedupe", "stringInspector": "String Inspector", - "escapeEncode": "Escape / Encode" + "escapeEncode": "Escape / Encode", + "bcryptGenerator": "Bcrypt", + "chmodCalculator": "Chmod 计算器", + "codeScreenshot": "代码截图", + "cssGenerators": "CSS 生成器", + "exifViewer": "EXIF 查看器", + "faviconGenerator": "Favicon 生成器", + "jsonDiff": "JSON 对比", + "jsonpathPlayground": "JSONPath 沙盒", + "keycodeInspector": "键码检查器", + "markdownTableGenerator": "Markdown 表格", + "timezoneConverter": "时区转换器", + "tokenCounter": "Token 计数器", + "webhookTester": "Webhook 测试器", + "websocketTester": "WebSocket 测试器", + "whoisLookup": "Whois 查询" }, "Help": { "title": "帮助与文档", @@ -526,6 +541,66 @@ "escapeEncode": { "title": "Escape / Encode", "description": "Escape and unescape HTML entities, backslash strings, hex, and Unicode." + }, + "bcryptGenerator": { + "title": "Bcrypt 生成与校验", + "description": "使用 bcrypt 哈希密码并校验现有哈希值。" + }, + "chmodCalculator": { + "title": "Chmod 计算器", + "description": "在八进制、符号表示和复选框之间转换 Unix 权限。" + }, + "codeScreenshot": { + "title": "代码截图", + "description": "将源代码转换为精美、可分享的图片。" + }, + "cssGenerators": { + "title": "CSS 生成器", + "description": "Box-shadow、border-radius 和流式 clamp() 生成器。" + }, + "exifViewer": { + "title": "EXIF 查看与移除", + "description": "在本地检查并清除照片的 EXIF 元数据。" + }, + "faviconGenerator": { + "title": "Favicon 生成器", + "description": "生成 favicon、应用图标和清单代码片段。" + }, + "jsonDiff": { + "title": "JSON 对比", + "description": "对两个 JSON 文档进行语义化、结构感知的对比。" + }, + "jsonpathPlayground": { + "title": "JSONPath 沙盒", + "description": "使用 JSONPath 或 JMESPath 查询 JSON 并实时查看结果。" + }, + "keycodeInspector": { + "title": "键码检查器", + "description": "按下按键即可查看其 JavaScript 事件码和修饰键。" + }, + "markdownTableGenerator": { + "title": "Markdown 表格生成器", + "description": "在类似电子表格的网格编辑器中构建 Markdown 和 HTML 表格。" + }, + "timezoneConverter": { + "title": "时区转换器", + "description": "跨多个时区转换时间,并提示日期偏移和工作时间。" + }, + "tokenCounter": { + "title": "LLM Token 计数器", + "description": "统计 LLM token 数量、可视化分块并估算 API 成本。" + }, + "webhookTester": { + "title": "Webhook 测试器", + "description": "实时捕获并检查传入的 webhook 请求。" + }, + "websocketTester": { + "title": "WebSocket 测试器", + "description": "连接 WebSocket 端点并实时收发消息。" + }, + "whoisLookup": { + "title": "Whois 查询", + "description": "通过 RDAP 查询域名和 IP 的注册信息。" } }, "tabs": { @@ -3877,5 +3952,602 @@ "copy": "Copy", "copied": "Copied", "outputPlaceholder": "Result will appear here…" + }, + "BcryptGenerator": { + "title": "Bcrypt 生成与校验", + "subtitle": "使用 bcrypt 哈希密码并校验哈希 — 一切都在你的浏览器中完成", + "tabs": { + "hash": "哈希", + "verify": "校验" + }, + "showPassword": "显示密码", + "hidePassword": "隐藏密码", + "copy": "复制", + "copied": "已复制", + "hash": { + "passwordLabel": "密码", + "passwordPlaceholder": "输入要哈希的密码…", + "costLabel": "成本因子", + "costHint": "更高的成本因子会使每一步的计算量翻倍。生产环境常用 10-12。", + "slowWarning": "成本因子达到 13 及以上可能需要数秒才能计算完成。", + "generate": "生成哈希", + "generating": "正在哈希…", + "resultLabel": "Bcrypt 哈希", + "timeTaken": "{ms} 毫秒", + "anatomy": { + "title": "哈希结构", + "algorithm": "算法", + "cost": "成本", + "salt": "盐值(22 个字符)", + "hash": "哈希(31 个字符)" + } + }, + "verify": { + "passwordLabel": "密码", + "passwordPlaceholder": "输入要校验的密码…", + "hashLabel": "Bcrypt 哈希", + "hashPlaceholder": "$2b$10$…", + "idle": "输入密码和 bcrypt 哈希以进行比对。", + "invalidHash": "这看起来不是有效的 bcrypt 哈希。", + "checking": "正在比对…", + "match": "匹配 — 该密码可生成此哈希。", + "noMatch": "不匹配 — 该密码无法生成此哈希。" + } + }, + "ChmodCalculator": { + "title": "Chmod 计算器", + "subtitle": "在复选框、八进制和符号表示之间转换 Unix 文件权限", + "grid": { + "title": "权限", + "read": "读取", + "write": "写入", + "execute": "执行", + "owner": "所有者", + "group": "组", + "others": "其他用户" + }, + "special": { + "title": "特殊位", + "setuid": "Setuid", + "setgid": "Setgid", + "sticky": "Sticky" + }, + "octal": { + "label": "八进制", + "placeholder": "755 或 4755", + "invalid": "请输入 3 或 4 位八进制数字(0-7)。" + }, + "symbolic": { + "label": "符号表示", + "placeholder": "rwxr-xr-x", + "invalid": "请输入 9 个字符,如 rwxr-xr-x(允许 s/S、t/T)。" + }, + "output": { + "title": "命令", + "copy": "复制", + "copied": "已复制" + }, + "explain": { + "line": "{cls}可以{perms}。", + "none": "{cls}没有任何权限。", + "setuid": "Setuid:文件以其所有者的权限运行。", + "setgid": "Setgid:文件以其所属组的权限运行(目录中的新文件会继承该组)。", + "sticky": "Sticky:只有文件的所有者才能在此目录中删除或重命名它。" + }, + "presets": { + "title": "常用预设", + "octal": "八进制", + "permissions": "权限", + "description": "典型用途", + "p644": "普通文件:所有者可写,所有人可读。", + "p755": "可执行文件和目录:所有人可读取和进入。", + "p600": "私有文件:只有所有者可读写。", + "p700": "私有可执行文件和目录:仅限所有者。", + "p664": "组可写文件。", + "p775": "组可写目录和可执行文件。", + "p400": "所有者只读(例如 SSH 私钥)。" + } + }, + "CodeScreenshot": { + "title": "代码截图", + "subtitle": "将代码转换为精美、可分享的图片", + "languageLabel": "语言", + "autoDetect": "自动检测", + "backgroundLabel": "背景", + "paddingLabel": "内边距", + "filenameLabel": "文件名", + "filenamePlaceholder": "example.ts", + "windowLabel": "窗口圆点", + "themeLabel": "窗口主题", + "themeDark": "深色", + "themeLight": "浅色", + "scaleLabel": "导出倍率", + "copyImage": "复制图片", + "copied": "已复制", + "exportPng": "导出 PNG", + "inputLabel": "代码", + "detected": "已检测:{language}", + "clear": "清空", + "placeholder": "在此粘贴你的代码…", + "previewLabel": "预览", + "emptyPreview": "你的代码预览将显示在这里", + "exportFailed": "导出图片失败", + "copyUnsupported": "此浏览器不支持复制图片", + "copyFailed": "复制图片失败", + "backgrounds": { + "ocean": "海洋", + "sunset": "日落", + "candy": "糖果", + "forest": "森林", + "midnight": "午夜", + "gold": "金色", + "aurora": "极光", + "slate": "石板灰", + "transparent": "透明" + } + }, + "CssGenerators": { + "title": "CSS 生成器", + "subtitle": "Box-shadow、border-radius 和流式 clamp() — 复制 CSS。", + "preview": "预览", + "output": "CSS", + "copy": "复制", + "copied": "已复制", + "tabs": { + "boxShadow": "Box Shadow", + "borderRadius": "Border Radius", + "clamp": "流式 clamp()" + }, + "boxShadow": { + "presets": "预设", + "layer": "图层", + "addLayer": "添加图层", + "removeLayer": "移除图层", + "x": "X", + "y": "Y", + "blur": "模糊", + "spread": "扩展", + "color": "颜色", + "alpha": "透明度", + "inset": "内阴影", + "presetNames": { + "subtleCard": "轻微卡片", + "elevated": "悬浮", + "hardOffset": "硬偏移", + "neonGlow": "霓虹光晕", + "inner": "内阴影", + "layeredSmooth": "多层柔和" + } + }, + "borderRadius": { + "modeLabel": "模式", + "modes": { + "simple": "逐角", + "blob": "有机形状" + }, + "unit": "单位", + "linkCorners": "关联各角", + "randomize": "随机", + "horizontal": "水平半径", + "vertical": "垂直半径", + "corners": { + "tl": "左上", + "tr": "右上", + "br": "右下", + "bl": "左下" + } + }, + "clamp": { + "minFont": "最小字号 (px)", + "maxFont": "最大字号 (px)", + "minViewport": "最小视口 (px)", + "maxViewport": "最大视口 (px)", + "rootFont": "根字号 (px)", + "viewportWidth": "模拟视口", + "computedSize": "计算尺寸", + "slope": "斜率", + "intercept": "截距", + "previewText": "The quick brown fox", + "invalid": "请输入有效且不同的视口范围。" + } + }, + "ExifViewer": { + "title": "EXIF 查看与移除", + "subtitle": "在浏览器中检查照片元数据并将其清除", + "dropTitle": "将图片拖放到此处", + "dropHint": "或点击浏览 — 你的图片绝不会离开你的设备", + "browse": "选择图片", + "formats": "JPEG、PNG、WebP、HEIC、TIFF", + "parsing": "正在读取元数据…", + "parseFailed": "读取此图片失败", + "clear": "移除图片", + "gpsWarning": "包含 GPS 位置信息", + "openMap": "在 OpenStreetMap 上查看", + "noMetadata": "此图片中未找到嵌入的元数据。", + "facts": { + "name": "名称", + "type": "类型", + "dimensions": "尺寸", + "size": "大小" + }, + "groups": { + "camera": "相机", + "exposure": "曝光", + "timestamps": "时间戳", + "gps": "GPS 位置", + "software": "软件与其他" + }, + "gpsCard": { + "latitude": "纬度", + "longitude": "经度", + "altitude": "海拔" + }, + "raw": { + "title": "全部元数据", + "copyJson": "复制 JSON", + "copied": "已复制" + }, + "remove": { + "title": "移除元数据", + "jpegDescription": "无损移除 EXIF、XMP、IPTC 和注释,且不重新压缩图片。", + "reencodeDescription": "重新编码图片以清除嵌入的元数据。这会重新压缩像素。", + "keepIcc": "保留颜色配置文件 (ICC)", + "stripButton": "清除元数据", + "reencodeButton": "重新编码并清除", + "unsupported": "此文件类型不支持移除元数据。", + "failed": "移除元数据失败", + "before": "之前", + "after": "之后", + "download": "下载已清理的图片" + } + }, + "FaviconGenerator": { + "title": "Favicon 生成器", + "subtitle": "从一张图片创建 favicon 和应用图标", + "dropTitle": "将图片拖放到此处", + "dropHint": "PNG、JPEG 或 SVG — 点击浏览", + "browse": "选择图片", + "clear": "移除图片", + "backgroundLabel": "背景", + "transparent": "透明", + "solidColor": "纯色", + "cornerLabel": "圆角形状", + "cornerNone": "方形", + "cornerRounded": "圆角", + "cornerCircle": "圆形", + "maskPaddingLabel": "可遮罩内边距", + "maskable": "maskable", + "previewLabel": "预览", + "copy": "复制", + "headSnippet": "HTML head 代码片段", + "downloadIco": "下载 favicon.ico", + "downloadZip": "下载全部 (.zip)" + }, + "JsonDiff": { + "title": "JSON 对比", + "subtitle": "对两个 JSON 文档进行语义化、结构感知的比较 — 忽略键顺序和格式", + "options": { + "ignoreArrayOrder": "忽略数组顺序" + }, + "loadBothSamples": "加载示例", + "sample": "示例", + "format": "格式化", + "clear": "清空", + "formatError": "无法格式化:输入不是有效的 JSON", + "placeholder": "在此粘贴或输入 JSON…", + "panels": { + "left": "原始(左)", + "right": "修改后(右)", + "results": "差异" + }, + "summary": { + "added": "{count} 项新增", + "removed": "{count} 项移除", + "changed": "{count} 项变更" + }, + "types": { + "added": "新增", + "removed": "移除", + "changed": "变更" + }, + "copyChanges": "复制为 JSON", + "copied": "已复制", + "empty": "在两个面板中粘贴 JSON 以查看它们的差异。", + "identical": "这两个文档在语义上完全相同。" + }, + "JsonpathPlayground": { + "title": "JSONPath 沙盒", + "subtitle": "使用 JSONPath 或 JMESPath 查询 JSON 文档并实时查看结果", + "engineLabel": "引擎", + "queryLabel": "查询", + "engines": { + "jsonpath": "JSONPath", + "jmespath": "JMESPath" + }, + "cheatsheet": "速查表", + "cheats": { + "jpAllTitles": "所有书名", + "jpAllAuthors": "文档中任意位置的所有作者", + "jpFirstBook": "第一本书", + "jpLastBook": "最后一本书", + "jpSlice": "前两本书(切片)", + "jpFilterPrice": "价格低于 40 的书", + "jpFilterInStock": "有库存书籍的书名", + "jpRecursive": "递归获取所有值", + "jmAllTitles": "所有书名", + "jmFirstAuthor": "第一本书的作者", + "jmLastBook": "最后一本书", + "jmSlice": "前两本书(切片)", + "jmFilterPrice": "价格低于 40 的书名", + "jmCount": "统计有库存的书籍", + "jmProjection": "将每本书重塑为新对象", + "jmSort": "按价格排序的书名" + }, + "panels": { + "input": "JSON 输入", + "results": "结果" + }, + "sample": "示例", + "format": "格式化", + "clear": "清空", + "formatError": "无法格式化:输入不是有效的 JSON", + "placeholder": "在此粘贴或输入 JSON…", + "outputPlaceholder": "匹配结果将显示在这里…", + "matchCount": "{count, plural, one {# 个匹配} other {# 个匹配}}", + "copy": "复制", + "copied": "已复制" + }, + "KeycodeInspector": { + "title": "键码检查器", + "subtitle": "按下任意键以检查其 JavaScript 键盘事件", + "capture": { + "prompt": "按下任意键…", + "ready": "正在监听 — 请按键盘上的任意键", + "clickToFocus": "点击此处以聚焦,然后按任意键" + }, + "hero": { + "spaceKey": "(空格)", + "keyLabel": "event.key", + "repeat": "重复" + }, + "copyJson": "复制 JSON", + "copiedJson": "事件 JSON 已复制到剪贴板", + "deprecated": "已弃用", + "fields": { + "code": "event.code", + "keyCode": "event.keyCode", + "which": "event.which", + "location": "event.location" + }, + "location": { + "standard": "标准", + "left": "左", + "right": "右", + "numpad": "小键盘", + "unknown": "未知" + }, + "history": { + "title": "历史记录", + "clear": "清空历史记录", + "empty": "按键记录将显示在这里。" + } + }, + "MarkdownTableGenerator": { + "title": "Markdown 表格生成器", + "subtitle": "编辑网格、从电子表格粘贴,并复制对齐美观的 Markdown 或 HTML 表格", + "addRow": "添加行", + "addColumn": "添加列", + "importLabel": "导入", + "importPlaceholder": "在此粘贴 TSV(来自 Excel / Google Sheets)或 CSV…", + "importButton": "导入到网格", + "pasteClipboard": "从剪贴板粘贴", + "importEmpty": "无内容可导入 — 请先粘贴一些行", + "clipboardError": "无法从剪贴板读取", + "clear": "清空", + "panels": { + "editor": "表格编辑器", + "output": "输出" + }, + "align": { + "left": "左对齐", + "center": "居中对齐", + "right": "右对齐" + }, + "headerPlaceholder": "表头", + "cellPlaceholder": "单元格", + "removeRow": "移除行", + "removeColumn": "移除列", + "tabs": { + "markdown": "Markdown", + "html": "HTML" + }, + "copy": "复制", + "copied": "已复制" + }, + "TimezoneConverter": { + "title": "时区转换器", + "subtitle": "在全球范围内转换某一时刻,方便安排会议。", + "dateTimeLabel": "日期和时间", + "now": "现在", + "sourceZoneLabel": "源时区", + "selectZone": "选择时区", + "searchZone": "搜索时区...", + "noZone": "未找到时区。", + "scrubLabel": "调整时间", + "reset": "重置", + "targetsLabel": "目标时区", + "addZone": "添加时区", + "remove": "移除", + "localBadge": "本地", + "invalidDate": "请输入有效的日期和时间。", + "bands": { + "work": "工作时间(09:00–18:00)", + "edge": "边缘时间(清晨 / 傍晚)", + "night": "夜间" + } + }, + "TokenCounter": { + "title": "LLM Token 计数器", + "subtitle": "统计 token、可视化分块并估算 API 成本。", + "inputLabel": "文本", + "sample": "示例", + "clear": "清空", + "placeholder": "粘贴文本、代码或 JSON 以统计 token...", + "encoding": "编码", + "loading": "正在加载分词器...", + "encodings": { + "o200k": "o200k_base (GPT-4o / o 系列)", + "cl100k": "cl100k_base (GPT-4 / 3.5)" + }, + "stats": { + "tokens": "Token 数", + "characters": "字符数", + "words": "单词数", + "ratio": "Token/单词", + "ratioSub": "约" + }, + "claudeRow": "Claude(近似)", + "estimate": "估算", + "viz": { + "title": "Token 可视化", + "toggle": "显示", + "hint": "切换以将每个 token 着色为一个分块。", + "empty": "输入文本以可视化 token。", + "truncated": "显示 {total} 个 token 中的前 {limit} 个。" + }, + "cost": { + "title": "成本计算器", + "disclaimer": "可编辑的默认值 — 请核对当前定价", + "model": "模型", + "expectedOutput": "预期输出 token 数", + "customInput": "输入 $/MTok", + "customOutput": "输出 $/MTok", + "inputCost": "输入成本", + "outputCost": "输出成本", + "total": "总计", + "per1000": "每 1000 次调用" + } + }, + "WebhookTester": { + "title": "Webhook 测试器", + "subtitle": "创建临时 URL 并实时检查传入的 webhook 请求。", + "intro": { + "title": "创建 webhook URL 以开始捕获请求", + "hint": "你将获得一个无法猜测的 URL,它会记录每个传入的请求。24 小时后过期。" + }, + "createBtn": "创建 webhook URL", + "creating": "正在创建…", + "urlLabel": "你的 webhook URL", + "expiresIn": "{countdown} 后过期", + "copyUrlBtn": "复制 URL", + "copied": "已复制", + "clearBtn": "清空", + "newBinBtn": "新建 URL", + "requestsTitle": "请求", + "selectPrompt": "选择一个请求以查看其详细信息。", + "expiredNotice": "此 webhook URL 已过期。创建一个新的以继续测试。", + "base64Badge": "base64", + "truncatedBadge": "已截断", + "empty": { + "title": "正在等待请求…", + "hint": "向你的 URL 发送请求,它会立即显示在这里。试试:" + }, + "detail": { + "receivedAt": "接收时间", + "source": "来源 IP", + "contentType": "Content-Type", + "query": "查询参数", + "headers": "请求头", + "body": "正文", + "copyBody": "复制正文", + "emptyBody": "无正文" + }, + "errors": { + "createFailed": "创建 webhook URL 失败。请重试。", + "clearFailed": "清空请求失败。请重试。" + } + }, + "WebsocketTester": { + "title": "WebSocket 测试器", + "subtitle": "连接 WebSocket 端点并实时发送、接收和检查消息", + "urlLabel": "端点 URL", + "protocolsLabel": "子协议", + "protocolsPlaceholder": "可选,以逗号分隔", + "connect": "连接", + "disconnect": "断开连接", + "status": { + "disconnected": "已断开", + "connecting": "正在连接", + "connected": "已连接", + "error": "错误" + }, + "messages": { + "connecting": "正在连接到 {url}…", + "connected": "连接已建立", + "connectedWithProtocol": "连接已建立(协议:{protocol})", + "invalidUrl": "无效的 URL — 必须以 ws:// 或 wss:// 开头", + "connectFailed": "打开连接失败", + "socketError": "发生连接错误", + "notConnected": "未连接 — 请先连接", + "binaryMessage": "收到二进制消息" + }, + "log": { + "title": "消息日志", + "empty": "暂无消息。连接后开始发送。", + "copyAll": "全部复制", + "download": "下载为 .txt", + "clear": "清空日志", + "prettyPrint": "美化 JSON", + "showRaw": "显示原始文本" + }, + "composer": { + "placeholder": "输入要发送的消息…", + "send": "发送", + "formatJson": "格式化为 JSON", + "invalidJson": "消息不是有效的 JSON", + "hint": "Cmd/Ctrl+Enter 发送" + }, + "ping": { + "label": "自动 ping", + "seconds": "秒", + "intervalTitle": "Ping 间隔(秒)", + "payloadPlaceholder": "ping 负载" + } + }, + "WhoisLookup": { + "title": "Whois 查询", + "subtitle": "通过 RDAP 查询任意域名或 IP 地址的注册信息", + "placeholder": "example.com 或 8.8.8.8", + "lookupBtn": "查询", + "looking": "正在查询…", + "refreshBtn": "刷新", + "tryLabel": "试试:", + "copy": "复制", + "copied": "已复制", + "notAvailable": "不可用", + "summary": { + "registrar": "注册商", + "network": "网络" + }, + "dates": { + "created": "创建时间", + "updated": "更新时间", + "expires": "过期时间" + }, + "sections": { + "status": "状态", + "nameservers": "名称服务器", + "contacts": "联系人", + "rawRdap": "原始 RDAP" + }, + "contacts": { + "roles": "角色", + "name": "名称", + "organization": "组织" + }, + "errors": { + "invalidTarget": "请输入有效的域名或 IP 地址", + "lookupFailed": "查询失败(状态 {status})" + } } } diff --git a/apps/web/src/app/app/bcrypt-generator/layout.tsx b/apps/web/src/app/app/bcrypt-generator/layout.tsx new file mode 100644 index 00000000..13739967 --- /dev/null +++ b/apps/web/src/app/app/bcrypt-generator/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('bcrypt-generator') + +export default function BcryptGeneratorLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/bcrypt-generator/page.tsx b/apps/web/src/app/app/bcrypt-generator/page.tsx new file mode 100644 index 00000000..92bcc676 --- /dev/null +++ b/apps/web/src/app/app/bcrypt-generator/page.tsx @@ -0,0 +1,8 @@ +import { BcryptGeneratorLayout } from '@/components/bcrypt-generator/bcrypt-generator-layout' +export default function BcryptGeneratorPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/chmod-calculator/layout.tsx b/apps/web/src/app/app/chmod-calculator/layout.tsx new file mode 100644 index 00000000..a1f79691 --- /dev/null +++ b/apps/web/src/app/app/chmod-calculator/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('chmod-calculator') + +export default function ChmodCalculatorPageLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/chmod-calculator/page.tsx b/apps/web/src/app/app/chmod-calculator/page.tsx new file mode 100644 index 00000000..5da12b2b --- /dev/null +++ b/apps/web/src/app/app/chmod-calculator/page.tsx @@ -0,0 +1,8 @@ +import { ChmodCalculatorLayout } from '@/components/chmod-calculator/chmod-calculator-layout' +export default function ChmodCalculatorPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/code-screenshot/layout.tsx b/apps/web/src/app/app/code-screenshot/layout.tsx new file mode 100644 index 00000000..e5ea6c16 --- /dev/null +++ b/apps/web/src/app/app/code-screenshot/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('code-screenshot') + +export default function CodeScreenshotLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/code-screenshot/page.tsx b/apps/web/src/app/app/code-screenshot/page.tsx new file mode 100644 index 00000000..31f536f4 --- /dev/null +++ b/apps/web/src/app/app/code-screenshot/page.tsx @@ -0,0 +1,8 @@ +import { CodeScreenshotLayout } from '@/components/code-screenshot/code-screenshot-layout' +export default function CodeScreenshotPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/css-generators/layout.tsx b/apps/web/src/app/app/css-generators/layout.tsx new file mode 100644 index 00000000..3e525519 --- /dev/null +++ b/apps/web/src/app/app/css-generators/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('css-generators') + +export default function CssGeneratorsLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/css-generators/page.tsx b/apps/web/src/app/app/css-generators/page.tsx new file mode 100644 index 00000000..3299ab0a --- /dev/null +++ b/apps/web/src/app/app/css-generators/page.tsx @@ -0,0 +1,8 @@ +import { CssGeneratorsLayout } from '@/components/css-generators/css-generators-layout' +export default function CssGeneratorsPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/exif-viewer/layout.tsx b/apps/web/src/app/app/exif-viewer/layout.tsx new file mode 100644 index 00000000..24e2caa0 --- /dev/null +++ b/apps/web/src/app/app/exif-viewer/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('exif-viewer') + +export default function ExifViewerLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/exif-viewer/page.tsx b/apps/web/src/app/app/exif-viewer/page.tsx new file mode 100644 index 00000000..397dc391 --- /dev/null +++ b/apps/web/src/app/app/exif-viewer/page.tsx @@ -0,0 +1,8 @@ +import { ExifViewerLayout } from '@/components/exif-viewer/exif-viewer-layout' +export default function ExifViewerPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/favicon-generator/layout.tsx b/apps/web/src/app/app/favicon-generator/layout.tsx new file mode 100644 index 00000000..0ac209dd --- /dev/null +++ b/apps/web/src/app/app/favicon-generator/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('favicon-generator') + +export default function FaviconGeneratorLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/favicon-generator/page.tsx b/apps/web/src/app/app/favicon-generator/page.tsx new file mode 100644 index 00000000..25375774 --- /dev/null +++ b/apps/web/src/app/app/favicon-generator/page.tsx @@ -0,0 +1,8 @@ +import { FaviconGeneratorLayout } from '@/components/favicon-generator/favicon-generator-layout' +export default function FaviconGeneratorPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/json-diff/layout.tsx b/apps/web/src/app/app/json-diff/layout.tsx new file mode 100644 index 00000000..f2fa2040 --- /dev/null +++ b/apps/web/src/app/app/json-diff/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('json-diff') + +export default function JsonDiffLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/json-diff/page.tsx b/apps/web/src/app/app/json-diff/page.tsx new file mode 100644 index 00000000..cd6f243b --- /dev/null +++ b/apps/web/src/app/app/json-diff/page.tsx @@ -0,0 +1,8 @@ +import { JsonDiffLayout } from '@/components/json-diff/json-diff-layout' +export default function JsonDiffPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/jsonpath-playground/layout.tsx b/apps/web/src/app/app/jsonpath-playground/layout.tsx new file mode 100644 index 00000000..1233d37f --- /dev/null +++ b/apps/web/src/app/app/jsonpath-playground/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('jsonpath-playground') + +export default function JsonpathPlaygroundLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/jsonpath-playground/page.tsx b/apps/web/src/app/app/jsonpath-playground/page.tsx new file mode 100644 index 00000000..f4379786 --- /dev/null +++ b/apps/web/src/app/app/jsonpath-playground/page.tsx @@ -0,0 +1,8 @@ +import { JsonpathPlaygroundLayout } from '@/components/jsonpath-playground/jsonpath-playground-layout' +export default function JsonpathPlaygroundPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/keycode-inspector/layout.tsx b/apps/web/src/app/app/keycode-inspector/layout.tsx new file mode 100644 index 00000000..13436707 --- /dev/null +++ b/apps/web/src/app/app/keycode-inspector/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('keycode-inspector') + +export default function KeycodeInspectorPageLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/keycode-inspector/page.tsx b/apps/web/src/app/app/keycode-inspector/page.tsx new file mode 100644 index 00000000..f6413354 --- /dev/null +++ b/apps/web/src/app/app/keycode-inspector/page.tsx @@ -0,0 +1,8 @@ +import { KeycodeInspectorLayout } from '@/components/keycode-inspector/keycode-inspector-layout' +export default function KeycodeInspectorPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/markdown-table-generator/layout.tsx b/apps/web/src/app/app/markdown-table-generator/layout.tsx new file mode 100644 index 00000000..6bda40c2 --- /dev/null +++ b/apps/web/src/app/app/markdown-table-generator/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('markdown-table-generator') + +export default function MarkdownTableGeneratorLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/markdown-table-generator/page.tsx b/apps/web/src/app/app/markdown-table-generator/page.tsx new file mode 100644 index 00000000..531b5b6f --- /dev/null +++ b/apps/web/src/app/app/markdown-table-generator/page.tsx @@ -0,0 +1,8 @@ +import { MarkdownTableGeneratorLayout } from '@/components/markdown-table-generator/markdown-table-generator-layout' +export default function MarkdownTableGeneratorPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/timezone-converter/layout.tsx b/apps/web/src/app/app/timezone-converter/layout.tsx new file mode 100644 index 00000000..0c0778c7 --- /dev/null +++ b/apps/web/src/app/app/timezone-converter/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('timezone-converter') + +export default function TimezoneConverterLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/timezone-converter/page.tsx b/apps/web/src/app/app/timezone-converter/page.tsx new file mode 100644 index 00000000..57e3a08e --- /dev/null +++ b/apps/web/src/app/app/timezone-converter/page.tsx @@ -0,0 +1,8 @@ +import { TimezoneConverterLayout } from '@/components/timezone-converter/timezone-converter-layout' +export default function TimezoneConverterPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/to-do/components/AssigneePicker.tsx b/apps/web/src/app/app/to-do/components/AssigneePicker.tsx index 74f26ce9..80e4b467 100644 --- a/apps/web/src/app/app/to-do/components/AssigneePicker.tsx +++ b/apps/web/src/app/app/to-do/components/AssigneePicker.tsx @@ -29,7 +29,7 @@ import { memberLabel, memberInitials, } from "@/app/app/to-do/hooks/useWorkspaceMembers"; -import type { Member } from "@/lib/members-api"; +import type { Member } from "@/app/app/to-do/hooks/useWorkspaceMembers"; const SIZES = { sm: { avatar: "h-6 w-6", text: "text-[10px]", icon: "h-4 w-4" }, diff --git a/apps/web/src/app/app/to-do/hooks/useWorkspaceMembers.ts b/apps/web/src/app/app/to-do/hooks/useWorkspaceMembers.ts index 75552112..b7754ae4 100644 --- a/apps/web/src/app/app/to-do/hooks/useWorkspaceMembers.ts +++ b/apps/web/src/app/app/to-do/hooks/useWorkspaceMembers.ts @@ -1,29 +1,39 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { listWorkspaceMembers, type Member } from "@/lib/members-api"; -import { useActiveWorkspace } from "@/store/workspace-store"; +import useAuth from "@/utils/useAuth"; + +export type Member = { + uid: string; + email: string | null; + display_name: string | null; + role: string; + since: number; +}; /** - * Members of the active workspace, for assignee pickers. Reuses the existing - * workspace members API — no new backend endpoint. In a personal workspace the - * list is just the owner; assignment shines in shared workspaces. + * Members available for assignee pickers. Workspaces are single-user (personal) + * now that shared/org collaboration has been removed, so the only assignable + * member is the current user. */ export function useWorkspaceMembers() { - const ws = useActiveWorkspace(); - const wsId = ws?.id ?? null; + const { user } = useAuth(); - const query = useQuery({ - queryKey: ["workspaceMembers", wsId], - queryFn: () => listWorkspaceMembers(wsId as string), - enabled: !!wsId, - staleTime: 5 * 60 * 1000, // members change rarely - }); + const members: Member[] = user + ? [ + { + uid: user.uid, + email: user.email ?? null, + display_name: user.displayName ?? null, + role: "admin", + since: 0, + }, + ] + : []; return { - members: query.data ?? [], - isLoading: query.isPending && !!wsId, - isShared: !!ws && !ws.is_personal, + members, + isLoading: false, + isShared: false, }; } diff --git a/apps/web/src/app/app/token-counter/layout.tsx b/apps/web/src/app/app/token-counter/layout.tsx new file mode 100644 index 00000000..3b96fd99 --- /dev/null +++ b/apps/web/src/app/app/token-counter/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('token-counter') + +export default function TokenCounterLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/token-counter/page.tsx b/apps/web/src/app/app/token-counter/page.tsx new file mode 100644 index 00000000..eca3de9d --- /dev/null +++ b/apps/web/src/app/app/token-counter/page.tsx @@ -0,0 +1,8 @@ +import { TokenCounterLayout } from '@/components/token-counter/token-counter-layout' +export default function TokenCounterPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/webhook-tester/layout.tsx b/apps/web/src/app/app/webhook-tester/layout.tsx new file mode 100644 index 00000000..e8a126ea --- /dev/null +++ b/apps/web/src/app/app/webhook-tester/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('webhook-tester') + +export default function WebhookTesterLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/webhook-tester/page.tsx b/apps/web/src/app/app/webhook-tester/page.tsx new file mode 100644 index 00000000..deae037a --- /dev/null +++ b/apps/web/src/app/app/webhook-tester/page.tsx @@ -0,0 +1,8 @@ +import { WebhookTesterLayout } from '@/components/webhook-tester/webhook-tester-layout' +export default function WebhookTesterPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/websocket-tester/layout.tsx b/apps/web/src/app/app/websocket-tester/layout.tsx new file mode 100644 index 00000000..369573d1 --- /dev/null +++ b/apps/web/src/app/app/websocket-tester/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('websocket-tester') + +export default function WebsocketTesterLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/websocket-tester/page.tsx b/apps/web/src/app/app/websocket-tester/page.tsx new file mode 100644 index 00000000..a3cefcac --- /dev/null +++ b/apps/web/src/app/app/websocket-tester/page.tsx @@ -0,0 +1,8 @@ +import { WebsocketTesterLayout } from '@/components/websocket-tester/websocket-tester-layout' +export default function WebsocketTesterPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/app/whois-lookup/layout.tsx b/apps/web/src/app/app/whois-lookup/layout.tsx new file mode 100644 index 00000000..33190431 --- /dev/null +++ b/apps/web/src/app/app/whois-lookup/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('whois-lookup') + +export default function WhoisLookupLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/app/whois-lookup/page.tsx b/apps/web/src/app/app/whois-lookup/page.tsx new file mode 100644 index 00000000..4e0060e7 --- /dev/null +++ b/apps/web/src/app/app/whois-lookup/page.tsx @@ -0,0 +1,8 @@ +import { WhoisLookupLayout } from '@/components/whois-lookup/whois-lookup-layout' +export default function WhoisLookupPage() { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 56ec2053..6df8dc43 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect, useMemo } from 'react' import dynamic from 'next/dynamic' -import Link from 'next/link' import { ArrowLeft, ArrowRight, BarChart3, LayoutGrid } from 'lucide-react' import { useTranslations } from 'next-intl' import { sidebarData } from '@/components/sidebar/data/sidebar-data' @@ -10,7 +9,6 @@ import { usePinnedToolsStore, usePinnedToolsForActiveWorkspace } from '@/store/p import { useWorkspaceStore } from '@/store/workspace-store' import { useToolUsage } from '@/hooks/use-tool-usage' import useAuth from '@/utils/useAuth' -import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { TooltipProvider } from '@/components/ui/tooltip' @@ -29,7 +27,6 @@ import { DashboardPinnedSection } from '@/components/dashboard/dashboard-pinned- import { DashboardWhatsNew } from '@/components/dashboard/dashboard-whats-new' import { DashboardRecentTools } from '@/components/dashboard/dashboard-recent-tools' import { ActivityLogDrawer } from '@/components/dashboard/activity/activity-log-drawer' -import { DashboardLoginCta } from '@/components/dashboard/dashboard-login-cta' import { DashboardToolGrid } from '@/components/dashboard/dashboard-tool-grid' import { DashboardFeatured } from '@/components/dashboard/dashboard-featured' import { RevealItem } from '@/components/dashboard/dashboard-reveal' @@ -59,7 +56,6 @@ const DashboardAnalyticsPanel = dynamic( // ─── Dashboard Page ───────────────────────────────────────────────────────── const DashboardPage: React.FC = () => { - const t = useTranslations('Dashboard') const tTabs = useTranslations('Dashboard.tabs') const { user, loading } = useAuth(false) const pinnedToolUrls = usePinnedToolsForActiveWorkspace() @@ -360,11 +356,6 @@ const DashboardPage: React.FC = () => { /> - {/* Login CTA (non-auth) */} - - - - {/* Browse-all entry — the door to the full catalog */} - - - )} + diff --git a/apps/web/src/app/profile/page.tsx b/apps/web/src/app/profile/page.tsx index 58239c84..cc24ff51 100644 --- a/apps/web/src/app/profile/page.tsx +++ b/apps/web/src/app/profile/page.tsx @@ -21,10 +21,9 @@ import { AtSign, Github, ChevronDown, - ChevronRight, FileDown, } from 'lucide-react' -import { useActiveOrg, useActiveWorkspace } from '@/store/workspace-store' +import { useActiveWorkspace } from '@/store/workspace-store' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import Link from 'next/link' import { Input } from '@/components/ui/input' @@ -59,7 +58,6 @@ import { export default function ProfilePage() { const t = useTranslations('SettingsPage') const { user } = useAuth() - const activeOrg = useActiveOrg() const activeWorkspace = useActiveWorkspace() const [mounted, setMounted] = useState(false) const [username, setUsername] = useState(null) @@ -261,11 +259,9 @@ export default function ProfilePage() { {/* ── Page header ── */}
- {activeOrg && activeWorkspace ? ( + {activeWorkspace ? (

- {activeOrg.name} - {activeWorkspace.name}

) : ( diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index e04200a7..ffa8fb29 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -4,24 +4,18 @@ import React, { useState, useEffect } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Moon, Sun, Monitor, Globe, User, List, Palette, Check, ChevronDown } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { Sun, Globe, Palette, Check } from 'lucide-react' import { useTheme } from 'next-themes' import { cn } from '@/lib/utils' -import { Switch } from '@/components/ui/switch' -import { sidebarData } from '@/components/sidebar/data/sidebar-data' -import { useToolVisibility } from '@/hooks/use-tool-visibility' import { useLocale, useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' import { COLOR_THEME_OPTIONS, type ColorTheme, useColorTheme } from '@/hooks/use-color-theme' -import { getToolMessageKey } from '@/lib/tool-i18n' import { PasskeySection } from '@/components/settings/passkey-section' import { DesktopSyncSettings } from '@/components/desktop/desktop-sync-settings' import { DesktopSyncConflicts } from '@/components/desktop/desktop-sync-conflicts' import { DesktopBackupSettings } from '@/components/desktop/desktop-backup-settings' -import { useActiveOrg, useActiveWorkspace } from '@/store/workspace-store' -import { Briefcase, ChevronRight } from 'lucide-react' +import { useActiveWorkspace } from '@/store/workspace-store' +import { Briefcase } from 'lucide-react' const colorDisplay: Record = { cyan: { swatchClass: 'bg-cyan-500', name: 'Teal' }, blue: { swatchClass: 'bg-blue-500', name: 'Blue' }, @@ -35,13 +29,10 @@ const colorDisplay: Record = export default function SettingsPage() { const t = useTranslations('SettingsPage') - const tNav = useTranslations('Navigation') const locale = useLocale() const router = useRouter() const { theme, setTheme } = useTheme() const { colorTheme, setColorTheme } = useColorTheme() - const { isToolEnabled, toggleTool } = useToolVisibility() - const activeOrg = useActiveOrg() const activeWorkspace = useActiveWorkspace() const [mounted, setMounted] = useState(false) @@ -61,11 +52,9 @@ export default function SettingsPage() { return (
- {activeOrg && activeWorkspace ? ( + {activeWorkspace ? (

- {activeOrg.name} - {activeWorkspace.name}

) : ( @@ -88,69 +77,6 @@ export default function SettingsPage() { - - - - - - - {t('tools.title')} - - - {t('tools.description')} - - - - {sidebarData.navGroups.map((group: any) => { - const groupTools = group.items.filter((item: any) => { - const url = typeof item.url === 'string' ? item.url : item.url?.toString() || ''; - return url.startsWith('/app/'); - }); - if (groupTools.length === 0) return null; - - return ( - -
-
- {group.icon && } -

{group.title}

-
- - - -
- - {groupTools.map((item: any) => { - const url = typeof item.url === 'string' ? item.url : item.url?.toString() || ''; - const isEnabled = isToolEnabled(url); - const toolKey = getToolMessageKey(url); - const label = toolKey ? tNav(toolKey as never) : item.title; - - return ( -
- - toggleTool(url)} - /> -
- ) - })} -
-
- ) - })} -
-
- diff --git a/apps/web/src/app/settings/workspaces/org-section.tsx b/apps/web/src/app/settings/workspaces/org-section.tsx deleted file mode 100644 index 51727691..00000000 --- a/apps/web/src/app/settings/workspaces/org-section.tsx +++ /dev/null @@ -1,217 +0,0 @@ -"use client" - -import { useState } from "react" -import { Pencil, Trash2, UserPlus, Plus } from "lucide-react" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { toast } from "sonner" -import type { Org } from "@/lib/workspace-api" -import { renameOrg, deleteOrg } from "@/lib/org-api" -import { useWorkspaceStore } from "@/store/workspace-store" -import { MemberList } from "@/components/member-list" -import { WorkspaceSection } from "./workspace-section" -import { CreateWorkspaceDialog } from "@/components/create-workspace-dialog" -import { InviteMemberDialog } from "@/components/invite-member-dialog" -import { useConfirm } from "@/components/confirm-dialog" - -export function OrgSection({ org }: { org: Org }) { - const { workspaces, loadFromBackend } = useWorkspaceStore() - const [inviteOpen, setInviteOpen] = useState(false) - const [createWsOpen, setCreateWsOpen] = useState(false) - const [renaming, setRenaming] = useState(false) - const [renameValue, setRenameValue] = useState(org.name) - const [saving, setSaving] = useState(false) - const { confirm, dialog: confirmDialog } = useConfirm() - - // System orgs (Mydevtools Cloud) are platform-managed — no member roster shown. - const isSystem = org.kind === "system" - const isOwner = org.org_role === "owner" - // System orgs let any member create their own workspaces. - const canAddWorkspace = - isSystem || org.org_role === "owner" || org.org_role === "admin" - const orgWorkspaces = workspaces.filter((w) => w.org_id === org.id) - - async function handleRename() { - const trimmed = renameValue.trim() - if (!trimmed || trimmed === org.name) { - setRenaming(false) - return - } - setSaving(true) - try { - await renameOrg(org.id, trimmed) - await loadFromBackend() - toast.success("Organisation renamed") - setRenaming(false) - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to rename organisation") - } finally { - setSaving(false) - } - } - - async function handleDelete() { - const ok = await confirm({ - title: `Delete organisation "${org.name}"?`, - description: "This will remove all workspaces and cannot be undone.", - confirmLabel: "Delete", - destructive: true, - }) - if (!ok) return - try { - await deleteOrg(org.id) - await loadFromBackend() - toast.success("Organisation deleted") - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to delete organisation") - } - } - - return ( -
- {/* Org header */} -
-
- {renaming ? ( -
- setRenameValue(e.target.value)} - className="h-8 text-sm" - autoFocus - disabled={saving} - onKeyDown={(e) => { - if (e.key === "Enter") handleRename() - if (e.key === "Escape") { - setRenaming(false) - setRenameValue(org.name) - } - }} - /> - - -
- ) : ( -

{org.name}

- )} - - {org.org_role} - -
- - {isOwner && !renaming && ( -
- - -
- )} -
- - {/* Workspaces section */} -
-
-

- Workspaces -

- {canAddWorkspace && ( - - )} -
- {orgWorkspaces.length === 0 ? ( -

No workspaces in this organisation.

- ) : ( -
- {orgWorkspaces.map((ws) => ( - - ))} -
- )} -
- - {/* Members section — hidden for system orgs (Mydevtools Cloud). */} - {!isSystem && ( -
-
-

- Members -

- -
- -
- )} - - - - - - {confirmDialog} -
- ) -} diff --git a/apps/web/src/app/settings/workspaces/page.tsx b/apps/web/src/app/settings/workspaces/page.tsx index 2e628c09..7535372f 100644 --- a/apps/web/src/app/settings/workspaces/page.tsx +++ b/apps/web/src/app/settings/workspaces/page.tsx @@ -1,15 +1,13 @@ "use client" -import { useEffect, useState } from "react" -import { Building2, Plus } from "lucide-react" -import { Button } from "@/components/ui/button" -import { useWorkspaceStore } from "@/store/workspace-store" -import { OrgSection } from "./org-section" -import { CreateOrgDialog } from "@/components/create-org-dialog" +import { useEffect } from "react" +import { Briefcase, ShieldCheck } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { useWorkspaceStore, useActiveWorkspace } from "@/store/workspace-store" export default function WorkspacesSettingsPage() { - const { orgs, hydrated, loadFromBackend } = useWorkspaceStore() - const [createOrgOpen, setCreateOrgOpen] = useState(false) + const { hydrated, loadFromBackend } = useWorkspaceStore() + const workspace = useActiveWorkspace() useEffect(() => { if (!hydrated) { @@ -23,60 +21,58 @@ export default function WorkspacesSettingsPage() {

- Collaboration + Workspace

- Organizations & Workspaces + Your Workspace

- Manage your organisations, workspaces, and team members. + All your tools and data live in your personal workspace.

- {/* Org list */} -
- {orgs.length === 0 && hydrated ? ( -
- -

- No organisations yet -

-

- Create your first organisation to start collaborating. -

- + {/* Personal workspace card */} +
+ {workspace ? ( +
+
+ +
+
+
+

{workspace.name}

+ + Personal + +
+

+ Private to you — only you can access this data. +

+
) : ( - <> - {orgs.map((org) => ( - - ))} - - {/* Footer CTA */} -
- -
- +

+ {hydrated ? "No workspace found." : "Loading…"} +

)}
- + {/* Encryption note */} +
+
+
+ +
+
+

Encrypted tools

+

+ Password Manager, API Key Vault, and Environment Manager are + end-to-end encrypted with your master password. Set or unlock your + master password to use them. +

+
+
+
) } diff --git a/apps/web/src/app/settings/workspaces/workspace-section.tsx b/apps/web/src/app/settings/workspaces/workspace-section.tsx deleted file mode 100644 index 197d1266..00000000 --- a/apps/web/src/app/settings/workspaces/workspace-section.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client" - -import { useState } from "react" -import { Pencil, Trash2, UserPlus, ShieldAlert } from "lucide-react" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { toast } from "sonner" -import type { Workspace } from "@/lib/workspace-api" -import { renameWorkspace, deleteWorkspace } from "@/lib/org-api" -import { useWorkspaceStore } from "@/store/workspace-store" -import { MemberList } from "@/components/member-list" -import { InviteMemberDialog } from "@/components/invite-member-dialog" -import { EnableEncryptedToolsCta } from "@/components/enable-encrypted-tools-cta" -import { PendingWrapsPrompt } from "@/components/pending-wraps-prompt" -import { RotateKeyButton } from "@/components/rotate-key-button" -import { hasWorkspaceEncryption } from "@/lib/workspace-rbac" -import { useConfirm } from "@/components/confirm-dialog" - -export function WorkspaceSection({ workspace }: { workspace: Workspace }) { - const { loadFromBackend } = useWorkspaceStore() - const [inviteOpen, setInviteOpen] = useState(false) - const [renaming, setRenaming] = useState(false) - const [renameValue, setRenameValue] = useState(workspace.name) - const [saving, setSaving] = useState(false) - const { confirm, dialog: confirmDialog } = useConfirm() - - const isPersonal = workspace.is_personal - const canManage = !isPersonal && workspace.ws_role === "admin" - - async function handleRename() { - const trimmed = renameValue.trim() - if (!trimmed || trimmed === workspace.name) { - setRenaming(false) - return - } - setSaving(true) - try { - await renameWorkspace(workspace.id, trimmed) - await loadFromBackend() - toast.success("Workspace renamed") - setRenaming(false) - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to rename workspace") - } finally { - setSaving(false) - } - } - - async function handleDelete() { - const ok = await confirm({ - title: `Delete workspace "${workspace.name}"?`, - description: "This action cannot be undone.", - confirmLabel: "Delete", - destructive: true, - }) - if (!ok) return - try { - await deleteWorkspace(workspace.id) - await loadFromBackend() - toast.success("Workspace deleted") - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to delete workspace") - } - } - - return ( -
- {/* Header */} -
-
- {renaming ? ( -
- setRenameValue(e.target.value)} - className="h-7 text-sm" - autoFocus - disabled={saving} - onKeyDown={(e) => { - if (e.key === "Enter") handleRename() - if (e.key === "Escape") { - setRenaming(false) - setRenameValue(workspace.name) - } - }} - /> - - -
- ) : ( -

{workspace.name}

- )} - {!isPersonal && ( - - {workspace.ws_role} - - )} - {isPersonal && ( - - Personal - - )} -
- - {canManage && !renaming && ( -
- - -
- )} -
- - {/* Members subsection — hidden on personal (single-owner, no collaboration). */} - {!isPersonal && ( -
-
-

- Members -

- -
- -
- )} - - {!workspace.is_personal && !hasWorkspaceEncryption(workspace) && workspace.ws_role === "admin" && ( -
- -
- )} - - {!workspace.is_personal && hasWorkspaceEncryption(workspace) && workspace.ws_role === "admin" && ( -
- {workspace.settings?.encryption?.rotationRequired && ( -
- - - A member was removed. Rotate the encryption key now to revoke their - access to workspace secrets — until you do, the removed member can - still decrypt existing data. - -
- )} - - -
- )} - - - - {confirmDialog} -
- ) -} diff --git a/apps/web/src/components/__tests__/create-org-dialog.test.ts b/apps/web/src/components/__tests__/create-org-dialog.test.ts deleted file mode 100644 index 7f70d965..00000000 --- a/apps/web/src/components/__tests__/create-org-dialog.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Tests for CreateOrgDialog (Task 14 — workspace collaboration) - * - * Environment: jest-environment-node — no DOM, no React rendering. - * Strategy: verify module exports and source-file structure. - */ - -jest.mock("@/lib/backend-auth", () => ({ - backendFetch: jest.fn(), -})) - -jest.mock("@/lib/org-api", () => ({ - createOrg: jest.fn(), -})) - -jest.mock("@/store/workspace-store", () => ({ - useWorkspaceStore: { - getState: jest.fn(() => ({ loadFromBackend: jest.fn() })), - }, -})) - -jest.mock("sonner", () => ({ - toast: { success: jest.fn(), error: jest.fn() }, -})) - -describe("CreateOrgDialog — module exports", () => { - it("exports a CreateOrgDialog named function component", () => { - const mod = require("../create-org-dialog") - expect(typeof mod.CreateOrgDialog).toBe("function") - }) -}) - -describe("CreateOrgDialog — source structure assertions", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../create-org-dialog.tsx"), - "utf8" - ) - - it("renders a dialog title Create organisation", () => { - expect(source).toContain("Create organisation") - }) - - it("has a name input field", () => { - expect(source).toContain("org-name") - }) - - it("imports createOrg from org-api", () => { - expect(source).toContain("createOrg") - expect(source).toContain("org-api") - }) - - it("calls loadFromBackend after successful creation", () => { - expect(source).toContain("loadFromBackend") - }) - - it("uses toast from sonner for notifications", () => { - expect(source).toContain("toast") - expect(source).toContain("sonner") - }) - - it("shows loading state with Loader2", () => { - expect(source).toContain("Loader2") - }) - - it("uses shadcn Dialog primitives", () => { - expect(source).toContain("DialogContent") - expect(source).toContain("DialogHeader") - expect(source).toContain("DialogTitle") - }) - - it("has cancel and create buttons", () => { - expect(source).toContain("Cancel") - expect(source).toContain("Create") - }) - - it("uses use client directive", () => { - expect(source).toContain('"use client"') - }) -}) - -describe("CreateOrgDialog — API contract (logic simulation)", () => { - beforeEach(() => jest.clearAllMocks()) - - it("createOrg is called with trimmed name", async () => { - const { createOrg } = require("@/lib/org-api") - ;(createOrg as jest.Mock).mockResolvedValueOnce({ id: "o1", name: "Acme" }) - - const { useWorkspaceStore } = require("@/store/workspace-store") - const loadFromBackend = jest.fn().mockResolvedValueOnce(undefined) - ;(useWorkspaceStore.getState as jest.Mock).mockReturnValueOnce({ loadFromBackend }) - - // Simulate the submit handler logic - const name = " Acme " - const trimmed = name.trim() - await createOrg(trimmed) - await loadFromBackend() - - expect(createOrg).toHaveBeenCalledWith("Acme") - expect(loadFromBackend).toHaveBeenCalledTimes(1) - }) - - it("toast.error is called on createOrg failure", async () => { - const { createOrg } = require("@/lib/org-api") - ;(createOrg as jest.Mock).mockRejectedValueOnce(new Error("createOrg failed (500)")) - - const { toast } = require("sonner") - - // Simulate error path - try { - await createOrg("Bad") - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to create organisation") - } - - expect(toast.error).toHaveBeenCalledWith("createOrg failed (500)") - }) -}) diff --git a/apps/web/src/components/__tests__/create-workspace-dialog.test.ts b/apps/web/src/components/__tests__/create-workspace-dialog.test.ts deleted file mode 100644 index 45f1b6bc..00000000 --- a/apps/web/src/components/__tests__/create-workspace-dialog.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Tests for CreateWorkspaceDialog (Task 14 — workspace collaboration) - * - * Environment: jest-environment-node — no DOM, no React rendering. - * Strategy: verify module exports and source-file structure. - */ - -jest.mock("@/lib/backend-auth", () => ({ - backendFetch: jest.fn(), -})) - -jest.mock("@/lib/org-api", () => ({ - createWorkspace: jest.fn(), -})) - -jest.mock("@/store/workspace-store", () => ({ - useWorkspaceStore: { - getState: jest.fn(() => ({ loadFromBackend: jest.fn() })), - }, -})) - -jest.mock("sonner", () => ({ - toast: { success: jest.fn(), error: jest.fn() }, -})) - -describe("CreateWorkspaceDialog — module exports", () => { - it("exports a CreateWorkspaceDialog named function component", () => { - const mod = require("../create-workspace-dialog") - expect(typeof mod.CreateWorkspaceDialog).toBe("function") - }) -}) - -describe("CreateWorkspaceDialog — source structure assertions", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../create-workspace-dialog.tsx"), - "utf8" - ) - - it("renders a dialog title Create workspace", () => { - expect(source).toContain("Create workspace") - }) - - it("has a name input field", () => { - expect(source).toContain("ws-name") - }) - - it("accepts orgId prop", () => { - expect(source).toContain("orgId") - }) - - it("imports createWorkspace from org-api", () => { - expect(source).toContain("createWorkspace") - expect(source).toContain("org-api") - }) - - it("calls loadFromBackend after successful creation", () => { - expect(source).toContain("loadFromBackend") - }) - - it("uses toast from sonner for notifications", () => { - expect(source).toContain("toast") - expect(source).toContain("sonner") - }) - - it("shows loading state with Loader2", () => { - expect(source).toContain("Loader2") - }) - - it("uses shadcn Dialog primitives", () => { - expect(source).toContain("DialogContent") - expect(source).toContain("DialogHeader") - expect(source).toContain("DialogTitle") - }) - - it("has cancel and create buttons", () => { - expect(source).toContain("Cancel") - expect(source).toContain("Create") - }) - - it("uses use client directive", () => { - expect(source).toContain('"use client"') - }) -}) - -describe("CreateWorkspaceDialog — API contract (logic simulation)", () => { - beforeEach(() => jest.clearAllMocks()) - - it("createWorkspace is called with orgId and trimmed name", async () => { - const { createWorkspace } = require("@/lib/org-api") - ;(createWorkspace as jest.Mock).mockResolvedValueOnce({ id: "w1", name: "Dev" }) - - const { useWorkspaceStore } = require("@/store/workspace-store") - const loadFromBackend = jest.fn().mockResolvedValueOnce(undefined) - ;(useWorkspaceStore.getState as jest.Mock).mockReturnValueOnce({ loadFromBackend }) - - // Simulate submit handler logic - const orgId = "o1" - const name = " Dev " - const trimmed = name.trim() - await createWorkspace(orgId, trimmed) - await loadFromBackend() - - expect(createWorkspace).toHaveBeenCalledWith("o1", "Dev") - expect(loadFromBackend).toHaveBeenCalledTimes(1) - }) - - it("toast.error is called on createWorkspace failure", async () => { - const { createWorkspace } = require("@/lib/org-api") - ;(createWorkspace as jest.Mock).mockRejectedValueOnce(new Error("createWorkspace failed (500)")) - - const { toast } = require("sonner") - - try { - await createWorkspace("o1", "Bad") - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to create workspace") - } - - expect(toast.error).toHaveBeenCalledWith("createWorkspace failed (500)") - }) -}) diff --git a/apps/web/src/components/__tests__/enable-encrypted-tools-cta.test.ts b/apps/web/src/components/__tests__/enable-encrypted-tools-cta.test.ts deleted file mode 100644 index 28de0c41..00000000 --- a/apps/web/src/components/__tests__/enable-encrypted-tools-cta.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Tests for EnableEncryptedToolsCta (Task 28 — Enable encrypted tools CTA) - * - * Environment: jest-environment-node — no DOM, no React rendering. - * Strategy: module-export check + source structure assertions + logic simulation. - */ - -jest.mock("@/lib/backend-auth", () => ({ - backendFetch: jest.fn(), -})) - -jest.mock("@/lib/workspace-dek-api", () => ({ - listMemberPublicKeys: jest.fn(), - rotateDek: jest.fn(), -})) - -jest.mock("@/lib/user-keypair-api", () => ({ - setKeypair: jest.fn(), - getKeypair: jest.fn(), -})) - -jest.mock("@/lib/workspace-crypto", () => ({ - generateUserKeypair: jest.fn(), - generateWorkspaceDek: jest.fn(), - wrapDekForMember: jest.fn(), - dekFingerprint: jest.fn(), - unwrapUserPrivateKey: jest.fn(), -})) - -jest.mock("@/store/master-key-store", () => ({ - useMasterKeyStore: jest.fn((sel: (s: { encryptionKey: null; vault: { salt: string } }) => unknown) => - sel({ encryptionKey: null, vault: { salt: "test-salt" } }), - ), -})) - -jest.mock("@/store/user-keypair-store", () => ({ - useUserKeypairStore: Object.assign( - jest.fn((sel: (s: { publicKey: null; privateKey: null }) => unknown) => - sel({ publicKey: null, privateKey: null }), - ), - { getState: jest.fn(() => ({ setKeypair: jest.fn() })) }, - ), -})) - -jest.mock("@/store/workspace-store", () => ({ - useWorkspaceStore: jest.fn((sel: (s: { loadFromBackend: jest.Mock }) => unknown) => - sel({ loadFromBackend: jest.fn() }), - ), -})) - -jest.mock("sonner", () => ({ - toast: { - success: jest.fn(), - error: jest.fn(), - warning: jest.fn(), - }, -})) - -describe("EnableEncryptedToolsCta — module exports", () => { - it("exports a EnableEncryptedToolsCta named function", () => { - const mod = require("../enable-encrypted-tools-cta") - expect(typeof mod.EnableEncryptedToolsCta).toBe("function") - }) -}) - -describe("EnableEncryptedToolsCta — source structure", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../enable-encrypted-tools-cta.tsx"), - "utf8", - ) - - it("uses the use client directive", () => { - expect(source).toContain('"use client"') - }) - - it("imports Lock and Loader2 from lucide-react", () => { - expect(source).toContain("Lock") - expect(source).toContain("Loader2") - expect(source).toContain("lucide-react") - }) - - it("imports generateWorkspaceDek from workspace-crypto", () => { - expect(source).toContain("generateWorkspaceDek") - expect(source).toContain("workspace-crypto") - }) - - it("imports listMemberPublicKeys and rotateDek from workspace-dek-api", () => { - expect(source).toContain("listMemberPublicKeys") - expect(source).toContain("rotateDek") - expect(source).toContain("workspace-dek-api") - }) - - it("imports useMasterKeyStore", () => { - expect(source).toContain("useMasterKeyStore") - }) - - it("imports useUserKeypairStore", () => { - expect(source).toContain("useUserKeypairStore") - }) - - it("imports setKeypair and getKeypair from user-keypair-api", () => { - expect(source).toContain("setKeypair") - expect(source).toContain("getKeypair") - expect(source).toContain("user-keypair-api") - }) - - it("accepts workspaceId prop", () => { - expect(source).toContain("workspaceId") - }) - - it("renders Enable encrypted tools button text", () => { - expect(source).toContain("Enable encrypted tools") - }) - - it("shows toast.error when master password is not unlocked", () => { - expect(source).toContain("Unlock your master password first") - }) - - it("warns about members without a keypair", () => { - expect(source).toContain("haven't published a keypair yet") - }) - - it("calls dekFingerprint before submitting", () => { - expect(source).toContain("dekFingerprint") - }) - - it("calls reloadStore after successful rotate-dek", () => { - expect(source).toContain("reloadStore") - }) -}) - -describe("EnableEncryptedToolsCta — handleEnable logic simulation", () => { - beforeEach(() => jest.clearAllMocks()) - - it("shows error toast when masterKey is null", async () => { - const { toast } = require("sonner") - - // Simulate handleEnable with no masterKey - const masterKey = null - if (!masterKey) { - toast.error("Unlock your master password first") - } - - expect(toast.error).toHaveBeenCalledWith("Unlock your master password first") - }) - - it("calls listMemberPublicKeys with the workspaceId", async () => { - const { listMemberPublicKeys } = require("@/lib/workspace-dek-api") - ;(listMemberPublicKeys as jest.Mock).mockResolvedValueOnce([ - { uid: "u1", email: "u1@test.com", publicKey: "pk1" }, - ]) - - await listMemberPublicKeys("ws-abc") - - expect(listMemberPublicKeys).toHaveBeenCalledWith("ws-abc") - }) - - it("calls wrapDekForMember for each member with a publicKey", async () => { - const { wrapDekForMember, generateWorkspaceDek, dekFingerprint } = require("@/lib/workspace-crypto") - const { rotateDek } = require("@/lib/workspace-dek-api") - - const fakeDek = { type: "secret" } as unknown as CryptoKey - ;(generateWorkspaceDek as jest.Mock).mockResolvedValueOnce(fakeDek) - ;(wrapDekForMember as jest.Mock).mockResolvedValue({ encrypted: "enc", iv: "iv", senderPublicKey: "spk" }) - ;(dekFingerprint as jest.Mock).mockResolvedValueOnce("fp-abc") - ;(rotateDek as jest.Mock).mockResolvedValueOnce(undefined) - - const members = [ - { uid: "u1", email: "u1@test.com", publicKey: "pk1" }, - { uid: "u2", email: null, publicKey: null }, - ] - const ready = members.filter((m) => m.publicKey) - const myPriv = {} as CryptoKey - const myPub = "my-pub" - - const dek = await generateWorkspaceDek() - const wraps = await Promise.all( - ready.map(async (m) => { - const wrapped = await wrapDekForMember(dek, myPriv, m.publicKey!, myPub) - return { uid: m.uid, wrapped } - }), - ) - const fp = await dekFingerprint(dek) - await rotateDek("ws-abc", { dekFingerprint: fp, wraps }) - - expect(wrapDekForMember).toHaveBeenCalledTimes(1) - expect(rotateDek).toHaveBeenCalledWith("ws-abc", { - dekFingerprint: "fp-abc", - wraps: [{ uid: "u1", wrapped: { encrypted: "enc", iv: "iv", senderPublicKey: "spk" } }], - }) - }) - - it("shows warning toast when some members have no publicKey", async () => { - const { toast } = require("sonner") - - const members = [ - { uid: "u1", email: "u1@test.com", publicKey: null }, - { uid: "u2", email: "u2@test.com", publicKey: null }, - ] - const missing = members.filter((m) => !m.publicKey) - if (missing.length > 0) { - toast.warning( - `${missing.length} member(s) haven't published a keypair yet. They'll receive a pending-wrap prompt on next login.`, - ) - } - - expect(toast.warning).toHaveBeenCalledWith( - "2 member(s) haven't published a keypair yet. They'll receive a pending-wrap prompt on next login.", - ) - }) - - it("shows success toast after successful enable", async () => { - const { toast } = require("sonner") - - // Simulate the success path - toast.success("Encryption enabled — encrypted tools are now available") - - expect(toast.success).toHaveBeenCalledWith( - "Encryption enabled — encrypted tools are now available", - ) - }) - - it("shows error toast on unexpected error", async () => { - const { toast } = require("sonner") - - const err = new Error("rotateDek failed (500)") - toast.error(err instanceof Error ? err.message : "Failed to enable encryption") - - expect(toast.error).toHaveBeenCalledWith("rotateDek failed (500)") - }) - - it("calls setKeypair with master vault salt", async () => { - const { setKeypair } = require("@/lib/user-keypair-api") - const { generateUserKeypair, unwrapUserPrivateKey } = require("@/lib/workspace-crypto") - - const fakeBlob = { publicKey: "pk-abc", privateKeyEncrypted: "enc-abc" } - ;(generateUserKeypair as jest.Mock).mockResolvedValueOnce(fakeBlob) - ;(unwrapUserPrivateKey as jest.Mock).mockResolvedValueOnce({} as CryptoKey) - ;(setKeypair as jest.Mock).mockResolvedValueOnce(undefined) - - await setKeypair({ - publicKey: fakeBlob.publicKey, - privateKeyEncrypted: fakeBlob.privateKeyEncrypted, - salt: "test-salt", - createdAt: expect.any(Number), - }) - - expect(setKeypair).toHaveBeenCalledWith( - expect.objectContaining({ - salt: "test-salt", - }), - ) - }) - - it("shows error toast when master vault is not initialized", async () => { - const { toast } = require("sonner") - const { useMasterKeyStore: originalMock } = require("@/store/master-key-store") - - // Mock with no vault - ;(originalMock as jest.Mock).mockImplementationOnce((sel) => - sel({ encryptionKey: { type: "CryptoKey" }, vault: null }), - ) - - const masterVault = null as { salt: string } | null - if (!masterVault?.salt) { - toast.error("Master vault not initialized") - } - - expect(toast.error).toHaveBeenCalledWith("Master vault not initialized") - }) -}) diff --git a/apps/web/src/components/__tests__/encrypted-tool-placeholder.test.ts b/apps/web/src/components/__tests__/encrypted-tool-placeholder.test.ts deleted file mode 100644 index f789ed1e..00000000 --- a/apps/web/src/components/__tests__/encrypted-tool-placeholder.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Tests for EncryptedToolPlaceholder (Task 17 — encrypted-tool gating in shared workspaces). - * - * Environment: jest-environment-node — no DOM, no React rendering. - * Strategy: verify named export, source-file structure, gate logic, and - * structural assertions on the 3 encrypted tool pages. - */ - -// ── Mocks ───────────────────────────────────────────────────────────────────── - -jest.mock("@/store/workspace-store", () => ({ - useWorkspaceStore: jest.fn((selector: (s: unknown) => unknown) => - selector({ - workspaces: [ - { id: "p1", is_personal: true, name: "Personal", slug: "personal", org_id: "o1", kind: "personal", ws_role: "owner" }, - { id: "s1", is_personal: false, name: "Shared", slug: "shared", org_id: "o1", kind: "shared", ws_role: "member" }, - ], - setActiveWorkspace: jest.fn(), - }) - ), - useActiveWorkspace: jest.fn(() => null), -})) - -jest.mock("next/link", () => ({ - __esModule: true, - default: jest.fn(), -})) - -jest.mock("lucide-react", () => ({ - Lock: jest.fn(), - ArrowRight: jest.fn(), -})) - -jest.mock("@/components/ui/button", () => ({ - Button: jest.fn(), -})) - -jest.mock("@/components/ui/card", () => ({ - Card: jest.fn(), - CardContent: jest.fn(), -})) - -// ── Component export ─────────────────────────────────────────────────────────── - -describe("EncryptedToolPlaceholder — module exports", () => { - it("exports a named EncryptedToolPlaceholder function component", () => { - const mod = require("../encrypted-tool-placeholder") - expect(typeof mod.EncryptedToolPlaceholder).toBe("function") - }) - - it("does NOT have a default export (named export only)", () => { - const mod = require("../encrypted-tool-placeholder") - expect(mod.default).toBeUndefined() - }) -}) - -// ── Source structure ─────────────────────────────────────────────────────────── - -describe("EncryptedToolPlaceholder — source structure", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../encrypted-tool-placeholder.tsx"), - "utf8" - ) - - it("uses use client directive", () => { - expect(source).toContain('"use client"') - }) - - it("imports useWorkspaceStore from workspace-store", () => { - expect(source).toContain("useWorkspaceStore") - expect(source).toContain("workspace-store") - }) - - it("imports Lock and ArrowRight from lucide-react", () => { - expect(source).toContain("Lock") - expect(source).toContain("ArrowRight") - expect(source).toContain("lucide-react") - }) - - it("accepts toolName prop", () => { - expect(source).toContain("toolName") - }) - - it("renders toolName in heading (Personal-only message)", () => { - expect(source).toContain("{toolName}") - expect(source).toContain("Personal-only") - }) - - it("contains the placeholder copy about encryption for shared workspaces", () => { - expect(source).toContain("End-to-end encryption for shared workspaces") - }) - - it("renders Switch to Personal button", () => { - expect(source).toContain("Switch to Personal") - }) - - it("finds personal workspace via workspaces.find(w => w.is_personal)", () => { - expect(source).toContain("is_personal") - expect(source).toContain("find") - }) - - it("calls setActiveWorkspace when switching to personal", () => { - expect(source).toContain("setActiveWorkspace") - }) - - it("guards Switch button on personal workspace existing", () => { - expect(source).toContain("personal &&") - }) -}) - -// ── Gate logic simulation ────────────────────────────────────────────────────── - -describe("EncryptedToolPlaceholder — workspace gate logic", () => { - type Workspace = { - id: string - is_personal: boolean - name: string - slug: string - org_id: string - kind: string - ws_role: string - } - - /** Mirrors the component's gate condition. */ - function shouldShowPlaceholder(activeWs: Workspace | null): boolean { - return activeWs !== null && !activeWs.is_personal - } - - it("shows placeholder when active workspace is shared (is_personal=false)", () => { - const shared: Workspace = { id: "s1", is_personal: false, name: "Shared", slug: "shared", org_id: "o1", kind: "shared", ws_role: "member" } - expect(shouldShowPlaceholder(shared)).toBe(true) - }) - - it("does NOT show placeholder when active workspace is personal", () => { - const personal: Workspace = { id: "p1", is_personal: true, name: "Personal", slug: "personal", org_id: "o1", kind: "personal", ws_role: "owner" } - expect(shouldShowPlaceholder(personal)).toBe(false) - }) - - it("does NOT show placeholder when no active workspace (null)", () => { - expect(shouldShowPlaceholder(null)).toBe(false) - }) - - it("personal workspace button shows when at least one personal workspace exists", () => { - const workspaces: Workspace[] = [ - { id: "p1", is_personal: true, name: "Personal", slug: "personal", org_id: "o1", kind: "personal", ws_role: "owner" }, - { id: "s1", is_personal: false, name: "Shared", slug: "shared", org_id: "o1", kind: "shared", ws_role: "member" }, - ] - const personal = workspaces.find((w) => w.is_personal) - expect(personal).toBeDefined() - expect(personal?.id).toBe("p1") - }) - - it("personal workspace button does NOT show when no personal workspace exists", () => { - const workspaces: Workspace[] = [ - { id: "s1", is_personal: false, name: "Shared", slug: "shared", org_id: "o1", kind: "shared", ws_role: "member" }, - ] - const personal = workspaces.find((w) => w.is_personal) - expect(personal).toBeUndefined() - }) -}) - -// ── Password Manager page gate ───────────────────────────────────────────────── - -describe("PasswordManagerPage — encrypted tool gate (source assertions)", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../../app/app/password-manager/page.tsx"), - "utf8" - ) - - it("imports EncryptedToolPlaceholder", () => { - expect(source).toContain("EncryptedToolPlaceholder") - expect(source).toContain("encrypted-tool-placeholder") - }) - - it("imports useActiveWorkspace from workspace-store", () => { - expect(source).toContain("useActiveWorkspace") - expect(source).toContain("workspace-store") - }) - - it("calls useActiveWorkspace and stores result as activeWs", () => { - expect(source).toContain("useActiveWorkspace()") - expect(source).toContain("activeWs") - }) - - it("gates on activeWs && !activeWs.is_personal", () => { - expect(source).toContain("activeWs && !activeWs.is_personal") - }) - - it("renders EncryptedToolPlaceholder with toolName 'Password Manager'", () => { - expect(source).toContain('toolName="Password Manager"') - }) -}) - -// ── Environment Manager page gate ────────────────────────────────────────────── - -describe("EnvironmentManagerPage — encrypted tool gate (source assertions)", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../../app/app/environment-manager/page.tsx"), - "utf8" - ) - - it("imports EncryptedToolPlaceholder", () => { - expect(source).toContain("EncryptedToolPlaceholder") - expect(source).toContain("encrypted-tool-placeholder") - }) - - it("imports useActiveWorkspace from workspace-store", () => { - expect(source).toContain("useActiveWorkspace") - expect(source).toContain("workspace-store") - }) - - it("calls useActiveWorkspace and stores result as activeWs", () => { - expect(source).toContain("useActiveWorkspace()") - expect(source).toContain("activeWs") - }) - - it("gates on activeWs && !activeWs.is_personal (with forward-compat encryption check)", () => { - // C-T7: gate extended to forward-compat form: checks !activeWs.is_personal AND - // that settings?.encryption is absent (so C-T9 can enable the tool per-workspace). - expect(source).toContain("!activeWs.is_personal") - expect(source).toContain("settings?.encryption") - }) - - it("renders EncryptedToolPlaceholder with toolName 'Environment Manager'", () => { - expect(source).toContain('toolName="Environment Manager"') - }) -}) - -// ── API Keys (API Key Vault) page gate ──────────────────────────────────────── - -describe("ApiKeyVaultPage — encrypted tool gate (source assertions)", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../../app/app/api-keys/page.tsx"), - "utf8" - ) - - it("imports EncryptedToolPlaceholder", () => { - expect(source).toContain("EncryptedToolPlaceholder") - expect(source).toContain("encrypted-tool-placeholder") - }) - - it("imports useActiveWorkspace from workspace-store", () => { - expect(source).toContain("useActiveWorkspace") - expect(source).toContain("workspace-store") - }) - - it("calls useActiveWorkspace and stores result as activeWs", () => { - expect(source).toContain("useActiveWorkspace()") - expect(source).toContain("activeWs") - }) - - it("gates on activeWs && !activeWs.is_personal (with forward-compat encryption check)", () => { - // C-T8: gate extended to forward-compat form: checks !activeWs.is_personal AND - // that settings?.encryption is absent (so C-T9 can enable the tool per-workspace). - expect(source).toContain("!activeWs.is_personal") - expect(source).toContain("settings?.encryption") - }) - - it("renders EncryptedToolPlaceholder with toolName 'API Key Vault'", () => { - expect(source).toContain('toolName="API Key Vault"') - }) -}) diff --git a/apps/web/src/components/__tests__/invite-member-dialog.test.ts b/apps/web/src/components/__tests__/invite-member-dialog.test.ts deleted file mode 100644 index 0f9424b3..00000000 --- a/apps/web/src/components/__tests__/invite-member-dialog.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Tests for InviteMemberDialog (Task 14 — workspace collaboration) - * - * Environment: jest-environment-node — no DOM, no React rendering. - * Strategy: verify module exports and source-file structure. - */ - -jest.mock("@/lib/backend-auth", () => ({ - backendFetch: jest.fn(), -})) - -jest.mock("@/lib/invitations-api", () => ({ - inviteToOrg: jest.fn(), - inviteToWorkspace: jest.fn(), -})) - -jest.mock("sonner", () => ({ - toast: { success: jest.fn(), error: jest.fn() }, -})) - -describe("InviteMemberDialog — module exports", () => { - it("exports an InviteMemberDialog named function component", () => { - const mod = require("../invite-member-dialog") - expect(typeof mod.InviteMemberDialog).toBe("function") - }) -}) - -describe("InviteMemberDialog — source structure assertions", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../invite-member-dialog.tsx"), - "utf8" - ) - - it("has an email input field", () => { - expect(source).toContain("invite-email") - }) - - it("has a role select field", () => { - expect(source).toContain("invite-role") - }) - - it("accepts scope and scopeId props", () => { - expect(source).toContain("scope") - expect(source).toContain("scopeId") - }) - - it("imports inviteToOrg from invitations-api", () => { - expect(source).toContain("inviteToOrg") - expect(source).toContain("invitations-api") - }) - - it("imports inviteToWorkspace from invitations-api", () => { - expect(source).toContain("inviteToWorkspace") - }) - - it("uses toast from sonner for notifications", () => { - expect(source).toContain("toast") - expect(source).toContain("sonner") - }) - - it("shows loading state with Loader2", () => { - expect(source).toContain("Loader2") - }) - - it("uses shadcn Dialog primitives", () => { - expect(source).toContain("DialogContent") - expect(source).toContain("DialogHeader") - expect(source).toContain("DialogTitle") - }) - - it("uses shadcn Select primitives for role", () => { - expect(source).toContain("SelectContent") - expect(source).toContain("SelectItem") - expect(source).toContain("SelectTrigger") - }) - - it("includes org and workspace role options", () => { - // org roles - expect(source).toContain("admin") - expect(source).toContain("member") - // workspace-specific - expect(source).toContain("developer") - expect(source).toContain("viewer") - }) - - it("has cancel and send invite buttons", () => { - expect(source).toContain("Cancel") - expect(source).toContain("Send invite") - }) - - it("uses use client directive", () => { - expect(source).toContain('"use client"') - }) - - it("dispatches to inviteToOrg when scope is org", () => { - expect(source).toContain("scope === \"org\"") - }) -}) - -describe("InviteMemberDialog — API contract (logic simulation)", () => { - beforeEach(() => jest.clearAllMocks()) - - it("inviteToOrg called with scopeId, email, role when scope=org", async () => { - const { inviteToOrg } = require("@/lib/invitations-api") - ;(inviteToOrg as jest.Mock).mockResolvedValueOnce({ id: "inv1", status: "pending" }) - - const { toast } = require("sonner") - - // Simulate submit with scope=org - const scope = "org" - const scopeId = "o1" - const email = "alice@example.com" - const role = "member" - - if (scope === "org") { - await inviteToOrg(scopeId, email, role) - } - toast.success(`Invitation sent to ${email}`) - - expect(inviteToOrg).toHaveBeenCalledWith("o1", "alice@example.com", "member") - expect(toast.success).toHaveBeenCalledWith("Invitation sent to alice@example.com") - }) - - it("inviteToWorkspace called with scopeId, email, role when scope=workspace", async () => { - const { inviteToWorkspace } = require("@/lib/invitations-api") - ;(inviteToWorkspace as jest.Mock).mockResolvedValueOnce({ id: "inv2", status: "pending" }) - - // Simulate submit with scope=workspace - const scope = "workspace" - const scopeId = "w1" - const email = "bob@example.com" - const role = "developer" - - if (scope === "workspace") { - await inviteToWorkspace(scopeId, email, role) - } - - expect(inviteToWorkspace).toHaveBeenCalledWith("w1", "bob@example.com", "developer") - }) - - it("toast.error is called on invitation failure", async () => { - const { inviteToOrg } = require("@/lib/invitations-api") - ;(inviteToOrg as jest.Mock).mockRejectedValueOnce(new Error("inviteToOrg failed (422)")) - - const { toast } = require("sonner") - - try { - await inviteToOrg("o1", "bad@example.com", "member") - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to send invitation") - } - - expect(toast.error).toHaveBeenCalledWith("inviteToOrg failed (422)") - }) -}) diff --git a/apps/web/src/components/__tests__/member-list.test.ts b/apps/web/src/components/__tests__/member-list.test.ts deleted file mode 100644 index d329e929..00000000 --- a/apps/web/src/components/__tests__/member-list.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Tests for MemberList (Task 15 — workspace management page) - * - * Environment: jest-environment-node — no DOM, no React rendering. - * Strategy: verify module exports and source-file structure. - */ - -jest.mock("@/lib/backend-auth", () => ({ - backendFetch: jest.fn(), -})) - -jest.mock("@/lib/members-api", () => ({ - listOrgMembers: jest.fn(), - listWorkspaceMembers: jest.fn(), - changeOrgRole: jest.fn(), - changeWorkspaceRole: jest.fn(), - removeOrgMember: jest.fn(), - removeWorkspaceMember: jest.fn(), -})) - -jest.mock("sonner", () => ({ - toast: { success: jest.fn(), error: jest.fn() }, -})) - -describe("MemberList — module exports", () => { - it("exports a MemberList named function component", () => { - const mod = require("../member-list") - expect(typeof mod.MemberList).toBe("function") - }) -}) - -describe("MemberList — source structure assertions", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../member-list.tsx"), - "utf8" - ) - - it("uses use client directive", () => { - expect(source).toContain('"use client"') - }) - - it("accepts scope and scopeId props", () => { - expect(source).toContain("scope") - expect(source).toContain("scopeId") - }) - - it("imports listOrgMembers from members-api", () => { - expect(source).toContain("listOrgMembers") - expect(source).toContain("members-api") - }) - - it("imports listWorkspaceMembers from members-api", () => { - expect(source).toContain("listWorkspaceMembers") - }) - - it("imports changeOrgRole and changeWorkspaceRole", () => { - expect(source).toContain("changeOrgRole") - expect(source).toContain("changeWorkspaceRole") - }) - - it("imports removeOrgMember and removeWorkspaceMember", () => { - expect(source).toContain("removeOrgMember") - expect(source).toContain("removeWorkspaceMember") - }) - - it("uses RoleSelect component", () => { - expect(source).toContain("RoleSelect") - expect(source).toContain("role-select") - }) - - it("uses toast from sonner", () => { - expect(source).toContain("toast") - expect(source).toContain("sonner") - }) - - it("shows loading state with Loader2", () => { - expect(source).toContain("Loader2") - }) - - it("shows Remove member button", () => { - expect(source).toContain("Remove member") - }) - - it("dispatches to org or workspace APIs based on scope", () => { - expect(source).toContain('scope === "org"') - }) - - it("confirms via useConfirm dialog before removing member", () => { - expect(source).toContain("useConfirm") - expect(source).toContain("confirm({") - }) -}) - -describe("MemberList — API contract (logic simulation)", () => { - beforeEach(() => jest.clearAllMocks()) - - it("listOrgMembers called with orgId when scope=org", async () => { - const { listOrgMembers } = require("@/lib/members-api") - ;(listOrgMembers as jest.Mock).mockResolvedValueOnce([ - { uid: "u1", email: "a@example.com", display_name: "Alice", role: "admin", since: 1 }, - ]) - - const result = await listOrgMembers("org-1") - expect(listOrgMembers).toHaveBeenCalledWith("org-1") - expect(result).toHaveLength(1) - expect(result[0].role).toBe("admin") - }) - - it("listWorkspaceMembers called with wsId when scope=workspace", async () => { - const { listWorkspaceMembers } = require("@/lib/members-api") - ;(listWorkspaceMembers as jest.Mock).mockResolvedValueOnce([ - { uid: "u2", email: "b@example.com", display_name: "Bob", role: "developer", since: 2 }, - ]) - - const result = await listWorkspaceMembers("ws-1") - expect(listWorkspaceMembers).toHaveBeenCalledWith("ws-1") - expect(result[0].role).toBe("developer") - }) - - it("changeOrgRole called with correct args", async () => { - const { changeOrgRole } = require("@/lib/members-api") - ;(changeOrgRole as jest.Mock).mockResolvedValueOnce({ uid: "u1", role: "member" }) - - await changeOrgRole("org-1", "u1", "member") - expect(changeOrgRole).toHaveBeenCalledWith("org-1", "u1", "member") - }) - - it("removeWorkspaceMember called with correct args", async () => { - const { removeWorkspaceMember } = require("@/lib/members-api") - ;(removeWorkspaceMember as jest.Mock).mockResolvedValueOnce(undefined) - - await removeWorkspaceMember("ws-1", "u2") - expect(removeWorkspaceMember).toHaveBeenCalledWith("ws-1", "u2") - }) - - it("toast.error called on listOrgMembers failure", async () => { - const { listOrgMembers } = require("@/lib/members-api") - ;(listOrgMembers as jest.Mock).mockRejectedValueOnce(new Error("listOrgMembers failed (500)")) - - const { toast } = require("sonner") - - try { - await listOrgMembers("bad-org") - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to load members") - } - - expect(toast.error).toHaveBeenCalledWith("listOrgMembers failed (500)") - }) -}) diff --git a/apps/web/src/components/__tests__/pending-invitations-badge.test.ts b/apps/web/src/components/__tests__/pending-invitations-badge.test.ts deleted file mode 100644 index 7694ec55..00000000 --- a/apps/web/src/components/__tests__/pending-invitations-badge.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -/** - * Tests for PendingInvitationsBadge (Task 16 — pending invitations + token auto-accept) - * - * Environment: jest-environment-node — no DOM, no React rendering. - * Strategy: verify module exports, source-file structure, and API logic contracts. - * - * Test 1: exports PendingInvitationsBadge function - * Test 2: returns null when listPending returns [] - * Test 3: polls using setInterval with 30s interval - * Test 4: calls acceptInvitation with invitation id (token) - * Test 5: calls loadFromBackend after accepting - * Test 6: calls setActiveWorkspace when workspace_id is non-null - * Test 7: toasts success on accept - * Test 8: toasts error when acceptInvitation throws - * Test 9: re-fetches listPending after accept - * Test 10: source uses setInterval with POLL_INTERVAL_MS - * Test 11: source renders count badge when invitations present - * Test 12: source renders Accept button per row - */ - -jest.mock("@/lib/backend-auth", () => ({ - backendFetch: jest.fn(), -})) - -jest.mock("@/lib/invitations-api", () => ({ - listPending: jest.fn(), - acceptInvitation: jest.fn(), -})) - -jest.mock("@/store/workspace-store", () => ({ - useWorkspaceStore: Object.assign( - jest.fn(() => ({ - orgs: [], - workspaces: [], - activeWorkspaceId: null, - hydrated: false, - })), - { - getState: jest.fn(() => ({ - loadFromBackend: jest.fn().mockResolvedValue(undefined), - setActiveWorkspace: jest.fn().mockResolvedValue(undefined), - })), - } - ), -})) - -jest.mock("sonner", () => ({ - toast: { success: jest.fn(), error: jest.fn() }, -})) - -jest.mock("lucide-react", () => ({ - Bell: () => null, - Loader2: () => null, -})) - -jest.mock("@/components/ui/dropdown-menu", () => ({ - DropdownMenu: ({ children }: any) => children, - DropdownMenuContent: ({ children }: any) => children, - DropdownMenuItem: ({ children }: any) => children, - DropdownMenuLabel: ({ children }: any) => children, - DropdownMenuSeparator: () => null, - DropdownMenuTrigger: ({ children }: any) => children, -})) - -jest.mock("@/components/ui/button", () => ({ - Button: ({ children }: any) => children, -})) - -jest.mock("@/components/ui/badge", () => ({ - Badge: ({ children }: any) => children, -})) - -import type { Invitation } from "@/lib/invitations-api" - -const PENDING_INV: Invitation = { - id: "inv-token-123", - org_id: "o1", - workspace_id: "w1", - invited_email: "alice@example.com", - invited_uid: null, - invited_role_org: null, - invited_role_ws: "developer", - status: "pending", - token: "dGVzdC10b2tlbi1hYmMxMjM=", - expires_at: Date.now() + 86400000, - created_at: Date.now(), -} - -const PENDING_ORG_INV: Invitation = { - id: "inv-org-456", - org_id: "o2", - workspace_id: null, - invited_email: "bob@example.com", - invited_uid: null, - invited_role_org: "member", - invited_role_ws: null, - status: "pending", - token: "dGVzdC10b2tlbi1vcmc0NTY=", - expires_at: Date.now() + 86400000, - created_at: Date.now(), -} - -describe("PendingInvitationsBadge — module exports", () => { - it("exports a PendingInvitationsBadge named function component", () => { - const mod = require("../pending-invitations-badge") - expect(typeof mod.PendingInvitationsBadge).toBe("function") - }) -}) - -describe("PendingInvitationsBadge — null-render when no pending invitations", () => { - beforeEach(() => jest.clearAllMocks()) - - it("returns null when listPending resolves to empty array", async () => { - const { listPending } = require("@/lib/invitations-api") - ;(listPending as jest.Mock).mockResolvedValueOnce([]) - - const pending = await listPending() - // Badge logic: if (invitations.length === 0) return null - const wouldRender = pending.length > 0 - expect(wouldRender).toBe(false) - }) - - it("renders badge when listPending resolves to non-empty array", async () => { - const { listPending } = require("@/lib/invitations-api") - ;(listPending as jest.Mock).mockResolvedValueOnce([PENDING_INV]) - - const pending = await listPending() - const wouldRender = pending.length > 0 - expect(wouldRender).toBe(true) - }) -}) - -describe("PendingInvitationsBadge — polling behaviour", () => { - beforeEach(() => jest.clearAllMocks()) - - it("source uses setInterval for polling", () => { - const fs = require("fs") - const path = require("path") - const source: string = fs.readFileSync( - path.join(__dirname, "../pending-invitations-badge.tsx"), - "utf8" - ) - expect(source).toContain("setInterval") - expect(source).toContain("clearInterval") - }) - - it("source defines POLL_INTERVAL_MS = 30000", () => { - const fs = require("fs") - const path = require("path") - const source: string = fs.readFileSync( - path.join(__dirname, "../pending-invitations-badge.tsx"), - "utf8" - ) - expect(source).toContain("30_000") - }) - - it("listPending is called on poll", async () => { - const { listPending } = require("@/lib/invitations-api") - ;(listPending as jest.Mock).mockResolvedValue([]) - - await listPending() - expect(listPending).toHaveBeenCalledTimes(1) - }) -}) - -describe("PendingInvitationsBadge — accept flow", () => { - beforeEach(() => jest.clearAllMocks()) - - it("calls acceptInvitation with the invitation token", async () => { - const { acceptInvitation } = require("@/lib/invitations-api") - ;(acceptInvitation as jest.Mock).mockResolvedValueOnce({ - org_id: "o1", - workspace_id: "w1", - }) - - await acceptInvitation(PENDING_INV.token) - expect(acceptInvitation).toHaveBeenCalledWith("dGVzdC10b2tlbi1hYmMxMjM=") - }) - - it("calls loadFromBackend after accepting", async () => { - const { acceptInvitation } = require("@/lib/invitations-api") - ;(acceptInvitation as jest.Mock).mockResolvedValueOnce({ - org_id: "o1", - workspace_id: "w1", - }) - - const { useWorkspaceStore } = require("@/store/workspace-store") - const { loadFromBackend, setActiveWorkspace } = useWorkspaceStore.getState() - - await acceptInvitation(PENDING_INV.token) - await loadFromBackend() - - expect(loadFromBackend).toHaveBeenCalledTimes(1) - }) - - it("calls setActiveWorkspace when workspace_id is non-null", async () => { - const { acceptInvitation } = require("@/lib/invitations-api") - ;(acceptInvitation as jest.Mock).mockResolvedValueOnce({ - org_id: "o1", - workspace_id: "w1", - }) - - const { useWorkspaceStore } = require("@/store/workspace-store") - const { setActiveWorkspace } = useWorkspaceStore.getState() - - const result = await acceptInvitation(PENDING_INV.token) - if (result.workspace_id) { - await setActiveWorkspace(result.workspace_id) - } - - expect(setActiveWorkspace).toHaveBeenCalledWith("w1") - }) - - it("does NOT call setActiveWorkspace when workspace_id is null (org-only invite)", async () => { - const { acceptInvitation } = require("@/lib/invitations-api") - ;(acceptInvitation as jest.Mock).mockResolvedValueOnce({ - org_id: "o2", - workspace_id: null, - }) - - const { useWorkspaceStore } = require("@/store/workspace-store") - const { setActiveWorkspace } = useWorkspaceStore.getState() - - const result = await acceptInvitation(PENDING_ORG_INV.token) - if (result.workspace_id) { - await setActiveWorkspace(result.workspace_id) - } - - expect(setActiveWorkspace).not.toHaveBeenCalled() - }) - - it("toasts success on successful accept", async () => { - const { toast } = require("sonner") - toast.success("Invitation accepted") - expect(toast.success).toHaveBeenCalledWith("Invitation accepted") - }) - - it("toasts error when acceptInvitation throws", async () => { - const { acceptInvitation } = require("@/lib/invitations-api") - ;(acceptInvitation as jest.Mock).mockRejectedValueOnce(new Error("acceptInvitation failed (404)")) - - const { toast } = require("sonner") - - try { - await acceptInvitation("bad-token") - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to accept invitation") - } - - expect(toast.error).toHaveBeenCalledWith("acceptInvitation failed (404)") - }) - - it("re-fetches listPending after accept", async () => { - const { listPending, acceptInvitation } = require("@/lib/invitations-api") - ;(acceptInvitation as jest.Mock).mockResolvedValueOnce({ - org_id: "o1", - workspace_id: "w1", - }) - ;(listPending as jest.Mock).mockResolvedValue([]) - - await acceptInvitation(PENDING_INV.token) - await listPending() - - expect(listPending).toHaveBeenCalledTimes(1) - }) -}) - -describe("PendingInvitationsBadge — source structure assertions", () => { - const fs = require("fs") - const path = require("path") - const source: string = fs.readFileSync( - path.join(__dirname, "../pending-invitations-badge.tsx"), - "utf8" - ) - - it("uses use client directive", () => { - expect(source).toContain('"use client"') - }) - - it("imports listPending and acceptInvitation from invitations-api", () => { - expect(source).toContain("listPending") - expect(source).toContain("acceptInvitation") - expect(source).toContain("invitations-api") - }) - - it("imports loadFromBackend via useWorkspaceStore.getState()", () => { - expect(source).toContain("useWorkspaceStore") - expect(source).toContain("loadFromBackend") - }) - - it("imports toast from sonner", () => { - expect(source).toContain("sonner") - expect(source).toContain("toast") - }) - - it("renders count badge when invitations are present", () => { - // A count indicator is rendered (the span with the count) - expect(source).toContain("invitations.length") - }) - - it("renders an Accept button per invitation row", () => { - expect(source).toContain("Accept") - expect(source).toContain("handleAccept") - }) - - it("uses DropdownMenu for the invitation list", () => { - expect(source).toContain("DropdownMenu") - expect(source).toContain("DropdownMenuContent") - expect(source).toContain("DropdownMenuTrigger") - }) - - it("shows loading state while accepting (Loader2 spinner)", () => { - expect(source).toContain("Loader2") - expect(source).toContain("animate-spin") - }) -}) - -describe("PendingInvitationsBadge — WorkspaceSwitcherDropdown integration", () => { - it("WorkspaceSwitcherDropdown source imports PendingInvitationsBadge", () => { - const fs = require("fs") - const path = require("path") - const source: string = fs.readFileSync( - path.join(__dirname, "../workspace-switcher-dropdown.tsx"), - "utf8" - ) - expect(source).toContain("PendingInvitationsBadge") - expect(source).toContain("pending-invitations-badge") - }) - - it("WorkspaceSwitcherDropdown renders PendingInvitationsBadge in JSX", () => { - const fs = require("fs") - const path = require("path") - const source: string = fs.readFileSync( - path.join(__dirname, "../workspace-switcher-dropdown.tsx"), - "utf8" - ) - expect(source).toContain(" { - it("login-form source imports acceptInvitation", () => { - const fs = require("fs") - const path = require("path") - const source: string = fs.readFileSync( - path.join(__dirname, "../login-form.tsx"), - "utf8" - ) - expect(source).toContain("acceptInvitation") - expect(source).toContain("invitations-api") - }) - - it("login-form source imports useWorkspaceStore", () => { - const fs = require("fs") - const path = require("path") - const source: string = fs.readFileSync( - path.join(__dirname, "../login-form.tsx"), - "utf8" - ) - expect(source).toContain("useWorkspaceStore") - }) - - it("login-form source reads invite param from URL search", () => { - const fs = require("fs") - const path = require("path") - const source: string = fs.readFileSync( - path.join(__dirname, "../login-form.tsx"), - "utf8" - ) - expect(source).toContain("invite") - expect(source).toContain("window.location.search") - }) - - it("login-form source has handleInviteToken helper", () => { - const fs = require("fs") - const path = require("path") - const source: string = fs.readFileSync( - path.join(__dirname, "../login-form.tsx"), - "utf8" - ) - expect(source).toContain("handleInviteToken") - }) - - it("handleInviteToken returns /dashboard as fallback when no invite param", async () => { - // Simulate the logic: no ?invite= in URL → return "/dashboard" - const params = new URLSearchParams("") - const token = params.get("invite") - const destination = token ? "/some-workspace" : "/dashboard" - expect(destination).toBe("/dashboard") - }) - - it("handleInviteToken calls acceptInvitation when invite param is present", async () => { - const { acceptInvitation } = require("@/lib/invitations-api") - ;(acceptInvitation as jest.Mock).mockResolvedValueOnce({ - org_id: "o1", - workspace_id: "w1", - }) - - const params = new URLSearchParams("invite=some-token") - const token = params.get("invite") - expect(token).toBe("some-token") - - if (token) { - await acceptInvitation(token) - } - expect(acceptInvitation).toHaveBeenCalledWith("some-token") - }) - - it("login-form redirects to /dashboard even when invite token is invalid", async () => { - const { acceptInvitation } = require("@/lib/invitations-api") - ;(acceptInvitation as jest.Mock).mockRejectedValueOnce(new Error("acceptInvitation failed (404)")) - - const { toast } = require("sonner") - - let destination = "/dashboard" - try { - await acceptInvitation("bad-token") - } catch (err) { - toast.error(err instanceof Error ? err.message : "Could not accept invitation") - destination = "/dashboard" - } - - expect(toast.error).toHaveBeenCalledWith("acceptInvitation failed (404)") - expect(destination).toBe("/dashboard") - }) -}) diff --git a/apps/web/src/components/__tests__/pending-wraps-prompt.test.ts b/apps/web/src/components/__tests__/pending-wraps-prompt.test.ts deleted file mode 100644 index de68213c..00000000 --- a/apps/web/src/components/__tests__/pending-wraps-prompt.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -/** - * Tests for PendingWrapsPrompt (Task 29 — Pending wraps prompt) - * - * Environment: jest-environment-node — no DOM, no React rendering. - * Strategy: module-export check + source structure assertions + logic simulation. - */ - -jest.mock("@/lib/backend-auth", () => ({ - backendFetch: jest.fn(), -})) - -jest.mock("@/lib/workspace-dek-api", () => ({ - listPendingWraps: jest.fn(), - postDekWrap: jest.fn(), -})) - -jest.mock("@/lib/user-keypair-api", () => ({ - getKeypair: jest.fn(), -})) - -jest.mock("@/lib/workspace-crypto", () => ({ - unwrapUserPrivateKey: jest.fn(), - wrapDekForMember: jest.fn(), -})) - -jest.mock("@/store/master-key-store", () => ({ - useMasterKeyStore: jest.fn((sel: (s: { encryptionKey: null; vault: { salt: string } }) => unknown) => - sel({ encryptionKey: null, vault: { salt: "test-salt" } }), - ), -})) - -jest.mock("@/store/user-keypair-store", () => ({ - useUserKeypairStore: Object.assign( - jest.fn((sel: (s: { publicKey: null; privateKey: null }) => unknown) => - sel({ publicKey: null, privateKey: null }), - ), - { getState: jest.fn(() => ({ setKeypair: jest.fn() })) }, - ), -})) - -jest.mock("@/store/workspace-dek-store", () => ({ - useWorkspaceDekStore: Object.assign( - jest.fn(), - { getState: jest.fn(() => ({ getDek: jest.fn() })) }, - ), -})) - -jest.mock("sonner", () => ({ - toast: { - success: jest.fn(), - error: jest.fn(), - warning: jest.fn(), - }, -})) - -const fs = require("fs") -const path = require("path") -const sourcePath = path.join(__dirname, "../pending-wraps-prompt.tsx") -const source = fs.readFileSync(sourcePath, "utf8") - -// ── Module exports ───────────────────────────────────────────────────────────── - -describe("PendingWrapsPrompt — module exports", () => { - it("exports a PendingWrapsPrompt named function", () => { - const mod = require("../pending-wraps-prompt") - expect(typeof mod.PendingWrapsPrompt).toBe("function") - }) -}) - -// ── Source structure ─────────────────────────────────────────────────────────── - -describe("PendingWrapsPrompt — source structure", () => { - it("uses the use client directive", () => { - expect(source).toContain('"use client"') - }) - - it("imports Lock and Loader2 from lucide-react", () => { - expect(source).toContain("Lock") - expect(source).toContain("Loader2") - expect(source).toContain("lucide-react") - }) - - it("imports listPendingWraps and postDekWrap from workspace-dek-api", () => { - expect(source).toContain("listPendingWraps") - expect(source).toContain("postDekWrap") - expect(source).toContain("workspace-dek-api") - }) - - it("imports useMasterKeyStore", () => { - expect(source).toContain("useMasterKeyStore") - }) - - it("imports useUserKeypairStore", () => { - expect(source).toContain("useUserKeypairStore") - }) - - it("imports useWorkspaceDekStore", () => { - expect(source).toContain("useWorkspaceDekStore") - }) - - it("imports getKeypair from user-keypair-api", () => { - expect(source).toContain("getKeypair") - expect(source).toContain("user-keypair-api") - }) - - it("imports unwrapUserPrivateKey and wrapDekForMember from workspace-crypto", () => { - expect(source).toContain("unwrapUserPrivateKey") - expect(source).toContain("wrapDekForMember") - expect(source).toContain("workspace-crypto") - }) - - it("accepts workspaceId prop", () => { - expect(source).toContain("workspaceId") - }) - - it("renders 'Pending encryption wraps' heading text", () => { - expect(source).toContain("Pending encryption wraps") - }) - - it("renders 'Complete wraps' CTA text", () => { - expect(source).toContain("Complete wraps") - }) - - it("shows 'still need to publish their keypair' message", () => { - expect(source).toContain("still need to publish their keypair") - }) - - it("shows toast.error when master password is not unlocked", () => { - expect(source).toContain("Unlock your master password first") - }) - - it("shows toast.error when DEK is not available", () => { - expect(source).toContain("You don't have access to this workspace's encryption key") - }) - - it("calls getDek from workspace dek store", () => { - expect(source).toContain("getDek") - }) - - it("uses useEffect to fetch pending wraps on mount", () => { - expect(source).toContain("useEffect") - }) - - it("returns null when pending list is empty", () => { - // Source must guard on pending.length === 0 - expect(source).toContain("pending.length === 0") - }) -}) - -// ── Logic simulation ─────────────────────────────────────────────────────────── - -describe("PendingWrapsPrompt — handleCompleteWraps logic simulation", () => { - beforeEach(() => jest.clearAllMocks()) - - it("renders null when listPendingWraps returns empty list", () => { - // Simulate the guard: if (!pending || pending.length === 0) return null - const pending: { uid: string; email: string | null; publicKey: string | null }[] = [] - const result = !pending || pending.length === 0 ? null : "rendered" - expect(result).toBeNull() - }) - - it("renders CTA when at least one pending member has a publicKey", () => { - const pending = [ - { uid: "u1", email: "u1@test.com", publicKey: "pk1" }, - ] - const ready = pending.filter((m) => m.publicKey) - // Component shows button only when ready.length > 0 - expect(ready.length).toBe(1) - const result = ready.length > 0 ? "Complete wraps button shown" : null - expect(result).toBe("Complete wraps button shown") - }) - - it("shows 'still need to publish' message when some pending members lack publicKeys", () => { - const pending = [ - { uid: "u1", email: "u1@test.com", publicKey: "pk1" }, - { uid: "u2", email: "u2@test.com", publicKey: null }, - ] - const stillPending = pending.filter((m) => !m.publicKey) - expect(stillPending.length).toBe(1) - // Source renders `${stillPending.length} still need to publish their keypair.` - const message = `${stillPending.length} still need to publish their keypair.` - expect(message).toContain("still need to publish their keypair") - }) - - it("shows error toast when masterKey is null", async () => { - const { toast } = require("sonner") - const masterKey = null - if (!masterKey) { - toast.error("Unlock your master password first") - } - expect(toast.error).toHaveBeenCalledWith("Unlock your master password first") - }) - - it("shows error toast when master vault is not initialized", async () => { - const { toast } = require("sonner") - const masterKey = { type: "CryptoKey" } as { type: string } | null - const masterVault = null as { salt: string } | null - if (masterKey && !masterVault?.salt) { - toast.error("Master vault not initialized") - } - expect(toast.error).toHaveBeenCalledWith("Master vault not initialized") - }) - - it("shows error toast when getDek returns null", async () => { - const { toast } = require("sonner") - const { useWorkspaceDekStore } = require("@/store/workspace-dek-store") - ;(useWorkspaceDekStore.getState as jest.Mock).mockReturnValueOnce({ - getDek: jest.fn().mockResolvedValueOnce(null), - }) - - const dek = await useWorkspaceDekStore.getState().getDek("ws-abc") - if (!dek) { - toast.error("You don't have access to this workspace's encryption key") - } - - expect(toast.error).toHaveBeenCalledWith( - "You don't have access to this workspace's encryption key", - ) - }) - - it("calls listPendingWraps with the correct workspaceId", async () => { - const { listPendingWraps } = require("@/lib/workspace-dek-api") - ;(listPendingWraps as jest.Mock).mockResolvedValueOnce([ - { uid: "u1", email: "u1@test.com", publicKey: "pk1" }, - ]) - - await listPendingWraps("ws-xyz") - - expect(listPendingWraps).toHaveBeenCalledWith("ws-xyz") - }) - - it("calls wrapDekForMember and postDekWrap for each ready member", async () => { - const { wrapDekForMember } = require("@/lib/workspace-crypto") - const { postDekWrap, listPendingWraps } = require("@/lib/workspace-dek-api") - const { useWorkspaceDekStore } = require("@/store/workspace-dek-store") - - const fakeDek = { type: "secret" } as unknown as CryptoKey - const fakeWrapped = { encrypted: "enc", iv: "iv", senderPublicKey: "spk" } - - ;(useWorkspaceDekStore.getState as jest.Mock).mockReturnValueOnce({ - getDek: jest.fn().mockResolvedValueOnce(fakeDek), - }) - ;(wrapDekForMember as jest.Mock).mockResolvedValue(fakeWrapped) - ;(postDekWrap as jest.Mock).mockResolvedValue(undefined) - ;(listPendingWraps as jest.Mock).mockResolvedValueOnce([]) - - const pending = [ - { uid: "u1", email: "u1@test.com", publicKey: "pk1" }, - { uid: "u2", email: "u2@test.com", publicKey: null }, - ] - const ready = pending.filter((m) => m.publicKey) - const myPriv = {} as CryptoKey - const myPub = "my-pub" - - const dek = await useWorkspaceDekStore.getState().getDek("ws-abc") - for (const member of ready) { - const wrapped = await wrapDekForMember(dek, myPriv, member.publicKey!, myPub) - await postDekWrap("ws-abc", { target_uid: member.uid, wrapped }) - } - - expect(wrapDekForMember).toHaveBeenCalledTimes(1) - expect(postDekWrap).toHaveBeenCalledTimes(1) - expect(postDekWrap).toHaveBeenCalledWith("ws-abc", { - target_uid: "u1", - wrapped: fakeWrapped, - }) - }) - - it("refreshes pending list after wrapping and shows success toast", async () => { - const { toast } = require("sonner") - const { listPendingWraps } = require("@/lib/workspace-dek-api") - - ;(listPendingWraps as jest.Mock).mockResolvedValueOnce([]) - - const ready = [{ uid: "u1", email: "u1@test.com", publicKey: "pk1" }] - const fresh = await listPendingWraps("ws-abc") - // Simulate setPending(fresh) and success toast - expect(fresh).toEqual([]) - toast.success(`Wrapped DEK for ${ready.length} member(s)`) - - expect(toast.success).toHaveBeenCalledWith("Wrapped DEK for 1 member(s)") - }) - - it("shows error toast on unexpected error", async () => { - const { toast } = require("sonner") - - const err = new Error("postDekWrap failed (500)") - toast.error(err instanceof Error ? err.message : "Failed to wrap DEK") - - expect(toast.error).toHaveBeenCalledWith("postDekWrap failed (500)") - }) - - it("skips pending members without a publicKey when wrapping", () => { - const pending = [ - { uid: "u1", email: "u1@test.com", publicKey: "pk1" }, - { uid: "u2", email: "u2@test.com", publicKey: null }, - { uid: "u3", email: "u3@test.com", publicKey: "pk3" }, - ] - const ready = pending.filter((m) => m.publicKey) - expect(ready).toHaveLength(2) - expect(ready.map((m) => m.uid)).toEqual(["u1", "u3"]) - }) -}) diff --git a/apps/web/src/components/__tests__/pinned-tools-preferences-sync.test.tsx b/apps/web/src/components/__tests__/pinned-tools-preferences-sync.test.tsx index 1bbb87ed..f4c650b4 100644 --- a/apps/web/src/components/__tests__/pinned-tools-preferences-sync.test.tsx +++ b/apps/web/src/components/__tests__/pinned-tools-preferences-sync.test.tsx @@ -28,38 +28,68 @@ jest.mock("@/store/workspace-store", () => ({ import { usePinnedToolsStore } from "@/store/pinned-tools-store" import * as api from "@/lib/user-preferences-api" +import { normalizePinnedToolsList } from "@/lib/pinned-tools-path" // ── Helpers ────────────────────────────────────────────────────────────────── /** * Simulate the read path of PinnedToolsPreferencesSync: * given a GET response shape, apply the same hydration logic the component - * uses and return the resulting pinnedByWorkspace state. + * uses and return the resulting pinnedByWorkspace state. Models a first load + * (enable/disable → pins migration not yet run for this uid). + * + * @param validIds Workspace ids that still exist. Defaults to every id present + * in the server map (i.e. nothing is treated as orphaned). */ async function simulateLoad( activeWorkspaceId: string, - apiResponse: Awaited> + apiResponse: Awaited>, + validIds?: Set ): Promise> { - const { setPinnedTools } = usePinnedToolsStore.getState() - + let resolved: Record = {} if ( apiResponse.pinnedToolsByWorkspace && Object.keys(apiResponse.pinnedToolsByWorkspace).length > 0 ) { - for (const [wsId, tools] of Object.entries(apiResponse.pinnedToolsByWorkspace)) { - if (Array.isArray(tools)) { - setPinnedTools(wsId, tools) - } - } + resolved = { ...apiResponse.pinnedToolsByWorkspace } } else if ( Array.isArray(apiResponse.toolFavorites) && - apiResponse.toolFavorites.length > 0 + apiResponse.toolFavorites.length > 0 && + activeWorkspaceId ) { - if (activeWorkspaceId) { - setPinnedTools(activeWorkspaceId, apiResponse.toolFavorites) + resolved = { [activeWorkspaceId]: apiResponse.toolFavorites } + } + + // One-time enable/disable → pins union migration. + const enabled = Array.isArray(apiResponse.enabledTools) ? apiResponse.enabledTools : [] + if (activeWorkspaceId && enabled.length > 0) { + const existingActive = resolved[activeWorkspaceId] ?? [] + resolved = { + ...resolved, + [activeWorkspaceId]: normalizePinnedToolsList([...existingActive, ...enabled]), } } + // Rescue pins stranded under workspace ids that no longer exist. + const valid = validIds ?? new Set(Object.keys(resolved)) + if (activeWorkspaceId && valid.size > 0) { + const kept: Record = {} + const orphaned: string[] = [] + for (const [wsId, tools] of Object.entries(resolved)) { + if (wsId === activeWorkspaceId || valid.has(wsId)) kept[wsId] = tools ?? [] + else orphaned.push(...(tools ?? [])) + } + if (orphaned.length > 0) { + kept[activeWorkspaceId] = normalizePinnedToolsList([ + ...(kept[activeWorkspaceId] ?? []), + ...orphaned, + ]) + resolved = kept + } + } + + // Wholesale replace (server truth), matching the component. + usePinnedToolsStore.setState({ pinnedByWorkspace: resolved }) return usePinnedToolsStore.getState().pinnedByWorkspace } @@ -172,6 +202,105 @@ describe("pinned-tools-preferences-sync — store contracts", () => { expect(result).toEqual({}) }) + // ── Migration: enable/disable → pins ────────────────────────────────────── + + it("migrate: folds retired enabledTools into the active workspace pins", async () => { + const result = await simulateLoad("w1", { + theme: "system", + accentColor: "blue", + locale: "en", + enabledTools: ["/app/json-formatter", "/app/notes", "/app/uuid-generator"], + toolFavorites: [], + pinnedToolsByWorkspace: {}, + toolStats: {}, + createdAt: 0, + updatedAt: 0, + }) + + expect(result["w1"]).toEqual([ + "/app/json-formatter", + "/app/notes", + "/app/uuid-generator", + ]) + }) + + it("migrate: unions enabledTools with existing pins without dropping either", async () => { + const result = await simulateLoad("w1", { + theme: "system", + accentColor: "blue", + locale: "en", + enabledTools: ["/app/json-formatter", "/app/notes"], + toolFavorites: [], + pinnedToolsByWorkspace: { w1: ["/app/notes", "/app/regex-tester"] }, + toolStats: {}, + createdAt: 0, + updatedAt: 0, + }) + + // Existing pins first (order preserved), then the new-from-enabled tool. + expect(result["w1"]).toEqual([ + "/app/notes", + "/app/regex-tester", + "/app/json-formatter", + ]) + }) + + // ── Orphan rescue: pins survive workspace-id churn ──────────────────────── + + it("rescue: folds pins from a removed workspace into the active one", async () => { + // "w-old" no longer exists (only "w-personal" is valid) — its pins must not + // vanish on login; they migrate into the active workspace. + const result = await simulateLoad( + "w-personal", + { + theme: "system", + accentColor: "blue", + locale: "en", + enabledTools: [], + toolFavorites: [], + pinnedToolsByWorkspace: { + "w-personal": ["/app/notes"], + "w-old": ["/app/json-formatter", "/app/uuid-generator"], + }, + toolStats: {}, + createdAt: 0, + updatedAt: 0, + }, + new Set(["w-personal"]) + ) + + expect(result["w-personal"]).toEqual([ + "/app/notes", + "/app/json-formatter", + "/app/uuid-generator", + ]) + expect(result["w-old"]).toBeUndefined() + }) + + it("rescue: leaves pins under still-valid workspaces untouched", async () => { + const result = await simulateLoad( + "w1", + { + theme: "system", + accentColor: "blue", + locale: "en", + enabledTools: [], + toolFavorites: [], + pinnedToolsByWorkspace: { + w1: ["/app/notes"], + w2: ["/app/uuid-generator"], + }, + toolStats: {}, + createdAt: 0, + updatedAt: 0, + }, + new Set(["w1", "w2"]) + ) + + expect(result["w1"]).toEqual(["/app/notes"]) + expect(result["w2"]).toEqual(["/app/uuid-generator"]) + }) + // ── Write path ──────────────────────────────────────────────────────────── it("write: sends pinnedToolsByWorkspace (not toolFavorites) to the API", async () => { diff --git a/apps/web/src/components/__tests__/role-select.test.ts b/apps/web/src/components/__tests__/role-select.test.ts deleted file mode 100644 index 26813dca..00000000 --- a/apps/web/src/components/__tests__/role-select.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Tests for RoleSelect (Task 15 — workspace management page) - * - * Environment: jest-environment-node — no DOM, no React rendering. - * Strategy: verify module exports and source-file structure. - */ - -describe("RoleSelect — module exports", () => { - it("exports a RoleSelect named function component", () => { - const mod = require("../role-select") - expect(typeof mod.RoleSelect).toBe("function") - }) -}) - -describe("RoleSelect — source structure assertions", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../role-select.tsx"), - "utf8" - ) - - it("uses use client directive", () => { - expect(source).toContain('"use client"') - }) - - it("accepts scope, currentRole, and onChange props", () => { - expect(source).toContain("scope") - expect(source).toContain("currentRole") - expect(source).toContain("onChange") - }) - - it("includes org roles", () => { - expect(source).toContain("owner") - expect(source).toContain("admin") - expect(source).toContain("member") - }) - - it("includes workspace roles", () => { - expect(source).toContain("developer") - expect(source).toContain("viewer") - }) - - it("uses shadcn Select primitives", () => { - expect(source).toContain("SelectContent") - expect(source).toContain("SelectItem") - expect(source).toContain("SelectTrigger") - expect(source).toContain("SelectValue") - }) - - it("switches role lists based on scope", () => { - expect(source).toContain('scope === "org"') - }) -}) - -describe("RoleSelect — role list logic", () => { - it("org scope has 4 roles and workspace scope has 3 roles", () => { - // Simulate the role lists defined in the component - const ORG_ROLES = ["owner", "admin", "member", "viewer"] - const WS_ROLES = ["admin", "developer", "viewer"] - expect(ORG_ROLES).toHaveLength(4) - expect(WS_ROLES).toHaveLength(3) - expect(ORG_ROLES).toContain("owner") - expect(WS_ROLES).toContain("developer") - }) -}) diff --git a/apps/web/src/components/__tests__/rotate-key-button.test.ts b/apps/web/src/components/__tests__/rotate-key-button.test.ts deleted file mode 100644 index 88dd585d..00000000 --- a/apps/web/src/components/__tests__/rotate-key-button.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -/** - * Tests for RotateKeyButton (Task 30 — C-T11 — Rotate encryption key) - * - * Environment: jest-environment-node — no DOM, no React rendering. - * Strategy: module-export check + source structure assertions + logic simulation. - */ - -jest.mock("@/lib/backend-auth", () => ({ - backendFetch: jest.fn(), -})) - -jest.mock("@/lib/workspace-dek-api", () => ({ - listMemberPublicKeys: jest.fn(), - rotateDek: jest.fn(), -})) - -jest.mock("@/lib/workspace-crypto", () => ({ - generateWorkspaceDek: jest.fn(), - wrapDekForMember: jest.fn(), - dekFingerprint: jest.fn(), -})) - -jest.mock("@/store/user-keypair-store", () => ({ - useUserKeypairStore: jest.fn((sel: (s: { publicKey: string | null; privateKey: CryptoKey | null }) => unknown) => - sel({ publicKey: null, privateKey: null }), - ), -})) - -jest.mock("@/store/workspace-dek-store", () => ({ - useWorkspaceDekStore: jest.fn((sel: (s: { clearWorkspace: jest.Mock; getDek: jest.Mock }) => unknown) => - sel({ clearWorkspace: jest.fn(), getDek: jest.fn().mockResolvedValue(null) }), - ), -})) - -jest.mock("@/lib/dek-rotation", () => ({ - reencryptAllEntries: jest.fn().mockResolvedValue({ rotated: 0, failed: 0 }), -})) - -jest.mock("@/store/workspace-store", () => ({ - useWorkspaceStore: jest.fn((sel: (s: { loadFromBackend: jest.Mock }) => unknown) => - sel({ loadFromBackend: jest.fn() }), - ), -})) - -jest.mock("sonner", () => ({ - toast: { - success: jest.fn(), - error: jest.fn(), - warning: jest.fn(), - }, -})) - -// ── Module exports ───────────────────────────────────────────────────────────── - -describe("RotateKeyButton — module exports", () => { - it("exports a RotateKeyButton named function", () => { - const mod = require("../rotate-key-button") - expect(typeof mod.RotateKeyButton).toBe("function") - }) -}) - -// ── Source structure ─────────────────────────────────────────────────────────── - -describe("RotateKeyButton — source structure", () => { - const fs = require("fs") - const path = require("path") - const source = fs.readFileSync( - path.join(__dirname, "../rotate-key-button.tsx"), - "utf8", - ) - - it("uses the use client directive", () => { - expect(source).toContain('"use client"') - }) - - it("imports RotateCw and Loader2 from lucide-react", () => { - expect(source).toContain("RotateCw") - expect(source).toContain("Loader2") - expect(source).toContain("lucide-react") - }) - - it("imports AlertDialog components from ui/alert-dialog", () => { - expect(source).toContain("AlertDialog") - expect(source).toContain("AlertDialogAction") - expect(source).toContain("AlertDialogCancel") - expect(source).toContain("AlertDialogContent") - expect(source).toContain("AlertDialogDescription") - expect(source).toContain("AlertDialogFooter") - expect(source).toContain("AlertDialogHeader") - expect(source).toContain("AlertDialogTitle") - expect(source).toContain("AlertDialogTrigger") - expect(source).toContain("alert-dialog") - }) - - it("imports generateWorkspaceDek, wrapDekForMember, dekFingerprint from workspace-crypto", () => { - expect(source).toContain("generateWorkspaceDek") - expect(source).toContain("wrapDekForMember") - expect(source).toContain("dekFingerprint") - expect(source).toContain("workspace-crypto") - }) - - it("imports listMemberPublicKeys and rotateDek from workspace-dek-api", () => { - expect(source).toContain("listMemberPublicKeys") - expect(source).toContain("rotateDek") - expect(source).toContain("workspace-dek-api") - }) - - it("imports useUserKeypairStore", () => { - expect(source).toContain("useUserKeypairStore") - expect(source).toContain("user-keypair-store") - }) - - it("imports useWorkspaceDekStore", () => { - expect(source).toContain("useWorkspaceDekStore") - expect(source).toContain("workspace-dek-store") - }) - - it("imports reencryptAllEntries from dek-rotation", () => { - expect(source).toContain("reencryptAllEntries") - expect(source).toContain("dek-rotation") - }) - - it("reads getDek from the workspace dek store", () => { - expect(source).toContain("getDek") - }) - - it("imports useWorkspaceStore", () => { - expect(source).toContain("useWorkspaceStore") - expect(source).toContain("workspace-store") - }) - - it("accepts workspaceId prop", () => { - expect(source).toContain("workspaceId") - }) - - it("renders 'Rotate encryption key' button label", () => { - expect(source).toContain("Rotate encryption key") - }) - - it("renders confirmation dialog title", () => { - expect(source).toContain("Rotate encryption key?") - }) - - it("mentions re-encryption in dialog description", () => { - expect(source).toContain("re-encrypted") - expect(source).toContain("do not close this tab") - }) - - it("guards against missing keypair with error toast message", () => { - expect(source).toContain("Generate your keypair first via Enable encrypted tools") - }) - - it("guards against no ready members with error toast message", () => { - expect(source).toContain("No members with published keypairs") - }) - - it("warns about members without keypairs", () => { - expect(source).toContain("have no keypair and will lose access") - }) - - it("calls clearWsDek after successful rotation", () => { - expect(source).toContain("clearWsDek") - }) - - it("calls reloadStore after successful rotation", () => { - expect(source).toContain("reloadStore") - }) - - it("shows success toast after rotation", () => { - expect(source).toContain("Rotation complete") - expect(source).toContain("re-encrypted") - }) -}) - -// ── Logic simulation ─────────────────────────────────────────────────────────── - -describe("RotateKeyButton — handleRotate logic simulation", () => { - beforeEach(() => jest.clearAllMocks()) - - it("shows error toast when userPriv is null", async () => { - const { toast } = require("sonner") - - const userPriv = null - const userPub = null - if (!userPriv || !userPub) { - toast.error("Generate your keypair first via Enable encrypted tools") - } - - expect(toast.error).toHaveBeenCalledWith( - "Generate your keypair first via Enable encrypted tools", - ) - }) - - it("does not call any API when keypair is missing", async () => { - const { listMemberPublicKeys, rotateDek } = require("@/lib/workspace-dek-api") - const { generateWorkspaceDek } = require("@/lib/workspace-crypto") - - const userPriv = null - const userPub = null - - // Simulate handleRotate early return - if (!userPriv || !userPub) { - // returns early — no API calls - } else { - await listMemberPublicKeys("ws-abc") - await generateWorkspaceDek() - await rotateDek("ws-abc", { dekFingerprint: "fp", wraps: [] }) - } - - expect(listMemberPublicKeys).not.toHaveBeenCalled() - expect(generateWorkspaceDek).not.toHaveBeenCalled() - expect(rotateDek).not.toHaveBeenCalled() - }) - - it("shows error toast when no members have a publicKey", async () => { - const { toast } = require("sonner") - - const members = [ - { uid: "u1", email: "u1@test.com", publicKey: null }, - { uid: "u2", email: "u2@test.com", publicKey: null }, - ] - const ready = members.filter((m) => m.publicKey) - if (ready.length === 0) { - toast.error("No members with published keypairs — cannot rotate") - } - - expect(toast.error).toHaveBeenCalledWith( - "No members with published keypairs — cannot rotate", - ) - }) - - it("shows warning toast when some members are missing keypairs", async () => { - const { toast } = require("sonner") - - const members = [ - { uid: "u1", email: "u1@test.com", publicKey: "pk1" }, - { uid: "u2", email: "u2@test.com", publicKey: null }, - { uid: "u3", email: "u3@test.com", publicKey: null }, - ] - const missing = members.filter((m) => !m.publicKey) - if (missing.length > 0) { - toast.warning( - `${missing.length} member(s) have no keypair and will lose access until they publish one`, - ) - } - - expect(toast.warning).toHaveBeenCalledWith( - "2 member(s) have no keypair and will lose access until they publish one", - ) - }) - - it("calls generateWorkspaceDek → listMemberPublicKeys → wrapDekForMember per ready member → rotateDek", async () => { - const { listMemberPublicKeys, rotateDek } = require("@/lib/workspace-dek-api") - const { generateWorkspaceDek, wrapDekForMember, dekFingerprint } = - require("@/lib/workspace-crypto") - - const fakeDek = { type: "secret" } as unknown as CryptoKey - const fakeWrapped = { encrypted: "enc", iv: "iv", senderPublicKey: "spk" } - - ;(listMemberPublicKeys as jest.Mock).mockResolvedValueOnce([ - { uid: "u1", email: "u1@test.com", publicKey: "pk1" }, - { uid: "u2", email: "u2@test.com", publicKey: "pk2" }, - { uid: "u3", email: "u3@test.com", publicKey: null }, - ]) - ;(generateWorkspaceDek as jest.Mock).mockResolvedValueOnce(fakeDek) - ;(wrapDekForMember as jest.Mock).mockResolvedValue(fakeWrapped) - ;(dekFingerprint as jest.Mock).mockResolvedValueOnce("fp-xyz") - ;(rotateDek as jest.Mock).mockResolvedValueOnce(undefined) - - const userPriv = {} as CryptoKey - const userPub = "my-pub-key" - - // Simulate the full handleRotate happy path - const members = await listMemberPublicKeys("ws-test") - const ready = members.filter((m: { publicKey: string | null }) => m.publicKey) - const missing = members.filter((m: { publicKey: string | null }) => !m.publicKey) - - expect(ready).toHaveLength(2) - expect(missing).toHaveLength(1) - - const newDek = await generateWorkspaceDek() - const wraps = await Promise.all( - ready.map(async (m: { uid: string; publicKey: string }) => { - const wrapped = await wrapDekForMember(newDek, userPriv, m.publicKey, userPub) - return { uid: m.uid, wrapped } - }), - ) - const fp = await dekFingerprint(newDek) - await rotateDek("ws-test", { dekFingerprint: fp, wraps }) - - expect(generateWorkspaceDek).toHaveBeenCalledTimes(1) - expect(wrapDekForMember).toHaveBeenCalledTimes(2) - expect(wrapDekForMember).toHaveBeenCalledWith(fakeDek, userPriv, "pk1", "my-pub-key") - expect(wrapDekForMember).toHaveBeenCalledWith(fakeDek, userPriv, "pk2", "my-pub-key") - expect(dekFingerprint).toHaveBeenCalledWith(fakeDek) - expect(rotateDek).toHaveBeenCalledWith("ws-test", { - dekFingerprint: "fp-xyz", - wraps: [ - { uid: "u1", wrapped: fakeWrapped }, - { uid: "u2", wrapped: fakeWrapped }, - ], - }) - }) - - it("calls clearWsDek after rotateDek succeeds", async () => { - const { useWorkspaceDekStore } = require("@/store/workspace-dek-store") - - const clearWorkspace = jest.fn() - ;(useWorkspaceDekStore as jest.Mock).mockImplementationOnce( - (sel: (s: { clearWorkspace: jest.Mock }) => unknown) => sel({ clearWorkspace }), - ) - - // Simulate the post-rotate clear - const clearWsDek = clearWorkspace - clearWsDek("ws-test") - - expect(clearWorkspace).toHaveBeenCalledWith("ws-test") - }) - - it("calls reloadStore after clearWsDek", async () => { - const { useWorkspaceStore } = require("@/store/workspace-store") - - const loadFromBackend = jest.fn().mockResolvedValueOnce(undefined) - ;(useWorkspaceStore as jest.Mock).mockImplementationOnce( - (sel: (s: { loadFromBackend: jest.Mock }) => unknown) => sel({ loadFromBackend }), - ) - - const reloadStore = loadFromBackend - await reloadStore() - - expect(loadFromBackend).toHaveBeenCalledTimes(1) - }) - - it("shows success toast after successful rotation (no old DEK — no entries)", async () => { - const { toast } = require("sonner") - - // Simulates the no-oldDek branch - toast.success("Encryption key rotated. No existing entries needed re-encryption.") - - expect(toast.success).toHaveBeenCalledWith( - "Encryption key rotated. No existing entries needed re-encryption.", - ) - }) - - it("shows Rotation complete toast with counts when oldDek was available", async () => { - const { toast } = require("sonner") - - const rotated = 5 - const failed = 1 - toast.success( - `Rotation complete: ${rotated} entries re-encrypted, ${failed} failed.`, - ) - - expect(toast.success).toHaveBeenCalledWith( - "Rotation complete: 5 entries re-encrypted, 1 failed.", - ) - }) - - it("shows error toast on unexpected error from rotateDek", async () => { - const { toast } = require("sonner") - - const err = new Error("rotateDek failed (403)") - toast.error(err instanceof Error ? err.message : "Rotation failed") - - expect(toast.error).toHaveBeenCalledWith("rotateDek failed (403)") - }) - - it("shows generic error toast when non-Error is thrown", async () => { - const { toast } = require("sonner") - - const err: unknown = "unexpected string error" - toast.error(err instanceof Error ? err.message : "Rotation failed") - - expect(toast.error).toHaveBeenCalledWith("Rotation failed") - }) - - it("skips members without a publicKey when building wraps", () => { - const members = [ - { uid: "u1", email: "u1@test.com", publicKey: "pk1" }, - { uid: "u2", email: "u2@test.com", publicKey: null }, - { uid: "u3", email: "u3@test.com", publicKey: "pk3" }, - ] - const ready = members.filter((m) => m.publicKey) - expect(ready).toHaveLength(2) - expect(ready.map((m) => m.uid)).toEqual(["u1", "u3"]) - }) -}) - -// ── Workspace section mount guard ────────────────────────────────────────────── - -describe("RotateKeyButton — workspace-section mount conditions", () => { - const fs = require("fs") - const path = require("path") - const sectionSource = fs.readFileSync( - path.join(__dirname, "../../app/settings/workspaces/workspace-section.tsx"), - "utf8", - ) - - it("imports RotateKeyButton in workspace-section", () => { - expect(sectionSource).toContain("RotateKeyButton") - expect(sectionSource).toContain("rotate-key-button") - }) - - it("renders RotateKeyButton only when encryption is enabled", () => { - // The JSX usage