fix: merge main, resolve conflicts, address review feedback
- Resolve merge conflicts in app/api/onedrive.py and tests/test_api_google_drive_final.py - Fix legacy Dict[str, str] type hints in update_env_file functions to use dict[str, str] - Add admin-only access (_require_admin dependency) to save-settings endpoints in google_drive.py, onedrive.py, and dropbox.py - Fix in_memory_only response field to reflect actual env_write_success status - Update tests to override _require_admin dependency for save-settings endpoint tests
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# =============================================================================
|
||||
# Docker build context exclusions
|
||||
# Reducing the build context speeds up builds and prevents unnecessary cache
|
||||
# invalidation when unrelated files change.
|
||||
# =============================================================================
|
||||
|
||||
# ── Version control ──────────────────────────────────────────────────────────
|
||||
.git
|
||||
|
||||
# ── GitHub / CI tooling ──────────────────────────────────────────────────────
|
||||
.github
|
||||
|
||||
# ── IDE / local dev ──────────────────────────────────────────────────────────
|
||||
.vscode
|
||||
.jules
|
||||
|
||||
# ── Pre-commit / linting config (not needed at runtime) ──────────────────────
|
||||
.pre-commit-config.yaml
|
||||
pyproject.toml
|
||||
codecov.yml
|
||||
crowdin.yml
|
||||
|
||||
# ── Test suite ───────────────────────────────────────────────────────────────
|
||||
tests/
|
||||
requirements-dev.txt
|
||||
coverage.json
|
||||
COVERAGE_REPORT.md
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
junit.xml
|
||||
coverage.xml
|
||||
|
||||
# ── Mobile app / browser extension / legacy placeholder ─────────────────────
|
||||
# backend/ is an empty placeholder directory not part of the Python application
|
||||
mobile/
|
||||
browser-extension/
|
||||
backend/
|
||||
|
||||
# ── Helm charts ──────────────────────────────────────────────────────────────
|
||||
helm/
|
||||
|
||||
# ── Scripts (run before Docker build, output files are COPYd separately) ─────
|
||||
scripts/
|
||||
|
||||
# ── Benchmark and one-off utility scripts ────────────────────────────────────
|
||||
benchmark_*.py
|
||||
fix_test*.py
|
||||
run_fast_tests.sh
|
||||
|
||||
# ── Root-level Markdown files (docs/ is kept for docs-builder stage) ─────────
|
||||
# Note: *.md only matches files at the root level, not inside subdirectories
|
||||
*.md
|
||||
|
||||
# ── Python bytecode / compiled artifacts ─────────────────────────────────────
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
|
||||
# ── Virtual environments ──────────────────────────────────────────────────────
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# ── Environment / secret files ───────────────────────────────────────────────
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# ── Runtime state files ───────────────────────────────────────────────────────
|
||||
*.log
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# ── Build artifacts ───────────────────────────────────────────────────────────
|
||||
build/
|
||||
dist/
|
||||
.cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
site/
|
||||
docs_build/
|
||||
|
||||
# ── Editor temp files ─────────────────────────────────────────────────────────
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -7,6 +7,28 @@ GOTENBERG_URL=http://gotenberg:3000
|
||||
ALLOW_FILE_DELETE=true # Allow deletion of file records
|
||||
COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
|
||||
|
||||
# **System Reset / Factory Reset**
|
||||
# FACTORY_RESET_ON_STARTUP=false # Wipe all user data on every startup (demo/testing only)
|
||||
# ENABLE_FACTORY_RESET=false # Show the System Reset page in admin UI
|
||||
|
||||
# **Logging**
|
||||
# LOG_LEVEL controls the Python root-logger level.
|
||||
# Accepted values: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO).
|
||||
# When DEBUG=true and LOG_LEVEL is not set, the level is automatically lowered to DEBUG.
|
||||
# LOG_LEVEL=INFO
|
||||
# DEBUG=false
|
||||
|
||||
# Log output format: "text" (human-readable, default) or "json" (structured JSON lines).
|
||||
# Use "json" when shipping logs to Grafana Loki, Splunk, ELK, Datadog, or any SIEM.
|
||||
# LOG_FORMAT=text
|
||||
|
||||
# Forward application logs to a syslog receiver (in addition to stdout).
|
||||
# Useful for traditional (non-container) deployments and centralised SIEM ingestion.
|
||||
# LOG_SYSLOG_ENABLED=false
|
||||
# LOG_SYSLOG_HOST=localhost
|
||||
# LOG_SYSLOG_PORT=514
|
||||
# LOG_SYSLOG_PROTOCOL=udp # udp | tcp
|
||||
|
||||
# **UI / Appearance**
|
||||
# Default colour scheme: system (follow OS), light, or dark
|
||||
# Individual users can always override with the navbar dark-mode toggle.
|
||||
@@ -142,6 +164,16 @@ AUTH_ENABLED=true
|
||||
# Generate a secure random string, for example:
|
||||
# python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SESSION_SECRET=b39fd43f68d0491ca942f28a16e484b1e763fe9accf4445ca2669a5f3b179eb4
|
||||
|
||||
# Session lifetime in days (default: 30). Common values: 30, 60, 90.
|
||||
# Determines how long a user stays logged in before needing to re-authenticate.
|
||||
# SESSION_LIFETIME_DAYS=30
|
||||
# Override with a custom value (takes precedence over SESSION_LIFETIME_DAYS):
|
||||
# SESSION_LIFETIME_CUSTOM_DAYS=
|
||||
|
||||
# Time-to-live in seconds for QR code login challenges (default: 120 = 2 minutes).
|
||||
# QR_LOGIN_CHALLENGE_TTL_SECONDS=120
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=your_secure_password
|
||||
ADMIN_GROUP_NAME=admin
|
||||
@@ -240,6 +272,13 @@ OPENAI_MODEL=gpt-4o-mini
|
||||
# AZURE_OPENAI_API_VERSION=2024-02-01
|
||||
# AI_MODEL=gpt-4o # deployment name in Azure
|
||||
|
||||
# **Document Translation**
|
||||
# After processing, documents whose detected language differs from the default
|
||||
# target language are automatically translated. Only the original and this
|
||||
# default-language version are persisted; other translations are on-the-fly.
|
||||
# Users can override this in their profile settings.
|
||||
# DEFAULT_DOCUMENT_LANGUAGE=en
|
||||
|
||||
# Azure Document Intelligence (OCR – separate from AI provider above)
|
||||
# **Email Settings (shared SMTP – password reset, verification, and system notifications)**
|
||||
EMAIL_HOST=smtp.example.com
|
||||
@@ -408,6 +447,15 @@ ONEDRIVE_TENANT_ID=common
|
||||
ONEDRIVE_REFRESH_TOKEN=your-refresh-token
|
||||
ONEDRIVE_FOLDER_PATH=Documents/Uploads
|
||||
|
||||
# SharePoint
|
||||
SHAREPOINT_CLIENT_ID=your-client-id
|
||||
SHAREPOINT_CLIENT_SECRET=your-client-secret
|
||||
SHAREPOINT_TENANT_ID=common
|
||||
SHAREPOINT_REFRESH_TOKEN=your-refresh-token
|
||||
SHAREPOINT_SITE_URL=https://tenant.sharepoint.com/sites/sitename
|
||||
SHAREPOINT_DOCUMENT_LIBRARY=Documents
|
||||
SHAREPOINT_FOLDER_PATH=Uploads
|
||||
|
||||
# WebDAV
|
||||
# WEBDAV_ENABLED=true # Set to false to disable WebDAV uploads without removing credentials
|
||||
WEBDAV_URL=https://webdav.example.com/path
|
||||
|
||||
@@ -44,6 +44,18 @@ jobs:
|
||||
- run: ruff check app/ tests/
|
||||
- run: ruff format --check app/ tests/
|
||||
|
||||
migration-chain:
|
||||
name: Alembic Migration Chain Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Validate migration chain
|
||||
run: python scripts/check_alembic_migrations.py
|
||||
|
||||
html-lint:
|
||||
name: HTML Accessibility Lint
|
||||
runs-on: ubuntu-latest
|
||||
@@ -138,7 +150,7 @@ jobs:
|
||||
build:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [run-tests, mypy, dependency-scan, html-lint]
|
||||
needs: [run-tests, mypy, dependency-scan, html-lint, migration-chain]
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
|
||||
@@ -2,3 +2,11 @@
|
||||
**Vulnerability:** The `_test_webdav_connection` function had a custom SSRF check that failed to resolve DNS names, allowing attackers to bypass the check by providing a domain that resolves to an internal IP (e.g., `127.0.0.1`).
|
||||
**Learning:** DNS resolution is required for robust SSRF protection when validating URLs provided by users.
|
||||
**Prevention:** Use a centralized `is_private_ip` function (now in `app/utils/network.py`) that resolves the hostname to its IPs and checks if any are private.
|
||||
## 2026-03-20 - Safe Path Traversal Prevention in Low-Level Utilities
|
||||
**Vulnerability:** The generic file utility `hash_file` in `app/utils/file_operations.py` accepted any file path and was vulnerable to reading arbitrary files via path traversal (e.g., `../../../etc/passwd`) or absolute paths if an attacker could control the `filepath` argument.
|
||||
**Learning:** Naively checking for `".." in path` breaks legitimate relative paths used internally by the application. Blocking absolute paths entirely also breaks functionality. Input validation should occur at the API boundary, but for defense-in-depth, low-level utilities must enforce expected boundaries (e.g., the application's `workdir`).
|
||||
**Prevention:** Use `pathlib.Path.resolve()` on both the target path and the allowed base directory (`settings.workdir`). Ensure the resolved target path is strictly within the allowed boundary using `filepath_obj.relative_to(workdir_obj)`, catching the `ValueError` that is raised when the path is out of bounds. This safely blocks both relative traversal attacks and arbitrary absolute paths.
|
||||
## 2025-05-18 - [SSRF Bypass via DNS Resolution Failure]
|
||||
**Vulnerability:** The `is_private_ip` function in `app/utils/network.py` failed open (returned `False`) when a hostname could not be resolved (`socket.gaierror`).
|
||||
**Learning:** This fail-open pattern was originally added to allow external domains in tests, but in production, it created a severe SSRF risk. An attacker could bypass SSRF protections by providing a URL that fails to resolve during the security check but resolves later (DNS rebinding), or by exploiting internal routing behaviors via unresolvable addresses.
|
||||
**Prevention:** Always fail securely in network authorization functions. If a domain cannot be resolved to verify its safety, the request must be blocked (`return True` / default-deny). Tests should mock DNS resolution correctly instead of compromising production security logic.
|
||||
|
||||
@@ -48,6 +48,16 @@ repos:
|
||||
.env.demo
|
||||
)$
|
||||
|
||||
# Alembic migration chain validation
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: check-alembic-migrations
|
||||
name: Check Alembic migration chain
|
||||
entry: python scripts/check_alembic_migrations.py
|
||||
language: python
|
||||
pass_filenames: false
|
||||
files: ^migrations/versions/.*\.py$
|
||||
|
||||
# Conventional commits validation
|
||||
- repo: https://github.com/compilerla/conventional-pre-commit
|
||||
rev: v3.0.0
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2026-03-15T21:39:26Z
|
||||
2026-03-18T22:29:03Z
|
||||
|
||||
+1245
File diff suppressed because it is too large
Load Diff
+37
-19
@@ -1,14 +1,34 @@
|
||||
# Use multi-stage build for a smaller final image
|
||||
FROM python:3.14.1 AS builder
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
WORKDIR /app
|
||||
# ── Stage 1: Python dependency builder ──────────────────────────────────────
|
||||
# Use the same slim variant as the runtime to keep Python versions in sync.
|
||||
# build-essential + libffi-dev cover the few packages (e.g. cryptography) that
|
||||
# need a C compiler; they are discarded after this stage.
|
||||
FROM python:3.14.3-slim AS builder
|
||||
|
||||
# Copy requirements first for better layer caching
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
WORKDIR /build
|
||||
|
||||
# ── Documentation build stage ───────────────────────────────────────────────
|
||||
FROM python:3.14.1-slim AS docs-builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libffi-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create an isolated virtual environment so only installed packages are copied
|
||||
# to the runtime image (no pip, setuptools, or other builder artefacts).
|
||||
RUN python -m venv /opt/venv
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
COPY requirements.txt /build/
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
# Remove bytecode and cache to keep the venv lean
|
||||
&& find /opt/venv -type f -name "*.pyc" -delete \
|
||||
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# ── Stage 2: Documentation builder ──────────────────────────────────────────
|
||||
FROM python:3.14.3-slim AS docs-builder
|
||||
|
||||
WORKDIR /docs
|
||||
|
||||
@@ -23,14 +43,13 @@ COPY mkdocs.yml /docs/mkdocs.yml
|
||||
# Build the static documentation site
|
||||
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
|
||||
|
||||
# Second stage for the actual runtime
|
||||
# ── Stage 3: Runtime image ───────────────────────────────────────────────────
|
||||
FROM python:3.14.3-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed packages from builder stage
|
||||
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
# Copy only the pre-built virtual environment from the builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# Install system-level OCR tools required for local OCR workflows:
|
||||
# tesseract-ocr – OCR engine used by pytesseract and ocrmypdf
|
||||
@@ -62,15 +81,14 @@ COPY ./RUNTIME_INFO /app/RUNTIME_INFO
|
||||
# Copy the pre-built MkDocs documentation site (served at /help)
|
||||
COPY --from=docs-builder /docs/docs_build /app/docs_build
|
||||
|
||||
# Create runtime_info directory
|
||||
RUN mkdir -p /app/runtime_info
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /workdir
|
||||
# Create necessary runtime directories in a single layer
|
||||
RUN mkdir -p /app/runtime_info /workdir
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONPATH=/app \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 8000
|
||||
|
||||
+35
-13
@@ -1,13 +1,31 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Local development Dockerfile (avoids CI-only build metadata files)
|
||||
FROM python:3.14.1 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
# ── Stage 1: Python dependency builder ──────────────────────────────────────
|
||||
FROM python:3.14.3-slim AS builder
|
||||
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
WORKDIR /build
|
||||
|
||||
# ── Documentation build stage ───────────────────────────────────────────────
|
||||
FROM python:3.14.1-slim AS docs-builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libffi-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create an isolated virtual environment
|
||||
RUN python -m venv /opt/venv
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
COPY requirements.txt /build/
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
&& find /opt/venv -type f -name "*.pyc" -delete \
|
||||
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# ── Stage 2: Documentation builder ──────────────────────────────────────────
|
||||
FROM python:3.14.3-slim AS docs-builder
|
||||
|
||||
WORKDIR /docs
|
||||
|
||||
@@ -19,23 +37,25 @@ COPY mkdocs.yml /docs/mkdocs.yml
|
||||
|
||||
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
|
||||
|
||||
FROM python:3.14.1-slim
|
||||
# ── Stage 3: Runtime image ───────────────────────────────────────────────────
|
||||
FROM python:3.14.3-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# Install system-level OCR tools required for local OCR workflows:
|
||||
# tesseract-ocr – OCR engine used by pytesseract and ocrmypdf
|
||||
# ghostscript – required by ocrmypdf for PDF/PS operations
|
||||
# poppler-utils – provides pdfinfo/pdftoppm used by pdf2image
|
||||
# unpaper – optional deskewing pre-processor used by ocrmypdf
|
||||
# wget – used by ocr_language_manager to download tessdata files
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tesseract-ocr \
|
||||
ghostscript \
|
||||
poppler-utils \
|
||||
unpaper \
|
||||
wget \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY ./app /app/app
|
||||
@@ -53,11 +73,13 @@ COPY --from=docs-builder /docs/docs_build /app/docs_build
|
||||
RUN echo "local" > /app/GIT_SHA \
|
||||
&& echo "local" > /app/RUNTIME_INFO
|
||||
|
||||
RUN mkdir -p /app/runtime_info
|
||||
RUN mkdir -p /workdir
|
||||
# Create necessary runtime directories in a single layer
|
||||
RUN mkdir -p /app/runtime_info /workdir
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONPATH=/app \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
DocuElevate Build Information
|
||||
==============================
|
||||
Version: 0.145.2
|
||||
Build Date: 2026-03-15T21:39:26Z
|
||||
Git Commit: 237af31f5fe598cfc3c08f2bbba79b3d0925787e
|
||||
Git Short SHA: 237af31
|
||||
Version: 0.156.3
|
||||
Build Date: 2026-03-18T22:29:03Z
|
||||
Git Commit: f91c57eacbcdc14dde04ba44775e7b365655379a
|
||||
Git Short SHA: f91c57e
|
||||
Git Branch: main
|
||||
Commit Date: 2026-03-15T22:39:04+01:00
|
||||
Build Timestamp: 2026-03-15T21:39:26Z
|
||||
Commit Date: 2026-03-18T23:28:41+01:00
|
||||
Build Timestamp: 2026-03-18T22:29:03Z
|
||||
==============================
|
||||
|
||||
@@ -33,16 +33,20 @@ from app.api.pipelines import router as pipelines_router
|
||||
from app.api.plans import router as plans_router
|
||||
from app.api.process import router as process_router
|
||||
from app.api.profile import router as profile_router
|
||||
from app.api.qr_auth import router as qr_auth_router
|
||||
from app.api.queue import router as queue_router
|
||||
from app.api.routing_rules import router as routing_rules_router
|
||||
from app.api.saved_searches import router as saved_searches_router
|
||||
from app.api.scheduled_jobs import router as scheduled_jobs_router
|
||||
from app.api.search import router as search_router
|
||||
from app.api.sessions import router as sessions_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.shared_links import public_router as shared_links_public_router
|
||||
from app.api.shared_links import router as shared_links_router
|
||||
from app.api.similarity import router as similarity_router
|
||||
from app.api.subscriptions import router as subscriptions_router
|
||||
from app.api.system_reset import router as system_reset_router
|
||||
from app.api.translation import router as translation_router
|
||||
from app.api.url_upload import router as url_upload_router
|
||||
|
||||
# Import all the individual routers
|
||||
@@ -95,4 +99,8 @@ router.include_router(scheduled_jobs_router)
|
||||
router.include_router(audit_logs_router)
|
||||
router.include_router(i18n_router)
|
||||
router.include_router(mobile_router)
|
||||
router.include_router(sessions_router)
|
||||
router.include_router(qr_auth_router)
|
||||
router.include_router(compliance_router)
|
||||
router.include_router(system_reset_router)
|
||||
router.include_router(translation_router)
|
||||
|
||||
+55
-15
@@ -42,6 +42,9 @@ TOKEN_HASH_ITERATIONS = 100_000
|
||||
#: PBKDF2 salt for API token hashing (not secret, but fixed for determinism).
|
||||
TOKEN_HASH_SALT = b"api-token-v1"
|
||||
|
||||
#: Name prefix used for tokens created by the mobile app flow.
|
||||
MOBILE_TOKEN_PREFIX = "Mobile App"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helper
|
||||
@@ -91,6 +94,20 @@ def hash_token(token: str) -> str:
|
||||
return dk.hex()
|
||||
|
||||
|
||||
def _token_to_dict(t: ApiToken) -> dict[str, Any]:
|
||||
"""Convert an ``ApiToken`` ORM instance to a serialisable dict."""
|
||||
return {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"token_prefix": t.token_prefix,
|
||||
"is_active": t.is_active,
|
||||
"last_used_at": t.last_used_at,
|
||||
"last_used_ip": t.last_used_ip,
|
||||
"created_at": t.created_at,
|
||||
"revoked_at": t.revoked_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -177,21 +194,44 @@ async def list_tokens(
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all API tokens for the authenticated user."""
|
||||
tokens = db.query(ApiToken).filter(ApiToken.owner_id == owner_id).order_by(ApiToken.created_at.desc()).all()
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"token_prefix": t.token_prefix,
|
||||
"is_active": t.is_active,
|
||||
"last_used_at": t.last_used_at,
|
||||
"last_used_ip": t.last_used_ip,
|
||||
"created_at": t.created_at,
|
||||
"revoked_at": t.revoked_at,
|
||||
}
|
||||
for t in tokens
|
||||
]
|
||||
"""List non-mobile API tokens for the authenticated user.
|
||||
|
||||
Mobile tokens (whose names start with ``"Mobile App"``) are excluded
|
||||
from this list; they are managed on the dedicated Devices page via
|
||||
``GET /api/api-tokens/mobile``.
|
||||
"""
|
||||
tokens = (
|
||||
db.query(ApiToken)
|
||||
.filter(
|
||||
ApiToken.owner_id == owner_id,
|
||||
~ApiToken.name.startswith(MOBILE_TOKEN_PREFIX),
|
||||
)
|
||||
.order_by(ApiToken.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_token_to_dict(t) for t in tokens]
|
||||
|
||||
|
||||
@router.get("/mobile", response_model=list[TokenResponse])
|
||||
async def list_mobile_tokens(
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List mobile API tokens for the authenticated user.
|
||||
|
||||
Returns tokens whose names start with ``"Mobile App"`` — these are
|
||||
created via the mobile SSO flow or QR code login.
|
||||
"""
|
||||
tokens = (
|
||||
db.query(ApiToken)
|
||||
.filter(
|
||||
ApiToken.owner_id == owner_id,
|
||||
ApiToken.name.startswith(MOBILE_TOKEN_PREFIX),
|
||||
)
|
||||
.order_by(ApiToken.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_token_to_dict(t) for t in tokens]
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=status.HTTP_200_OK)
|
||||
|
||||
+2
-3
@@ -168,9 +168,8 @@ async def create_checkout_session(
|
||||
checkout_session = client.checkout.sessions.create(params=session_params)
|
||||
|
||||
logger.info(
|
||||
"Created Stripe checkout session %s for user %s plan %s",
|
||||
"Created Stripe checkout session %s for plan %s",
|
||||
checkout_session.id,
|
||||
owner_id,
|
||||
body.plan_id,
|
||||
)
|
||||
return {"checkout_url": checkout_session.url, "session_id": checkout_session.id}
|
||||
@@ -213,7 +212,7 @@ async def create_portal_session(
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("Created Stripe portal session for user %s", owner_id)
|
||||
logger.info("Created Stripe portal session for user")
|
||||
return {"portal_url": portal.url}
|
||||
|
||||
|
||||
|
||||
+19
-5
@@ -6,7 +6,7 @@ import logging
|
||||
import os
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import requests
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -23,6 +23,17 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""Dependency to ensure the current user is an admin."""
|
||||
user = request.session.get("user")
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||||
|
||||
|
||||
@router.post("/dropbox/exchange-token")
|
||||
@require_login
|
||||
async def exchange_dropbox_token(
|
||||
@@ -132,9 +143,10 @@ async def test_dropbox_token(request: Request):
|
||||
"message": "Dropbox credentials are not fully configured",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Check token validity by getting current account info
|
||||
headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"}
|
||||
response = requests.post(
|
||||
response = await client.post(
|
||||
"https://api.dropboxapi.com/2/users/get_current_account",
|
||||
headers=headers,
|
||||
timeout=settings.http_request_timeout,
|
||||
@@ -153,7 +165,9 @@ async def test_dropbox_token(request: Request):
|
||||
"client_secret": settings.dropbox_app_secret,
|
||||
}
|
||||
|
||||
refresh_response = requests.post(refresh_url, data=refresh_data, timeout=settings.http_request_timeout)
|
||||
refresh_response = await client.post(
|
||||
refresh_url, data=refresh_data, timeout=settings.http_request_timeout
|
||||
)
|
||||
|
||||
if refresh_response.status_code != 200:
|
||||
logger.error(f"Failed to refresh Dropbox token: {refresh_response.text}")
|
||||
@@ -168,7 +182,7 @@ async def test_dropbox_token(request: Request):
|
||||
|
||||
# Try again with the new access token
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
response = requests.post(
|
||||
response = await client.post(
|
||||
"https://api.dropboxapi.com/2/users/get_current_account",
|
||||
headers=headers,
|
||||
timeout=settings.http_request_timeout,
|
||||
@@ -208,9 +222,9 @@ async def test_dropbox_token(request: Request):
|
||||
|
||||
|
||||
@router.post("/dropbox/save-settings")
|
||||
@require_login
|
||||
async def save_dropbox_settings(
|
||||
request: Request,
|
||||
_admin: AdminUser,
|
||||
refresh_token: Annotated[str, Form(...)],
|
||||
app_key: Annotated[Optional[str], Form()] = None,
|
||||
app_secret: Annotated[Optional[str], Form()] = None,
|
||||
|
||||
+22
-17
@@ -73,24 +73,29 @@ def list_duplicate_groups(
|
||||
groups = []
|
||||
total_duplicate_files = 0
|
||||
|
||||
if dup_hashes:
|
||||
# Fetch all matching files (both original and duplicates) in a single batch query
|
||||
all_records = (
|
||||
db.query(FileRecord).filter(FileRecord.filehash.in_(dup_hashes)).order_by(FileRecord.id.asc()).all()
|
||||
)
|
||||
|
||||
# Group records by hash
|
||||
originals_by_hash = {}
|
||||
duplicates_by_hash = {h: [] for h in dup_hashes}
|
||||
|
||||
for record in all_records:
|
||||
h = record.filehash
|
||||
if not record.is_duplicate:
|
||||
# Store only the first original record per hash, matching the old .first() behaviour
|
||||
if h not in originals_by_hash:
|
||||
originals_by_hash[h] = record
|
||||
else:
|
||||
duplicates_by_hash[h].append(record)
|
||||
total_duplicate_files += 1
|
||||
|
||||
for filehash in dup_hashes:
|
||||
# Find the original (non-duplicate) record with this hash
|
||||
original = (
|
||||
db.query(FileRecord)
|
||||
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(False))
|
||||
.order_by(FileRecord.id.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
# Find all duplicate records for this hash
|
||||
duplicates = (
|
||||
db.query(FileRecord)
|
||||
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(True))
|
||||
.order_by(FileRecord.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
total_duplicate_files += len(duplicates)
|
||||
original = originals_by_hash.get(filehash)
|
||||
duplicates = duplicates_by_hash.get(filehash, [])
|
||||
|
||||
groups.append(
|
||||
{
|
||||
|
||||
+95
-39
@@ -11,6 +11,7 @@ import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
import aiofiles
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import asc, desc
|
||||
@@ -345,7 +346,9 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
|
||||
|
||||
try:
|
||||
# Find all file records
|
||||
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
|
||||
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
|
||||
query = apply_owner_filter(query, request)
|
||||
file_records = query.all()
|
||||
|
||||
if not file_records:
|
||||
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
|
||||
@@ -384,7 +387,9 @@ def bulk_reprocess_files(request: Request, file_ids: List[int], db: DbSession):
|
||||
"""
|
||||
try:
|
||||
# Find all file records
|
||||
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
|
||||
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
|
||||
query = apply_owner_filter(query, request)
|
||||
file_records = query.all()
|
||||
|
||||
if not file_records:
|
||||
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
|
||||
@@ -456,7 +461,9 @@ def bulk_reprocess_files_cloud_ocr(request: Request, file_ids: List[int], db: Db
|
||||
Useful for re-running OCR on files with poor text quality or missing OCR text.
|
||||
"""
|
||||
try:
|
||||
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
|
||||
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
|
||||
query = apply_owner_filter(query, request)
|
||||
file_records = query.all()
|
||||
|
||||
if not file_records:
|
||||
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
|
||||
@@ -536,7 +543,9 @@ def bulk_download_files(request: Request, file_ids: List[int], db: DbSession):
|
||||
Files not found on disk are silently skipped.
|
||||
"""
|
||||
try:
|
||||
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
|
||||
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
|
||||
query = apply_owner_filter(query, request)
|
||||
file_records = query.all()
|
||||
|
||||
if not file_records:
|
||||
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
|
||||
@@ -618,7 +627,9 @@ def reprocess_single_file(request: Request, file_id: int, db: DbSession):
|
||||
"""
|
||||
try:
|
||||
# Find the file record
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
query = db.query(FileRecord).filter(FileRecord.id == file_id)
|
||||
query = apply_owner_filter(query, request)
|
||||
file_record = query.first()
|
||||
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
|
||||
@@ -674,7 +685,9 @@ def reprocess_with_cloud_ocr(request: Request, file_id: int, db: DbSession):
|
||||
"""
|
||||
try:
|
||||
# Find the file record
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
query = db.query(FileRecord).filter(FileRecord.id == file_id)
|
||||
query = apply_owner_filter(query, request)
|
||||
file_record = query.first()
|
||||
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
|
||||
@@ -937,7 +950,9 @@ def retry_subtask(
|
||||
"""
|
||||
try:
|
||||
# Find the file record
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
query = db.query(FileRecord).filter(FileRecord.id == file_id)
|
||||
query = apply_owner_filter(query, request)
|
||||
file_record = query.first()
|
||||
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
|
||||
@@ -1079,7 +1094,9 @@ def get_file_preview(
|
||||
|
||||
try:
|
||||
# Find the file record
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
query = db.query(FileRecord).filter(FileRecord.id == file_id)
|
||||
query = apply_owner_filter(query, request)
|
||||
file_record = query.first()
|
||||
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
|
||||
@@ -1159,7 +1176,9 @@ def download_file(
|
||||
|
||||
try:
|
||||
# Find the file record
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
query = db.query(FileRecord).filter(FileRecord.id == file_id)
|
||||
query = apply_owner_filter(query, request)
|
||||
file_record = query.first()
|
||||
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
|
||||
@@ -1217,6 +1236,66 @@ def download_file(
|
||||
raise HTTPException(status_code=500, detail=f"Error downloading file: {str(e)}")
|
||||
|
||||
|
||||
async def _save_upload_file_chunks(file: UploadFile, target_path: str, max_size: int) -> int:
|
||||
"""Save an uploaded file in chunks and enforce the maximum size limit."""
|
||||
try:
|
||||
written_size = 0
|
||||
with open(target_path, "wb") as f:
|
||||
chunk_size = 65536 # 64 KB chunks
|
||||
while True:
|
||||
chunk = await file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
written_size += len(chunk)
|
||||
if written_size > max_size:
|
||||
# Exceeded limit mid-stream; clean up and reject
|
||||
f.close()
|
||||
os.remove(target_path)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File too large: exceeded {max_size} bytes during upload. "
|
||||
f"See SECURITY_AUDIT.md for configuration details.",
|
||||
)
|
||||
f.write(chunk)
|
||||
return written_size
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if os.path.exists(target_path):
|
||||
os.remove(target_path)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save file: {e}")
|
||||
|
||||
|
||||
def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: str) -> dict | None:
|
||||
"""Check for an exact duplicate of the uploaded file and return a warning if found."""
|
||||
if not settings.enable_deduplication:
|
||||
return None
|
||||
|
||||
try:
|
||||
filehash = hash_file(target_path)
|
||||
existing = (
|
||||
db.query(FileRecord)
|
||||
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(False))
|
||||
.order_by(FileRecord.id.asc())
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
logger.info(f"Exact duplicate detected on upload: '{safe_filename}' matches file ID {existing.id}")
|
||||
return {
|
||||
"duplicate_type": "exact",
|
||||
"original_file_id": existing.id,
|
||||
"original_filename": existing.original_filename,
|
||||
"message": (
|
||||
"This file appears to be an exact duplicate of an already-processed document. "
|
||||
"It will still be queued but will be flagged as a duplicate."
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Duplicate check failed for uploaded file '{safe_filename}': {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/ui-upload")
|
||||
@require_login
|
||||
async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...)):
|
||||
@@ -1277,7 +1356,7 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
||||
# enforcing the size limit during the read so memory usage stays bounded.
|
||||
try:
|
||||
written_size = 0
|
||||
with open(target_path, "wb") as f:
|
||||
async with aiofiles.open(target_path, "wb") as f:
|
||||
chunk_size = 65536 # 64 KB chunks
|
||||
while True:
|
||||
chunk = await file.read(chunk_size)
|
||||
@@ -1286,14 +1365,14 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
||||
written_size += len(chunk)
|
||||
if written_size > max_size:
|
||||
# Exceeded limit mid-stream; clean up and reject
|
||||
f.close()
|
||||
await f.close()
|
||||
os.remove(target_path)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File too large: exceeded {max_size} bytes during upload. "
|
||||
f"See SECURITY_AUDIT.md for configuration details.",
|
||||
)
|
||||
f.write(chunk)
|
||||
await f.write(chunk)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -1384,29 +1463,7 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
||||
# Check for exact duplicates (same SHA-256 hash) before returning.
|
||||
# This gives the caller an immediate warning without waiting for the pipeline.
|
||||
# Only performed when deduplication is enabled in settings.
|
||||
exact_duplicate_warning = None
|
||||
if settings.enable_deduplication:
|
||||
try:
|
||||
filehash = hash_file(target_path)
|
||||
existing = (
|
||||
db.query(FileRecord)
|
||||
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(False))
|
||||
.order_by(FileRecord.id.asc())
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
exact_duplicate_warning = {
|
||||
"duplicate_type": "exact",
|
||||
"original_file_id": existing.id,
|
||||
"original_filename": existing.original_filename,
|
||||
"message": (
|
||||
"This file appears to be an exact duplicate of an already-processed document. "
|
||||
"It will still be queued but will be flagged as a duplicate."
|
||||
),
|
||||
}
|
||||
logger.info(f"Exact duplicate detected on upload: '{safe_filename}' matches file ID {existing.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Duplicate check failed for uploaded file '{safe_filename}': {e}")
|
||||
exact_duplicate_warning = _check_for_exact_duplicate(db, target_path, safe_filename)
|
||||
|
||||
response: dict = {
|
||||
"task_id": task.id,
|
||||
@@ -1458,7 +1515,7 @@ def claim_file(request: Request, file_id: int, db: DbSession):
|
||||
logger.exception(f"Error claiming file {file_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to claim document")
|
||||
|
||||
logger.info(f"File {file_id} claimed by user '{owner_id}'")
|
||||
logger.info("File %d claimed by user", file_id)
|
||||
return {"status": "success", "message": "Document claimed successfully", "file_id": file_id, "owner_id": owner_id}
|
||||
|
||||
|
||||
@@ -1498,7 +1555,7 @@ def bulk_claim_files(request: Request, file_ids: list[int], db: DbSession):
|
||||
logger.exception(f"Error during bulk claim: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to claim documents")
|
||||
|
||||
logger.info(f"Bulk claim by '{owner_id}': claimed={claimed}, skipped={[s['file_id'] for s in skipped]}")
|
||||
logger.info("Bulk claim: claimed=%s, skipped=%s", claimed, [s["file_id"] for s in skipped])
|
||||
return {
|
||||
"status": "success",
|
||||
"claimed_count": len(claimed),
|
||||
@@ -1551,8 +1608,7 @@ def assign_owner(request: Request, db: DbSession, owner_id: str = Query(...), fi
|
||||
logger.exception(f"Error assigning owner: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to assign owner")
|
||||
|
||||
admin_name = get_current_owner_id(request) or "admin"
|
||||
logger.info(f"Admin '{admin_name}' assigned owner_id='{owner_id}' to {updated} file(s)")
|
||||
logger.info("Admin assigned owner to %d file(s)", updated)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Assigned owner to {updated} document(s)",
|
||||
|
||||
+15
-3
@@ -23,6 +23,17 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""Dependency to ensure the current user is an admin."""
|
||||
user = request.session.get("user")
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||||
|
||||
|
||||
@router.post("/google-drive/exchange-token")
|
||||
@require_login
|
||||
async def exchange_google_drive_token(
|
||||
@@ -362,9 +373,9 @@ def format_time_remaining(time_delta):
|
||||
|
||||
|
||||
@router.post("/google-drive/save-settings")
|
||||
@require_login
|
||||
async def save_google_drive_settings(
|
||||
request: Request,
|
||||
_admin: AdminUser,
|
||||
refresh_token: Annotated[str, Form(...)],
|
||||
client_id: Annotated[Optional[str], Form()] = None,
|
||||
client_secret: Annotated[Optional[str], Form()] = None,
|
||||
@@ -404,7 +415,8 @@ async def save_google_drive_settings(
|
||||
drive_settings["GOOGLE_DRIVE_FOLDER_ID"] = folder_id
|
||||
|
||||
# Try to update the .env file, but don't fail if it doesn't exist (for Docker containers)
|
||||
if not update_env_file(env_path, drive_settings):
|
||||
env_write_success = update_env_file(env_path, drive_settings)
|
||||
if not env_write_success:
|
||||
logger.info("Continuing with in-memory update despite .env file update failure or skip")
|
||||
|
||||
# Update the settings in memory (this always happens)
|
||||
@@ -443,7 +455,7 @@ async def save_google_drive_settings(
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Google Drive settings have been saved",
|
||||
"in_memory_only": not os.path.exists(env_path),
|
||||
"in_memory_only": not env_write_success,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import UserImapAccount
|
||||
from app.utils.encryption import decrypt_value, encrypt_value
|
||||
from app.utils.network import is_private_ip
|
||||
from app.utils.subscription import get_tier, get_user_tier_id
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
@@ -187,6 +188,11 @@ def _test_imap_connection(host: str, port: int, username: str, password: str, us
|
||||
|
||||
Returns a dict with ``{"success": bool, "message": str}``.
|
||||
"""
|
||||
|
||||
# Security: Prevent SSRF by blocking connections to internal IPs
|
||||
if is_private_ip(host):
|
||||
logger.warning("SSRF blocked: Attempt to connect to private IP %s", host)
|
||||
return {"success": False, "message": "Connection error: Invalid hostname or IP address"}
|
||||
try:
|
||||
if use_ssl:
|
||||
mail = imaplib.IMAP4_SSL(host, port)
|
||||
|
||||
@@ -452,17 +452,16 @@ async def update_preferences(
|
||||
)
|
||||
|
||||
try:
|
||||
# Pre-fetch existing preferences for this user to avoid N+1 queries
|
||||
existing_prefs = (
|
||||
db.query(UserNotificationPreference).filter(UserNotificationPreference.owner_id == owner_id).all()
|
||||
)
|
||||
|
||||
# Build a fast lookup dictionary keyed by (event_type, channel_type, target_id)
|
||||
prefs_dict = {(pref.event_type, pref.channel_type, pref.target_id): pref for pref in existing_prefs}
|
||||
|
||||
for item in body.preferences:
|
||||
existing = (
|
||||
db.query(UserNotificationPreference)
|
||||
.filter(
|
||||
UserNotificationPreference.owner_id == owner_id,
|
||||
UserNotificationPreference.event_type == item.event_type,
|
||||
UserNotificationPreference.channel_type == item.channel_type,
|
||||
UserNotificationPreference.target_id == item.target_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
existing = prefs_dict.get((item.event_type, item.channel_type, item.target_id))
|
||||
if existing:
|
||||
existing.is_enabled = item.is_enabled
|
||||
else:
|
||||
|
||||
+18
-33
@@ -7,7 +7,7 @@ import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import requests
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -24,6 +24,17 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""Dependency to ensure the current user is an admin."""
|
||||
user = request.session.get("user")
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||||
|
||||
|
||||
@router.post("/onedrive/exchange-token")
|
||||
@require_login
|
||||
async def exchange_onedrive_token(
|
||||
@@ -92,7 +103,8 @@ async def test_onedrive_token(request: Request):
|
||||
"scope": "offline_access Files.ReadWrite",
|
||||
}
|
||||
|
||||
response = requests.post(token_url, data=refresh_data, timeout=settings.http_request_timeout)
|
||||
async with httpx.AsyncClient(timeout=settings.http_request_timeout) as client:
|
||||
response = await client.post(token_url, data=refresh_data)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to refresh OneDrive token: {response.text}")
|
||||
@@ -115,32 +127,7 @@ async def test_onedrive_token(request: Request):
|
||||
settings.onedrive_refresh_token = new_refresh_token
|
||||
|
||||
# Also try to update .env file if it exists
|
||||
try:
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, "r") as f:
|
||||
env_lines = f.readlines()
|
||||
|
||||
updated_lines = []
|
||||
updated = False
|
||||
|
||||
for line in env_lines:
|
||||
if line.startswith("ONEDRIVE_REFRESH_TOKEN="):
|
||||
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
|
||||
updated = True
|
||||
else:
|
||||
updated_lines.append(line)
|
||||
|
||||
if not updated:
|
||||
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
|
||||
|
||||
with open(env_path, "w") as f:
|
||||
f.writelines(updated_lines)
|
||||
|
||||
logger.info("Updated refresh token in .env file")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update refresh token in .env file: {e}")
|
||||
update_env_file({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token})
|
||||
|
||||
# Persist the rotated refresh token to the database
|
||||
try:
|
||||
@@ -164,7 +151,8 @@ async def test_onedrive_token(request: Request):
|
||||
user_info_url = "https://graph.microsoft.com/v1.0/me"
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
user_response = requests.get(user_info_url, headers=headers, timeout=settings.http_request_timeout)
|
||||
async with httpx.AsyncClient(timeout=settings.http_request_timeout) as client:
|
||||
user_response = await client.get(user_info_url, headers=headers)
|
||||
|
||||
if user_response.status_code != 200:
|
||||
logger.error(f"OneDrive token test failed: {user_response.status_code} {user_response.text}")
|
||||
@@ -227,9 +215,9 @@ def format_time_remaining(time_delta):
|
||||
|
||||
|
||||
@router.post("/onedrive/save-settings")
|
||||
@require_login
|
||||
async def save_onedrive_settings(
|
||||
request: Request,
|
||||
_admin: AdminUser,
|
||||
refresh_token: Annotated[str, Form(...)],
|
||||
client_id: Annotated[Optional[str], Form()] = None,
|
||||
client_secret: Annotated[Optional[str], Form()] = None,
|
||||
@@ -247,7 +235,6 @@ async def save_onedrive_settings(
|
||||
)
|
||||
|
||||
# Best-effort .env file write
|
||||
try:
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
||||
onedrive_settings = {"ONEDRIVE_REFRESH_TOKEN": refresh_token}
|
||||
if client_id:
|
||||
@@ -261,8 +248,6 @@ async def save_onedrive_settings(
|
||||
|
||||
if not update_env_file(env_path, onedrive_settings):
|
||||
logger.info("Continuing with in-memory update despite .env file update failure or skip")
|
||||
except Exception as env_err:
|
||||
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
|
||||
|
||||
# Update the settings in memory
|
||||
if refresh_token:
|
||||
|
||||
+11
-2
@@ -196,11 +196,20 @@ def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Update sort_order for each plan_id in *body.order* (position = index in list)."""
|
||||
updated = 0
|
||||
for sort_order, plan_id in enumerate(body.order):
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
|
||||
|
||||
# Fetch all requested plans in a single query to avoid N+1
|
||||
plan_ids = body.order
|
||||
plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id.in_(plan_ids)).all()
|
||||
|
||||
# Build a map for fast O(1) lookup
|
||||
plan_map = {p.plan_id: p for p in plans}
|
||||
|
||||
for sort_order, plan_id in enumerate(plan_ids):
|
||||
plan = plan_map.get(plan_id)
|
||||
if plan:
|
||||
plan.sort_order = sort_order
|
||||
updated += 1
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
|
||||
@@ -99,6 +99,8 @@ class ProfileResponse(BaseModel):
|
||||
contact_email: str | None
|
||||
preferred_language: str | None
|
||||
preferred_theme: str | None
|
||||
default_document_language: str | None
|
||||
"""ISO 639-1 code for the user's preferred document translation target language."""
|
||||
avatar_url: str
|
||||
"""Gravatar URL or ``data:`` URI for a custom uploaded avatar."""
|
||||
is_local_user: bool
|
||||
@@ -112,6 +114,10 @@ class ProfileUpdateRequest(BaseModel):
|
||||
contact_email: str | None = Field(default=None, max_length=255, description="Contact / notification e-mail")
|
||||
preferred_language: str | None = Field(default=None, description="ISO 639-1 language code, e.g. 'en', 'de'")
|
||||
preferred_theme: str | None = Field(default=None, description="Colour scheme: 'light', 'dark', or 'system'")
|
||||
default_document_language: str | None = Field(
|
||||
default=None,
|
||||
description="ISO 639-1 code for the default document translation target language, e.g. 'en', 'de'",
|
||||
)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
@@ -149,6 +155,7 @@ async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
|
||||
contact_email=profile.contact_email, # type: ignore[arg-type]
|
||||
preferred_language=profile.preferred_language, # type: ignore[arg-type]
|
||||
preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
|
||||
default_document_language=profile.default_document_language, # type: ignore[arg-type]
|
||||
avatar_url=avatar_url,
|
||||
is_local_user=is_local,
|
||||
)
|
||||
@@ -201,6 +208,16 @@ async def update_profile(
|
||||
)
|
||||
profile.preferred_theme = theme or None # type: ignore[assignment]
|
||||
|
||||
# Validate default document language
|
||||
if body.default_document_language is not None:
|
||||
doc_lang = body.default_document_language.lower().strip()
|
||||
if doc_lang and doc_lang not in SUPPORTED_LANGUAGE_CODES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Unsupported language code: {doc_lang}",
|
||||
)
|
||||
profile.default_document_language = doc_lang or None # type: ignore[assignment]
|
||||
|
||||
if body.display_name is not None:
|
||||
profile.display_name = body.display_name.strip() or None # type: ignore[assignment]
|
||||
|
||||
@@ -225,6 +242,7 @@ async def update_profile(
|
||||
contact_email=profile.contact_email, # type: ignore[arg-type]
|
||||
preferred_language=profile.preferred_language, # type: ignore[arg-type]
|
||||
preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
|
||||
default_document_language=profile.default_document_language, # type: ignore[arg-type]
|
||||
avatar_url=avatar_url,
|
||||
is_local_user=is_local,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""QR code login API endpoints for mobile app authentication.
|
||||
|
||||
Provides a secure challenge-response flow for logging into the mobile app
|
||||
by scanning a QR code displayed in the web interface:
|
||||
|
||||
1. **Web user** calls ``POST /qr-auth/challenge`` → receives a time-limited
|
||||
challenge token (encoded in the QR code).
|
||||
2. **Web UI** polls ``GET /qr-auth/challenge/{id}/status`` to detect when
|
||||
the mobile app has claimed the challenge.
|
||||
3. **Mobile app** scans the QR code and calls ``POST /qr-auth/claim`` with
|
||||
the challenge token + device name → receives an API token.
|
||||
|
||||
Security properties:
|
||||
* Challenges expire after a configurable TTL (default 2 minutes).
|
||||
* Single-use: once claimed, a challenge cannot be reused (replay-safe).
|
||||
* Cryptographically random 64-byte tokens.
|
||||
* IP addresses are logged for audit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
import segno
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_login
|
||||
from app.database import get_db
|
||||
from app.middleware.audit_log import get_client_ip
|
||||
from app.utils.session_manager import (
|
||||
claim_qr_challenge,
|
||||
create_qr_challenge,
|
||||
get_challenge_status,
|
||||
)
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/qr-auth", tags=["qr-auth"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_owner_id(request: Request) -> str:
|
||||
"""Return the current user's owner ID, raising 401 if unauthenticated."""
|
||||
owner_id = get_current_owner_id(request)
|
||||
if not owner_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
return owner_id
|
||||
|
||||
|
||||
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CreateChallengeResponse(BaseModel):
|
||||
"""Response after creating a QR login challenge."""
|
||||
|
||||
challenge_id: int
|
||||
challenge_token: str
|
||||
expires_at: datetime
|
||||
ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).")
|
||||
qr_payload: str = Field(description="The string to encode in the QR code.")
|
||||
qr_code_svg: str = Field(description="Base64-encoded SVG data URI of the QR code, ready for use in an <img> src.")
|
||||
|
||||
|
||||
class ChallengeStatusResponse(BaseModel):
|
||||
"""Response for polling the status of a QR challenge."""
|
||||
|
||||
id: int
|
||||
status: str # "pending", "claimed", "expired", "cancelled"
|
||||
device_name: str | None = None
|
||||
claimed_at: datetime | None = None
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class ClaimChallengeRequest(BaseModel):
|
||||
"""Request body for claiming a QR login challenge."""
|
||||
|
||||
challenge_token: str = Field(min_length=1, max_length=256)
|
||||
device_name: str = Field(
|
||||
default="Mobile App",
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
description="Human-readable device name.",
|
||||
)
|
||||
|
||||
|
||||
class ClaimChallengeResponse(BaseModel):
|
||||
"""Response after successfully claiming a QR challenge."""
|
||||
|
||||
token: str
|
||||
token_id: int
|
||||
name: str
|
||||
owner_id: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# QR code rendering parameters
|
||||
_QR_ERROR_LEVEL = "M" # Medium error correction (~15% recovery); sufficient for on-screen display
|
||||
_QR_SCALE = 4 # Each QR module is rendered as 4×4 SVG pixels
|
||||
|
||||
|
||||
def _generate_qr_svg(payload: str) -> str:
|
||||
"""Generate a QR code for *payload* and return it as a base64 SVG data URI.
|
||||
|
||||
Using ``segno`` (pure-Python, no Pillow dependency) and SVG output so the
|
||||
QR code scales crisply at any resolution without requiring a canvas or any
|
||||
client-side JavaScript library.
|
||||
"""
|
||||
qr = segno.make(payload, error=_QR_ERROR_LEVEL)
|
||||
buf = io.BytesIO()
|
||||
qr.save(buf, kind="svg", scale=_QR_SCALE, xmldecl=False, svgclass=None, lineclass=None, omitsize=True)
|
||||
svg_bytes = buf.getvalue()
|
||||
return "data:image/svg+xml;base64," + base64.b64encode(svg_bytes).decode("ascii")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/challenge", status_code=status.HTTP_201_CREATED, response_model=CreateChallengeResponse)
|
||||
@require_login
|
||||
async def create_challenge(
|
||||
request: Request,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new QR login challenge.
|
||||
|
||||
The returned ``qr_payload`` should be encoded into a QR code and
|
||||
displayed to the user. The mobile app scans this QR code and
|
||||
calls the ``/claim`` endpoint.
|
||||
"""
|
||||
ip = get_client_ip(request)
|
||||
challenge = create_qr_challenge(db, owner_id, ip_address=ip)
|
||||
|
||||
# The QR payload is a JSON-like string with enough info for the mobile
|
||||
# app to know the server URL and challenge token.
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
qr_payload = f"docuelevate://qr-login?token={challenge.challenge_token}&server={base_url}"
|
||||
|
||||
# Compute the TTL in seconds so the client can run a countdown timer
|
||||
# without comparing absolute timestamps (which breaks when client and
|
||||
# server clocks are out of sync).
|
||||
ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
|
||||
|
||||
return {
|
||||
"challenge_id": challenge.id,
|
||||
"challenge_token": challenge.challenge_token,
|
||||
"expires_at": challenge.expires_at,
|
||||
"ttl_seconds": ttl_seconds,
|
||||
"qr_payload": qr_payload,
|
||||
"qr_code_svg": _generate_qr_svg(qr_payload),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/challenge/{challenge_id}/status", response_model=ChallengeStatusResponse)
|
||||
@require_login
|
||||
async def poll_challenge_status(
|
||||
request: Request,
|
||||
challenge_id: int,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Poll the status of a QR login challenge.
|
||||
|
||||
The web UI calls this endpoint every few seconds to check if the
|
||||
mobile app has scanned the QR code and claimed the challenge.
|
||||
"""
|
||||
result = get_challenge_status(db, challenge_id, owner_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/claim", response_model=ClaimChallengeResponse)
|
||||
async def claim_challenge(
|
||||
request: Request,
|
||||
body: ClaimChallengeRequest,
|
||||
db: DbSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Claim a QR login challenge and receive an API token.
|
||||
|
||||
This endpoint is called by the mobile app after scanning a QR code.
|
||||
It does **not** require authentication — the challenge token itself
|
||||
serves as proof that the user authorized this login from their web
|
||||
session.
|
||||
"""
|
||||
ip = get_client_ip(request)
|
||||
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid, expired, or already claimed challenge.",
|
||||
)
|
||||
|
||||
try:
|
||||
from app.utils.audit_service import record_event
|
||||
|
||||
record_event(
|
||||
db,
|
||||
action="qr_login_claimed",
|
||||
user=result["owner_id"],
|
||||
resource_type="session",
|
||||
ip_address=ip,
|
||||
details={"device_name": body.device_name, "token_id": result["token_id"]},
|
||||
severity="info",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to write QR login audit event", exc_info=True)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,196 @@
|
||||
"""API endpoints for managing user sessions.
|
||||
|
||||
Provides endpoints for listing active sessions, revoking individual sessions,
|
||||
and the "log off everywhere" feature that invalidates all sessions and API
|
||||
tokens across all devices.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_login
|
||||
from app.database import get_db
|
||||
from app.middleware.audit_log import get_client_ip
|
||||
from app.utils.session_manager import (
|
||||
get_session_lifetime_days,
|
||||
list_user_sessions,
|
||||
revoke_all_sessions,
|
||||
revoke_session,
|
||||
)
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_owner_id(request: Request) -> str:
|
||||
"""Return the current user's owner ID, raising 401 if unauthenticated."""
|
||||
owner_id = get_current_owner_id(request)
|
||||
if not owner_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
return owner_id
|
||||
|
||||
|
||||
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
"""Serialised user session for the management UI."""
|
||||
|
||||
id: int
|
||||
device_info: str | None
|
||||
ip_address: str | None
|
||||
created_at: datetime
|
||||
last_active_at: datetime
|
||||
expires_at: datetime
|
||||
is_current: bool = False
|
||||
|
||||
|
||||
class SessionListResponse(BaseModel):
|
||||
"""Response for listing active sessions."""
|
||||
|
||||
sessions: list[SessionResponse]
|
||||
session_lifetime_days: int
|
||||
|
||||
|
||||
class RevokeAllResponse(BaseModel):
|
||||
"""Response after revoking all sessions."""
|
||||
|
||||
revoked_count: int
|
||||
message: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/", response_model=SessionListResponse)
|
||||
@require_login
|
||||
async def list_sessions(
|
||||
request: Request,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> dict[str, Any]:
|
||||
"""List all active sessions for the current user."""
|
||||
sessions = list_user_sessions(db, owner_id)
|
||||
|
||||
# Determine which session is the current one
|
||||
current_token = request.session.get("_session_token")
|
||||
|
||||
session_list = []
|
||||
for s in sessions:
|
||||
session_list.append(
|
||||
{
|
||||
"id": s.id,
|
||||
"device_info": s.device_info,
|
||||
"ip_address": s.ip_address,
|
||||
"created_at": s.created_at,
|
||||
"last_active_at": s.last_active_at,
|
||||
"expires_at": s.expires_at,
|
||||
"is_current": s.session_token == current_token if current_token else False,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"sessions": session_list,
|
||||
"session_lifetime_days": get_session_lifetime_days(),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@require_login
|
||||
async def revoke_single_session(
|
||||
request: Request,
|
||||
session_id: int,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> None:
|
||||
"""Revoke a specific session by ID."""
|
||||
success = revoke_session(db, session_id, owner_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
|
||||
try:
|
||||
from app.utils.audit_service import record_event
|
||||
|
||||
record_event(
|
||||
db,
|
||||
action="session_revoked",
|
||||
user=owner_id,
|
||||
resource_type="session",
|
||||
resource_id=str(session_id),
|
||||
ip_address=get_client_ip(request),
|
||||
severity="info",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to write session revocation audit event", exc_info=True)
|
||||
|
||||
|
||||
@router.post("/revoke-all", response_model=RevokeAllResponse)
|
||||
@require_login
|
||||
async def revoke_all(
|
||||
request: Request,
|
||||
owner_id: CurrentOwner,
|
||||
db: DbSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Revoke all sessions except the current one ("log off everywhere").
|
||||
|
||||
Also revokes all active API tokens for the user, which invalidates
|
||||
mobile app sessions and any programmatic access.
|
||||
"""
|
||||
# Find current session to preserve it
|
||||
current_token = request.session.get("_session_token")
|
||||
current_session_id = None
|
||||
if current_token:
|
||||
from app.models import UserSession
|
||||
|
||||
current = db.query(UserSession).filter(UserSession.session_token == current_token).first()
|
||||
if current:
|
||||
current_session_id = current.id
|
||||
|
||||
count = revoke_all_sessions(
|
||||
db,
|
||||
owner_id,
|
||||
except_session_id=current_session_id,
|
||||
revoke_api_tokens=True,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.utils.audit_service import record_event
|
||||
|
||||
record_event(
|
||||
db,
|
||||
action="revoke_all_sessions",
|
||||
user=owner_id,
|
||||
resource_type="session",
|
||||
ip_address=get_client_ip(request),
|
||||
details={"revoked_count": count},
|
||||
severity="warning",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to write revoke-all audit event", exc_info=True)
|
||||
|
||||
return {
|
||||
"revoked_count": count,
|
||||
"message": f"Successfully revoked {count} session(s) and all API tokens.",
|
||||
}
|
||||
@@ -313,16 +313,18 @@ async def list_shared_links(
|
||||
active_only: bool = Query(False, description="When true, only return active (non-revoked) links"),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all shared links created by the authenticated user."""
|
||||
q = db.query(SharedLink).filter(SharedLink.owner_id == owner_id)
|
||||
q = (
|
||||
db.query(SharedLink, FileRecord.original_filename)
|
||||
.outerjoin(FileRecord, SharedLink.file_id == FileRecord.id)
|
||||
.filter(SharedLink.owner_id == owner_id)
|
||||
)
|
||||
if active_only:
|
||||
q = q.filter(SharedLink.is_active.is_(True))
|
||||
links = q.order_by(SharedLink.created_at.desc()).all()
|
||||
links_with_filenames = q.order_by(SharedLink.created_at.desc()).all()
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
result = []
|
||||
for link in links:
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first()
|
||||
filename = file_record.original_filename if file_record else None
|
||||
for link, filename in links_with_filenames:
|
||||
result.append(_link_to_dict(link, base_url, filename))
|
||||
return result
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
System reset API endpoints for DocuElevate.
|
||||
|
||||
Provides admin-only REST endpoints for:
|
||||
- Full system reset (wipe all user data)
|
||||
- Reset with re-import (move originals → reimport folder, wipe, re-ingest)
|
||||
|
||||
Both operations require the ``ENABLE_FACTORY_RESET=True`` feature flag and
|
||||
admin privileges.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/admin/system-reset", tags=["system-reset"])
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
||||
user = request.session.get("user")
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||||
|
||||
|
||||
def _require_feature_enabled() -> None:
|
||||
"""Raise 404 when the factory-reset feature flag is off."""
|
||||
if not settings.enable_factory_reset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="System reset is not enabled. Set ENABLE_FACTORY_RESET=True to activate.",
|
||||
)
|
||||
|
||||
|
||||
class ResetRequest(BaseModel):
|
||||
"""Body for system reset endpoints. Requires explicit confirmation."""
|
||||
|
||||
confirmation: str
|
||||
|
||||
|
||||
@router.post("/full")
|
||||
async def full_reset(
|
||||
body: ResetRequest,
|
||||
_admin: AdminUser,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Wipe all user data (database + work-files).
|
||||
|
||||
The caller must send ``{"confirmation": "DELETE"}`` to proceed.
|
||||
"""
|
||||
_require_feature_enabled()
|
||||
|
||||
if body.confirmation != "DELETE":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Confirmation required: send {"confirmation": "DELETE"} to proceed.',
|
||||
)
|
||||
|
||||
from app.utils.system_reset import perform_full_reset
|
||||
|
||||
try:
|
||||
result = perform_full_reset(db)
|
||||
except Exception as exc:
|
||||
logger.exception("Full system reset failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"System reset failed: {exc}",
|
||||
) from exc
|
||||
|
||||
return {"status": "ok", "result": result}
|
||||
|
||||
|
||||
@router.post("/reimport")
|
||||
async def reset_and_reimport(
|
||||
body: ResetRequest,
|
||||
_admin: AdminUser,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Move original files to a reimport folder, wipe everything, and
|
||||
configure the reimport folder as a watch folder for automatic
|
||||
re-ingestion.
|
||||
|
||||
The caller must send ``{"confirmation": "REIMPORT"}`` to proceed.
|
||||
"""
|
||||
_require_feature_enabled()
|
||||
|
||||
if body.confirmation != "REIMPORT":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Confirmation required: send {"confirmation": "REIMPORT"} to proceed.',
|
||||
)
|
||||
|
||||
from app.utils.system_reset import perform_reset_and_reimport
|
||||
|
||||
try:
|
||||
result = perform_reset_and_reimport(db)
|
||||
except Exception as exc:
|
||||
logger.exception("Reset-and-reimport failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Reset and reimport failed: {exc}",
|
||||
) from exc
|
||||
|
||||
return {"status": "ok", "result": result}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def reset_status(_admin: AdminUser) -> dict:
|
||||
"""Return whether the system reset feature is enabled."""
|
||||
return {
|
||||
"enabled": settings.enable_factory_reset,
|
||||
"factory_reset_on_startup": settings.factory_reset_on_startup,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
API endpoints for document translation.
|
||||
|
||||
Provides on-the-fly translation via the AI provider and access to the
|
||||
persisted default-language translation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_login
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import FileRecord
|
||||
from app.utils.ai_provider import get_ai_provider
|
||||
from app.utils.user_scope import apply_owner_filter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
# Maximum characters sent to the AI provider for a single translation request.
|
||||
_MAX_TRANSLATION_INPUT = 50_000
|
||||
|
||||
|
||||
def _get_file_or_404(db: Session, file_id: int, request: Request) -> FileRecord:
|
||||
"""Fetch a FileRecord visible to the current user or raise 404."""
|
||||
query = db.query(FileRecord).filter(FileRecord.id == file_id)
|
||||
query = apply_owner_filter(query, request)
|
||||
record = query.first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
return record
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/translation/default")
|
||||
@require_login
|
||||
def get_default_translation(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
db: DbSession,
|
||||
) -> JSONResponse:
|
||||
"""Return the persisted default-language translation for a document.
|
||||
|
||||
Returns 404 if no default-language translation has been generated yet
|
||||
(e.g. because the document is already in the default language).
|
||||
"""
|
||||
record = _get_file_or_404(db, file_id, request)
|
||||
|
||||
if not record.default_language_text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No default-language translation available for this file",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"file_id": record.id,
|
||||
"detected_language": record.detected_language,
|
||||
"default_language_code": record.default_language_code,
|
||||
"text": record.default_language_text,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/translate")
|
||||
@require_login
|
||||
def translate_on_the_fly(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
db: DbSession,
|
||||
lang: str = Query(..., min_length=2, max_length=10, description="Target language ISO 639-1 code"),
|
||||
) -> JSONResponse:
|
||||
"""Translate a document's extracted text into an arbitrary language on the fly.
|
||||
|
||||
The translation is generated via the configured AI provider and is **not**
|
||||
persisted. For the default-language translation, use the
|
||||
``/files/{file_id}/translation/default`` endpoint instead.
|
||||
"""
|
||||
record = _get_file_or_404(db, file_id, request)
|
||||
|
||||
source_text = record.ocr_text
|
||||
if not source_text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No extracted text available for this file — translation requires OCR text",
|
||||
)
|
||||
|
||||
# If the requested language matches what is already stored, return it directly.
|
||||
if record.default_language_code and lang == record.default_language_code and record.default_language_text:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"file_id": record.id,
|
||||
"source_language": record.detected_language,
|
||||
"target_language": lang,
|
||||
"text": record.default_language_text,
|
||||
"cached": True,
|
||||
}
|
||||
)
|
||||
|
||||
# If the detected language already matches, return the original text.
|
||||
detected = record.detected_language
|
||||
if detected and detected == lang:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"file_id": record.id,
|
||||
"source_language": detected,
|
||||
"target_language": lang,
|
||||
"text": source_text,
|
||||
"cached": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Truncate to keep AI costs bounded.
|
||||
text_to_translate = source_text[:_MAX_TRANSLATION_INPUT]
|
||||
|
||||
try:
|
||||
provider = get_ai_provider()
|
||||
model = settings.ai_model or settings.openai_model
|
||||
translated = provider.chat_completion(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
f"You are a professional translator. Translate the following text "
|
||||
f"into {lang}. Preserve the original formatting, paragraph structure, "
|
||||
f"and meaning. Do not add any commentary — output ONLY the translated text."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": text_to_translate},
|
||||
],
|
||||
model=model,
|
||||
temperature=0.3,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(f"On-the-fly translation failed for file {file_id}: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Translation failed — the AI provider returned an error",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"file_id": record.id,
|
||||
"source_language": detected or "unknown",
|
||||
"target_language": lang,
|
||||
"text": translated,
|
||||
"cached": False,
|
||||
}
|
||||
)
|
||||
+24
-17
@@ -9,7 +9,8 @@ import urllib.parse
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
import aiofiles
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, HttpUrl, field_validator
|
||||
|
||||
@@ -153,15 +154,14 @@ async def process_url(request: Request, url_request: URLUploadRequest):
|
||||
logger.info(f"Downloading file from URL: {url}")
|
||||
|
||||
# Use configured timeout to prevent hanging
|
||||
response = requests.get(
|
||||
url,
|
||||
async with httpx.AsyncClient(
|
||||
timeout=settings.http_request_timeout,
|
||||
stream=True, # Stream to handle large files
|
||||
allow_redirects=True, # Follow redirects
|
||||
follow_redirects=True,
|
||||
headers={
|
||||
"User-Agent": "DocuElevate/1.0", # Identify ourselves
|
||||
},
|
||||
)
|
||||
) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Validate content type
|
||||
@@ -186,9 +186,16 @@ async def process_url(request: Request, url_request: URLUploadRequest):
|
||||
|
||||
# Generate unique filename
|
||||
unique_id = str(uuid.uuid4())
|
||||
if "." in safe_filename:
|
||||
file_extension = safe_filename.rsplit(".", 1)[1]
|
||||
target_filename = f"{unique_id}.{file_extension}"
|
||||
|
||||
# Check for extension using original_filename to avoid any CodeQL issues
|
||||
# with safe_filename which is derived from the URL directly.
|
||||
if "." in original_filename:
|
||||
_, ext = os.path.splitext(original_filename)
|
||||
# Strip out the leading dot and any non-alphanumeric chars
|
||||
clean_ext = "".join(c for c in ext if c.isalnum())
|
||||
if not clean_ext:
|
||||
clean_ext = "bin"
|
||||
target_filename = f"{unique_id}.{clean_ext}"
|
||||
else:
|
||||
target_filename = unique_id
|
||||
|
||||
@@ -198,16 +205,16 @@ async def process_url(request: Request, url_request: URLUploadRequest):
|
||||
downloaded_size = 0
|
||||
max_size = settings.max_upload_size
|
||||
|
||||
with open(target_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
async with aiofiles.open(target_path, "wb") as f:
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
await f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
|
||||
# Check size during download
|
||||
if downloaded_size > max_size:
|
||||
# Remove partial file
|
||||
f.close()
|
||||
await f.close()
|
||||
os.remove(target_path)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
@@ -227,19 +234,19 @@ async def process_url(request: Request, url_request: URLUploadRequest):
|
||||
"size": downloaded_size,
|
||||
}
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Timeout while downloading file from URL: {url}")
|
||||
raise HTTPException(status_code=408, detail="Request timeout: server took too long to respond")
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"Connection error while downloading file from URL: {url} - {str(e)}")
|
||||
raise HTTPException(status_code=502, detail=f"Failed to connect to URL: {str(e)}")
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error while downloading file from URL: {url} - {str(e)}")
|
||||
raise HTTPException(status_code=e.response.status_code, detail=f"HTTP error: {str(e)}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Error downloading file from URL: {url} - {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to download file: {str(e)}")
|
||||
|
||||
|
||||
+183
-2
@@ -125,8 +125,36 @@ def get_current_user(request: Request):
|
||||
# Check for Bearer token auth first (API tokens)
|
||||
api_user = getattr(request.state, "api_token_user", None)
|
||||
if isinstance(api_user, dict):
|
||||
logger.debug("[AUTH] get_current_user: resolved from API token (user_id=%s)", api_user.get("id"))
|
||||
return api_user
|
||||
return request.session.get("user")
|
||||
session_user = request.session.get("user")
|
||||
if session_user:
|
||||
# Validate server-side session if a session token is present
|
||||
session_token = request.session.get("_session_token")
|
||||
if session_token:
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from app.utils.session_manager import validate_session
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
valid = validate_session(db, session_token)
|
||||
if not valid:
|
||||
logger.debug("[AUTH] get_current_user: server-side session invalid — clearing")
|
||||
request.session.pop("user", None)
|
||||
request.session.pop("_session_token", None)
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.debug("[AUTH] get_current_user: session validation error", exc_info=True)
|
||||
logger.debug(
|
||||
"[AUTH] get_current_user: resolved from session (user=%s)",
|
||||
session_user.get("preferred_username") or session_user.get("email") or session_user.get("id"),
|
||||
)
|
||||
else:
|
||||
logger.debug("[AUTH] get_current_user: no user in session or API token")
|
||||
return session_user
|
||||
|
||||
|
||||
def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
|
||||
@@ -141,10 +169,12 @@ def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
|
||||
"""
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not isinstance(auth_header, str) or not auth_header.startswith("Bearer "):
|
||||
logger.debug("[AUTH] _resolve_bearer_user: no Bearer token in Authorization header")
|
||||
return None
|
||||
|
||||
raw_token = auth_header[7:]
|
||||
if not raw_token or not isinstance(raw_token, str):
|
||||
logger.debug("[AUTH] _resolve_bearer_user: empty or invalid token after 'Bearer ' prefix")
|
||||
return None
|
||||
|
||||
from app.api.api_tokens import hash_token
|
||||
@@ -153,8 +183,15 @@ def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
|
||||
token_hash = hash_token(raw_token)
|
||||
db_token = db.query(ApiToken).filter(ApiToken.token_hash == token_hash, ApiToken.is_active.is_(True)).first()
|
||||
if db_token is None:
|
||||
logger.debug("[AUTH] _resolve_bearer_user: no active API token matched the provided hash")
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
"[AUTH] _resolve_bearer_user: matched API token id=%s owner=%s",
|
||||
db_token.id,
|
||||
db_token.owner_id,
|
||||
)
|
||||
|
||||
# Update usage tracking
|
||||
try:
|
||||
db_token.last_used_at = datetime.now(timezone.utc)
|
||||
@@ -205,16 +242,18 @@ def require_login(func):
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
url_path = urlparse(str(request.url)).path
|
||||
# Check session auth first
|
||||
if request.session.get("user"):
|
||||
logger.debug("[AUTH] require_login: session auth OK for %s", url_path)
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return await func(*args, request=request, **kwargs)
|
||||
else:
|
||||
return func(*args, request=request, **kwargs)
|
||||
|
||||
# Fall back to Bearer token auth for API endpoints
|
||||
url_path = urlparse(str(request.url)).path
|
||||
if url_path.startswith("/api/"):
|
||||
logger.debug("[AUTH] require_login: no session, trying Bearer token for %s", url_path)
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
|
||||
@@ -228,17 +267,22 @@ def require_login(func):
|
||||
|
||||
if api_user:
|
||||
request.state.api_token_user = api_user
|
||||
logger.debug(
|
||||
"[AUTH] require_login: Bearer token auth OK for %s (user=%s)", url_path, api_user.get("id")
|
||||
)
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return await func(*args, request=request, **kwargs)
|
||||
else:
|
||||
return func(*args, request=request, **kwargs)
|
||||
|
||||
logger.debug("[AUTH] require_login: no valid auth for API endpoint %s — returning 401", url_path)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={"error": "Not authenticated"},
|
||||
)
|
||||
|
||||
# Non-API endpoint with no session — redirect to login
|
||||
logger.debug("[AUTH] require_login: no session for %s — redirecting to /login", url_path)
|
||||
request.session["redirect_after_login"] = str(request.url)
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
@@ -307,9 +351,15 @@ async def login(request: Request):
|
||||
async def oauth_login(request: Request):
|
||||
"""Handle OAuth login flow"""
|
||||
if not OAUTH_CONFIGURED:
|
||||
logger.debug("[AUTH] oauth_login: OAuth not configured — redirecting to /login")
|
||||
return RedirectResponse(url="/login?error=OAuth+not+configured", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
redirect_uri = request.url_for("oauth_callback")
|
||||
logger.debug(
|
||||
"[AUTH] oauth_login: initiating Authentik OAuth redirect_uri=%s session_keys=%s",
|
||||
redirect_uri,
|
||||
list(request.session.keys()),
|
||||
)
|
||||
return await oauth.authentik.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@@ -324,13 +374,23 @@ async def social_login(request: Request, provider: str):
|
||||
A redirect to the provider's authorization page, or back to /login on error.
|
||||
"""
|
||||
if provider not in SOCIAL_PROVIDERS:
|
||||
logger.debug(
|
||||
"[AUTH] social_login: unknown provider=%r (registered=%s)", provider, list(SOCIAL_PROVIDERS.keys())
|
||||
)
|
||||
return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
redirect_uri = request.url_for("social_callback", provider=provider)
|
||||
oauth_client = getattr(oauth, provider, None)
|
||||
if oauth_client is None:
|
||||
logger.debug("[AUTH] social_login: provider=%r registered but OAuth client not configured", provider)
|
||||
return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
logger.debug(
|
||||
"[AUTH] social_login: initiating %s OAuth, redirect_uri=%s session_keys=%s",
|
||||
provider,
|
||||
redirect_uri,
|
||||
list(request.session.keys()),
|
||||
)
|
||||
return await oauth_client.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@@ -389,27 +449,39 @@ async def social_callback(request: Request, provider: str, db: Session = Depends
|
||||
A redirect to the user's original destination or the upload page.
|
||||
"""
|
||||
if provider not in SOCIAL_PROVIDERS:
|
||||
logger.debug("[AUTH] social_callback: unknown provider=%r", provider)
|
||||
return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
oauth_client = getattr(oauth, provider, None)
|
||||
if oauth_client is None:
|
||||
logger.debug("[AUTH] social_callback: provider=%r not configured", provider)
|
||||
return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
try:
|
||||
logger.debug("[AUTH] social_callback: exchanging auth code for provider=%s", provider)
|
||||
token = await oauth_client.authorize_access_token(request)
|
||||
|
||||
# Try standard OIDC userinfo first, fall back to token-embedded userinfo
|
||||
raw_userinfo = token.get("userinfo")
|
||||
if not raw_userinfo:
|
||||
logger.debug("[AUTH] social_callback: no userinfo in token, fetching from userinfo endpoint")
|
||||
try:
|
||||
resp = await oauth_client.userinfo(token=token)
|
||||
raw_userinfo = resp if isinstance(resp, dict) else resp.json() if hasattr(resp, "json") else {}
|
||||
except Exception:
|
||||
logger.debug("[AUTH] social_callback: userinfo endpoint failed, using empty dict", exc_info=True)
|
||||
raw_userinfo = {}
|
||||
|
||||
user_data = _normalize_social_userinfo(provider, token, raw_userinfo)
|
||||
logger.debug(
|
||||
"[AUTH] social_callback: normalized user_data email=%s sub=%s provider=%s",
|
||||
user_data.get("email"),
|
||||
user_data.get("sub"),
|
||||
provider,
|
||||
)
|
||||
|
||||
if not user_data.get("email"):
|
||||
logger.debug("[AUTH] social_callback: no email in user_data — aborting")
|
||||
return RedirectResponse(
|
||||
url="/login?error=Could+not+retrieve+email+from+provider",
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
@@ -428,6 +500,27 @@ async def social_callback(request: Request, provider: str, db: Session = Depends
|
||||
|
||||
request.session["user"] = user_data
|
||||
|
||||
# Create server-side session for tracking and revocation
|
||||
try:
|
||||
from app.utils.session_manager import create_session
|
||||
|
||||
_session_user_id = (
|
||||
user_data.get("sub")
|
||||
or user_data.get("preferred_username")
|
||||
or user_data.get("email")
|
||||
or user_data.get("id")
|
||||
)
|
||||
if _session_user_id:
|
||||
user_session = create_session(
|
||||
db,
|
||||
user_id=_session_user_id,
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
request.session["_session_token"] = user_session.session_token
|
||||
except Exception:
|
||||
logger.debug("[AUTH] Failed to create server-side session for social user", exc_info=True)
|
||||
|
||||
# Auto-create or update UserProfile
|
||||
_ensure_user_profile(db, user_data, is_admin=False)
|
||||
|
||||
@@ -442,21 +535,29 @@ async def social_callback(request: Request, provider: str, db: Session = Depends
|
||||
)
|
||||
|
||||
# Mobile app flow: issue an inline API token and redirect back to the app.
|
||||
logger.debug(
|
||||
"[MOBILE] social_callback: checking for mobile redirect (session has mobile_redirect_uri=%s)",
|
||||
"mobile_redirect_uri" in request.session,
|
||||
)
|
||||
mobile_resp = _create_mobile_redirect(request, db)
|
||||
if mobile_resp:
|
||||
logger.info("[MOBILE] social_callback: returning mobile redirect response for provider=%s", provider)
|
||||
return mobile_resp
|
||||
|
||||
if user_id:
|
||||
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
|
||||
if profile and not profile.onboarding_completed:
|
||||
logger.debug("[AUTH] social_callback: user=%s needs onboarding, redirecting", user_id)
|
||||
post_onboarding = request.session.pop("redirect_after_login", "/upload")
|
||||
request.session["post_onboarding_redirect"] = post_onboarding
|
||||
return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||
logger.debug("[AUTH] social_callback: login complete, redirecting to %s", redirect_url)
|
||||
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
|
||||
except Exception as e:
|
||||
logger.warning("[SECURITY] SOCIAL_LOGIN_FAILURE provider=%s error=%s", provider, type(e).__name__)
|
||||
logger.debug("[AUTH] social_callback: full exception for provider=%s", provider, exc_info=True)
|
||||
return RedirectResponse(
|
||||
url="/login?error=Social+login+failed.+Please+try+again.", status_code=status.HTTP_302_FOUND
|
||||
)
|
||||
@@ -561,15 +662,23 @@ def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -
|
||||
async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
"""Handle OAuth callback from provider"""
|
||||
try:
|
||||
logger.debug("[AUTH] oauth_callback: exchanging authorization code for token")
|
||||
token = await oauth.authentik.authorize_access_token(request)
|
||||
userinfo = token.get("userinfo")
|
||||
if not userinfo:
|
||||
logger.debug("[AUTH] oauth_callback: no userinfo in token response — aborting")
|
||||
return RedirectResponse(
|
||||
url="/login?error=Failed+to+retrieve+user+information", status_code=status.HTTP_302_FOUND
|
||||
)
|
||||
|
||||
# Store user info in session
|
||||
user_data = dict(userinfo)
|
||||
logger.debug(
|
||||
"[AUTH] oauth_callback: received userinfo email=%s sub=%s groups=%s",
|
||||
user_data.get("email"),
|
||||
user_data.get("sub"),
|
||||
user_data.get("groups", []),
|
||||
)
|
||||
|
||||
# Add Gravatar picture if no picture is provided
|
||||
if not user_data.get("picture") and user_data.get("email"):
|
||||
@@ -584,12 +693,39 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
groups = user_data.get("groups", [])
|
||||
admin_group = (settings.admin_group_name or "admin").strip().lower()
|
||||
is_admin = admin_group in [group.lower() for group in groups]
|
||||
logger.debug(
|
||||
"[AUTH] oauth_callback: admin group check — looking for %r in %s → is_admin=%s",
|
||||
admin_group,
|
||||
[g.lower() for g in groups],
|
||||
is_admin,
|
||||
)
|
||||
|
||||
# Set is_admin flag (defaults to False for OAuth users unless they're in admin group)
|
||||
user_data["is_admin"] = is_admin
|
||||
|
||||
request.session["user"] = user_data
|
||||
|
||||
# Create server-side session for tracking and revocation
|
||||
try:
|
||||
from app.utils.session_manager import create_session
|
||||
|
||||
_session_user_id = (
|
||||
user_data.get("sub")
|
||||
or user_data.get("preferred_username")
|
||||
or user_data.get("email")
|
||||
or user_data.get("id")
|
||||
)
|
||||
if _session_user_id:
|
||||
user_session = create_session(
|
||||
db,
|
||||
user_id=_session_user_id,
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
request.session["_session_token"] = user_session.session_token
|
||||
except Exception:
|
||||
logger.debug("[AUTH] Failed to create server-side session for OAuth user", exc_info=True)
|
||||
|
||||
# Auto-create or update UserProfile so the user appears in admin user management
|
||||
_ensure_user_profile(db, user_data, is_admin=is_admin)
|
||||
|
||||
@@ -623,15 +759,18 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
if user_id:
|
||||
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
|
||||
if profile and not profile.onboarding_completed:
|
||||
logger.debug("[AUTH] oauth_callback: user=%s needs onboarding, redirecting", user_id)
|
||||
post_onboarding = request.session.pop("redirect_after_login", "/upload")
|
||||
request.session["post_onboarding_redirect"] = post_onboarding
|
||||
return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
# Redirect to original destination or default
|
||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||
logger.debug("[AUTH] oauth_callback: login complete, redirecting to %s", redirect_url)
|
||||
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
|
||||
except Exception as e:
|
||||
logger.warning(f"[SECURITY] OAUTH_LOGIN_FAILURE error={type(e).__name__}")
|
||||
logger.debug("[AUTH] oauth_callback: full exception details", exc_info=True)
|
||||
return RedirectResponse(url=f"/login?error=Authentication+failed:+{str(e)}", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
|
||||
@@ -840,6 +979,19 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)
|
||||
user_data = _build_session_user(local_user)
|
||||
request.session["user"] = user_data
|
||||
# Create server-side session for tracking and revocation
|
||||
try:
|
||||
from app.utils.session_manager import create_session
|
||||
|
||||
user_session = create_session(
|
||||
db,
|
||||
user_id=local_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
request.session["_session_token"] = user_session.session_token
|
||||
except Exception:
|
||||
logger.debug("[AUTH] Failed to create server-side session", exc_info=True)
|
||||
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email)
|
||||
_record_login_event(db, request, local_user.email, success=True)
|
||||
_ensure_user_profile(db, user_data, is_admin=bool(local_user.is_admin))
|
||||
@@ -896,6 +1048,20 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
"is_admin": True,
|
||||
}
|
||||
request.session["user"] = admin_user_data
|
||||
# Create server-side session for tracking and revocation
|
||||
try:
|
||||
from app.utils.session_manager import create_session
|
||||
|
||||
admin_user_id = settings.admin_username or "admin"
|
||||
user_session = create_session(
|
||||
db,
|
||||
user_id=admin_user_id,
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
request.session["_session_token"] = user_session.session_token
|
||||
except Exception:
|
||||
logger.debug("[AUTH] Failed to create server-side session for admin", exc_info=True)
|
||||
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username)
|
||||
_record_login_event(db, request, username, success=True)
|
||||
_ensure_user_profile(db, admin_user_data, is_admin=True)
|
||||
@@ -931,6 +1097,7 @@ async def logout(request: Request, db: Session = Depends(get_db)):
|
||||
username = "unknown"
|
||||
if isinstance(user, dict):
|
||||
username = user.get("preferred_username") or user.get("email") or "unknown"
|
||||
logger.debug("[AUTH] logout: clearing session for user=%s client_ip=%s", username, get_client_ip(request))
|
||||
logger.info(f"[SECURITY] LOGOUT user={username}")
|
||||
try:
|
||||
from app.utils.audit_service import record_event
|
||||
@@ -945,6 +1112,20 @@ async def logout(request: Request, db: Session = Depends(get_db)):
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to write logout audit event for user=%s", username, exc_info=True)
|
||||
# Revoke server-side session
|
||||
session_token = request.session.get("_session_token")
|
||||
if session_token:
|
||||
try:
|
||||
from app.utils.session_manager import validate_session
|
||||
|
||||
user_session = validate_session(db, session_token)
|
||||
if user_session:
|
||||
user_session.is_revoked = True
|
||||
user_session.revoked_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.debug("[AUTH] Failed to revoke server-side session", exc_info=True)
|
||||
request.session.pop("_session_token", None)
|
||||
request.session.pop("user", None)
|
||||
return RedirectResponse(url="/login?message=You+have+been+logged+out+successfully", status_code=302)
|
||||
|
||||
|
||||
+69
-2
@@ -1,10 +1,15 @@
|
||||
# app/celery_app.py
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.signals import task_failure, worker_ready
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
celery = Celery(
|
||||
"document_processor",
|
||||
broker=settings.redis_url,
|
||||
@@ -21,6 +26,64 @@ celery.conf.task_routes = {
|
||||
"app.tasks.*": {"queue": "document_processor"},
|
||||
}
|
||||
|
||||
# Mapping of document pipeline task names to the positional index of ``file_id``
|
||||
# in their ``args`` tuple. These indices correspond to the task signatures:
|
||||
# process_with_ocr(filename, file_id, ...) → index 1
|
||||
# extract_metadata_with_gpt(filename, text, file_id) → index 2
|
||||
# embed_metadata_into_pdf(path, text, metadata, file_id) → index 3
|
||||
# Tasks that always pass ``file_id`` as a keyword argument
|
||||
# (e.g. ``process_document``, ``finalize_document_storage``) are not listed
|
||||
# here — their ``file_id`` is found via ``kwargs`` instead.
|
||||
_FILE_ID_ARG_INDEX: dict[str, int] = {
|
||||
"app.tasks.process_with_ocr.process_with_ocr": 1,
|
||||
"app.tasks.extract_metadata_with_gpt.extract_metadata_with_gpt": 2,
|
||||
"app.tasks.embed_metadata_into_pdf.embed_metadata_into_pdf": 3,
|
||||
}
|
||||
|
||||
|
||||
def _dispatch_user_failure_notification(sender, exception, args: list | None, kwargs: dict | None) -> None:
|
||||
"""Best-effort per-user failure notification for document pipeline tasks.
|
||||
|
||||
Extracts ``file_id`` from the failed task's arguments, looks up the owning
|
||||
user from the database, and dispatches a ``document.failed`` notification.
|
||||
"""
|
||||
from app.database import SessionLocal
|
||||
from app.models import FileRecord
|
||||
from app.utils.user_notification import notify_user_document_failed
|
||||
|
||||
task_name = sender.name if sender else ""
|
||||
if not task_name.startswith("app.tasks."):
|
||||
return
|
||||
|
||||
# 1. Resolve file_id from kwargs or positional args
|
||||
file_id = (kwargs or {}).get("file_id")
|
||||
if file_id is None:
|
||||
idx = _FILE_ID_ARG_INDEX.get(task_name)
|
||||
if idx is not None and args and len(args) > idx:
|
||||
val = args[idx]
|
||||
if isinstance(val, int):
|
||||
file_id = val
|
||||
|
||||
if file_id is None:
|
||||
return
|
||||
|
||||
# 2. Look up owner from the database
|
||||
with SessionLocal() as db:
|
||||
record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not record or not record.owner_id:
|
||||
return
|
||||
owner_id = record.owner_id
|
||||
filename = record.original_filename or record.local_filename or "unknown"
|
||||
|
||||
# 3. Dispatch per-user notification
|
||||
error_msg = f"{type(exception).__name__}: {exception}" if exception else "Unknown error"
|
||||
notify_user_document_failed(
|
||||
owner_id=owner_id,
|
||||
filename=os.path.basename(filename),
|
||||
error=error_msg,
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def init_sentry_on_worker_ready(**kwargs):
|
||||
@@ -48,6 +111,10 @@ def task_failure_handler(
|
||||
kwargs=kwargs or {},
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger.exception(f"Failed to send task failure notification: {e}")
|
||||
|
||||
logging.exception(f"Failed to send task failure notification: {e}")
|
||||
# Also dispatch a per-user failure notification for document pipeline tasks
|
||||
try:
|
||||
_dispatch_user_failure_notification(sender, exception, args, kwargs)
|
||||
except Exception:
|
||||
logger.warning("Could not dispatch per-user failure notification", exc_info=True)
|
||||
|
||||
@@ -39,6 +39,7 @@ from app.tasks.refine_text_with_gpt import refine_text_with_gpt # noqa: F401
|
||||
from app.tasks.rotate_pdf_pages import rotate_pdf_pages # noqa: F401
|
||||
from app.tasks.send_to_all import send_to_all_destinations # noqa: F401
|
||||
from app.tasks.subscription_tasks import apply_pending_subscription_changes_all # noqa: F401
|
||||
from app.tasks.translate_to_default_language import translate_to_default_language # noqa: F401
|
||||
|
||||
# Import new send tasks
|
||||
from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401
|
||||
@@ -51,6 +52,7 @@ from app.tasks.upload_to_onedrive import upload_to_onedrive # noqa: F401
|
||||
from app.tasks.upload_to_paperless import upload_to_paperless # noqa: F401
|
||||
from app.tasks.upload_to_s3 import upload_to_s3 # noqa: F401
|
||||
from app.tasks.upload_to_sftp import upload_to_sftp # noqa: F401
|
||||
from app.tasks.upload_to_sharepoint import upload_to_sharepoint # noqa: F401
|
||||
from app.tasks.upload_to_user_integration import upload_to_user_integration # noqa: F401
|
||||
from app.tasks.upload_to_webdav import upload_to_webdav # noqa: F401
|
||||
from app.tasks.upload_with_rclone import send_to_all_rclone_destinations, upload_with_rclone # noqa: F401
|
||||
|
||||
+151
@@ -48,6 +48,51 @@ class Settings(BaseSettings):
|
||||
workdir: str
|
||||
debug: bool = False # Default to False
|
||||
|
||||
# Logging level for the application. Accepts standard Python level names:
|
||||
# DEBUG, INFO, WARNING, ERROR, CRITICAL. When *debug* is True and
|
||||
# *log_level* has not been explicitly set, the effective level is forced to
|
||||
# DEBUG so that all ``logger.debug()`` calls produce output.
|
||||
log_level: str = Field(
|
||||
default="INFO",
|
||||
description=(
|
||||
"Python logging level for the application root logger. "
|
||||
"Accepts: DEBUG, INFO, WARNING, ERROR, CRITICAL. "
|
||||
"When DEBUG=True and LOG_LEVEL is not explicitly set, "
|
||||
"the effective level is automatically lowered to DEBUG."
|
||||
),
|
||||
)
|
||||
|
||||
# Log output format. ``text`` is the human-readable default.
|
||||
# ``json`` emits one JSON object per line, ideal for log collectors
|
||||
# (Promtail, Fluentd, Filebeat, Datadog agent) and SIEM ingestion.
|
||||
log_format: str = Field(
|
||||
default="text",
|
||||
description=(
|
||||
"Log output format: 'text' (human-readable, default) or "
|
||||
"'json' (structured JSON lines for SIEM / log aggregation)."
|
||||
),
|
||||
)
|
||||
|
||||
# Optional syslog forwarding for application logs (not just audit events).
|
||||
# When enabled, a Python SysLogHandler is added to the root logger so that
|
||||
# every log message is also sent to the configured syslog receiver.
|
||||
log_syslog_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Forward application logs to a syslog receiver in addition to stdout.",
|
||||
)
|
||||
log_syslog_host: str = Field(
|
||||
default="localhost",
|
||||
description="Hostname or IP of the syslog receiver for application logs.",
|
||||
)
|
||||
log_syslog_port: int = Field(
|
||||
default=514,
|
||||
description="Port of the syslog receiver for application logs.",
|
||||
)
|
||||
log_syslog_protocol: str = Field(
|
||||
default="udp",
|
||||
description="Protocol for syslog transport: 'udp' or 'tcp'.",
|
||||
)
|
||||
|
||||
# Making Dropbox optional
|
||||
dropbox_enabled: bool = Field(
|
||||
default=True,
|
||||
@@ -121,11 +166,50 @@ class Settings(BaseSettings):
|
||||
google_docai_location: str = "us" # Processor location, e.g. "us" or "eu"
|
||||
external_hostname: str = "localhost" # Default to localhost
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Document Translation Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default target language for automatic document translation (ISO 639-1 code).
|
||||
# After OCR / metadata extraction, if the detected document language differs
|
||||
# from this value the system translates the extracted text into this language
|
||||
# and stores it alongside the original. Other language translations are
|
||||
# generated on the fly via the AI provider and are NOT persisted.
|
||||
# Per-user overrides are stored in UserProfile.default_document_language.
|
||||
default_document_language: str = Field(
|
||||
default="en",
|
||||
description=(
|
||||
"ISO 639-1 language code for the default translation target "
|
||||
"(e.g. 'en', 'de', 'fr'). Documents whose detected language "
|
||||
"differs are automatically translated into this language after "
|
||||
"processing. Default: 'en' (English)."
|
||||
),
|
||||
)
|
||||
|
||||
# Authentication settings
|
||||
auth_enabled: bool = True # Default to enabled
|
||||
admin_username: Optional[str] = None
|
||||
admin_password: Optional[str] = None
|
||||
session_secret: Optional[str] = None
|
||||
session_lifetime_days: int = Field(
|
||||
default=30,
|
||||
description=(
|
||||
"Session lifetime in days. Common values: 30, 60, 90. "
|
||||
"Determines how long a user stays logged in before being required to re-authenticate. "
|
||||
"Applies to both browser sessions and the session cookie max_age."
|
||||
),
|
||||
)
|
||||
session_lifetime_custom_days: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Override session_lifetime_days with a custom value. "
|
||||
"When set, this takes precedence over session_lifetime_days. "
|
||||
"Useful for admin-configured non-standard durations."
|
||||
),
|
||||
)
|
||||
qr_login_challenge_ttl_seconds: int = Field(
|
||||
default=120,
|
||||
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
|
||||
)
|
||||
admin_group_name: str = "admin"
|
||||
|
||||
# Multi-user settings
|
||||
@@ -525,6 +609,15 @@ class Settings(BaseSettings):
|
||||
onedrive_refresh_token: Optional[str] = None # Required for personal accounts
|
||||
onedrive_folder_path: Optional[str] = None
|
||||
|
||||
# SharePoint settings
|
||||
sharepoint_client_id: Optional[str] = None
|
||||
sharepoint_client_secret: Optional[str] = None
|
||||
sharepoint_tenant_id: Optional[str] = "common"
|
||||
sharepoint_refresh_token: Optional[str] = None
|
||||
sharepoint_site_url: Optional[str] = None # e.g. https://tenant.sharepoint.com/sites/sitename
|
||||
sharepoint_document_library: Optional[str] = "Documents" # Document library name
|
||||
sharepoint_folder_path: Optional[str] = None # Subfolder inside the library
|
||||
|
||||
# AWS S3 settings
|
||||
s3_enabled: bool = Field(
|
||||
default=True,
|
||||
@@ -575,6 +668,25 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
|
||||
# System reset / factory reset settings
|
||||
factory_reset_on_startup: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When enabled, DocuElevate wipes all user data (database rows and "
|
||||
"work-files on disk) on every startup so the instance always comes "
|
||||
"up in a clean, fresh state. Useful for demo or testing environments. "
|
||||
"Default: False."
|
||||
),
|
||||
)
|
||||
enable_factory_reset: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Show the 'System Reset' page in the admin UI. When enabled, "
|
||||
"administrators can trigger a full data wipe or a wipe-and-reimport "
|
||||
"directly from the web interface. Default: False."
|
||||
),
|
||||
)
|
||||
|
||||
# PDF/A archival conversion settings
|
||||
enable_pdfa_conversion: bool = Field(
|
||||
default=False,
|
||||
@@ -1003,6 +1115,45 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
|
||||
# Database Connection Pool Configuration
|
||||
# Controls SQLAlchemy QueuePool behaviour for PostgreSQL/MySQL.
|
||||
# SQLite uses NullPool and ignores these settings.
|
||||
db_pool_size: int = Field(
|
||||
default=5,
|
||||
description="Number of persistent connections kept in the pool. Ignored for SQLite.",
|
||||
)
|
||||
db_max_overflow: int = Field(
|
||||
default=10,
|
||||
description=("Maximum number of connections that can be opened beyond db_pool_size. Ignored for SQLite."),
|
||||
)
|
||||
db_pool_timeout: int = Field(
|
||||
default=30,
|
||||
description="Seconds to wait for a connection from the pool before raising an error. Ignored for SQLite.",
|
||||
)
|
||||
db_pool_recycle: int = Field(
|
||||
default=1800,
|
||||
description=(
|
||||
"Seconds after which a connection is recycled to prevent stale connections. "
|
||||
"Ignored for SQLite. Default: 1800 (30 minutes)."
|
||||
),
|
||||
)
|
||||
|
||||
# Per-user upload rate limiting (health-aware limiter)
|
||||
# Controls how many uploads a single user may submit within a sliding window.
|
||||
upload_rate_limit_per_user: int = Field(
|
||||
default=20,
|
||||
description=(
|
||||
"Maximum number of uploads allowed per user within the upload_rate_limit_window. "
|
||||
"The limiter may dynamically reduce this value when Redis queue depth or CPU load is high."
|
||||
),
|
||||
)
|
||||
upload_rate_limit_window: int = Field(
|
||||
default=60,
|
||||
description=(
|
||||
"Sliding window in seconds over which upload_rate_limit_per_user is enforced. Default: 60 seconds."
|
||||
),
|
||||
)
|
||||
|
||||
# Rate Limiting Configuration (see SECURITY_AUDIT.md and docs/API.md)
|
||||
# Protects against DoS attacks and API abuse
|
||||
rate_limiting_enabled: bool = Field(
|
||||
|
||||
+16
-1
@@ -10,6 +10,7 @@ from typing import Any
|
||||
from sqlalchemy import create_engine, exc
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
||||
from sqlalchemy.pool import NullPool, QueuePool
|
||||
|
||||
from app.config import settings
|
||||
|
||||
@@ -19,7 +20,20 @@ Base = declarative_base()
|
||||
|
||||
# Parse the DATABASE_URL
|
||||
DB_URL = settings.database_url
|
||||
engine = create_engine(DB_URL, connect_args={"check_same_thread": False})
|
||||
_db_url = make_url(DB_URL)
|
||||
if _db_url.get_backend_name() == "sqlite":
|
||||
# SQLite does not benefit from connection pooling; NullPool avoids contention.
|
||||
engine = create_engine(DB_URL, connect_args={"check_same_thread": False}, poolclass=NullPool)
|
||||
else:
|
||||
# PostgreSQL / MySQL / other: use a configurable QueuePool.
|
||||
engine = create_engine(
|
||||
DB_URL,
|
||||
poolclass=QueuePool,
|
||||
pool_size=settings.db_pool_size,
|
||||
max_overflow=settings.db_max_overflow,
|
||||
pool_timeout=settings.db_pool_timeout,
|
||||
pool_recycle=settings.db_pool_recycle,
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
@@ -271,6 +285,7 @@ def _ensure_indexes(engine: Any, inspector: Any) -> None:
|
||||
if table not in columns_by_table:
|
||||
columns_by_table[table] = {col["name"] for col in inspector.get_columns(table)}
|
||||
if column in columns_by_table[table]:
|
||||
# SECURITY: Quoted identifiers to prevent SQL injection during index creation
|
||||
quoted_idx = preparer.quote(idx_name)
|
||||
quoted_table = preparer.quote(table)
|
||||
quoted_col = preparer.quote(column)
|
||||
|
||||
+129
-1
@@ -1,8 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
import json as _json_mod
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime as _dt
|
||||
from datetime import timezone as _tz
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -36,6 +39,114 @@ from app.views import router as frontend_router
|
||||
# Explicitly include the files router
|
||||
from app.views.files import router as files_router
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configure Python root logging level early so that *all* loggers (including
|
||||
# those already created via ``logging.getLogger(__name__)`` in other modules)
|
||||
# respect the configured level.
|
||||
#
|
||||
# Standard behaviour (matches Django, Flask, 12-factor conventions):
|
||||
# • ``LOG_LEVEL`` env var takes precedence when explicitly set.
|
||||
# • When ``DEBUG=True`` and ``LOG_LEVEL`` is **not** set, the effective
|
||||
# level is automatically lowered to ``DEBUG``.
|
||||
# • Default (neither flag set): ``INFO``.
|
||||
#
|
||||
# ``LOG_FORMAT=json`` enables structured JSON lines on stdout, suitable for
|
||||
# Promtail, Fluentd, Filebeat, Datadog, Splunk UF, or any log collector.
|
||||
#
|
||||
# ``LOG_SYSLOG_ENABLED=true`` adds a Python SysLogHandler so that every log
|
||||
# message is also forwarded to the configured syslog receiver — useful for
|
||||
# traditional (non-container) deployments and centralised SIEM ingestion.
|
||||
#
|
||||
# Noisy third-party loggers (httpx, httpcore, authlib, etc.) are pinned to
|
||||
# WARNING when the app-level is DEBUG to keep output useful.
|
||||
# ---------------------------------------------------------------------------
|
||||
_explicit_log_level = os.environ.get("LOG_LEVEL")
|
||||
if settings.debug and _explicit_log_level is None:
|
||||
_effective_level = "DEBUG"
|
||||
else:
|
||||
_effective_level = settings.log_level.upper()
|
||||
|
||||
_effective_level_int = getattr(logging, _effective_level, logging.INFO)
|
||||
|
||||
|
||||
class _JsonFormatter(logging.Formatter):
|
||||
"""Emit one JSON object per log line for machine consumption.
|
||||
|
||||
Fields emitted: ``timestamp``, ``level``, ``logger``, ``message``,
|
||||
``module``, ``funcName``, ``lineno``, and — when present — ``exc_info``.
|
||||
Compatible with Grafana Loki, Splunk, ELK, Datadog, and most SIEM tools.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry: dict = {
|
||||
"timestamp": _dt.fromtimestamp(record.created, tz=_tz.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"module": record.module,
|
||||
"funcName": record.funcName,
|
||||
"lineno": record.lineno,
|
||||
}
|
||||
if record.exc_info and record.exc_info[1] is not None:
|
||||
log_entry["exc_info"] = self.formatException(record.exc_info)
|
||||
return _json_mod.dumps(log_entry, default=str)
|
||||
|
||||
|
||||
# Choose formatter based on LOG_FORMAT setting
|
||||
if settings.log_format.lower() == "json":
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_JsonFormatter())
|
||||
logging.root.handlers = [_handler]
|
||||
logging.root.setLevel(_effective_level_int)
|
||||
else:
|
||||
logging.basicConfig(
|
||||
level=_effective_level_int,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
force=True,
|
||||
)
|
||||
|
||||
# Optional: forward application logs to a syslog receiver
|
||||
if settings.log_syslog_enabled:
|
||||
import logging.handlers as _lh
|
||||
import socket as _socket
|
||||
|
||||
_proto = settings.log_syslog_protocol.lower()
|
||||
_socktype = _socket.SOCK_STREAM if _proto == "tcp" else _socket.SOCK_DGRAM
|
||||
_syslog_handler = _lh.SysLogHandler(
|
||||
address=(settings.log_syslog_host, settings.log_syslog_port),
|
||||
socktype=_socktype,
|
||||
)
|
||||
_syslog_handler.setLevel(_effective_level_int)
|
||||
# Use the same formatter as stdout (text or JSON)
|
||||
if settings.log_format.lower() == "json":
|
||||
_syslog_handler.setFormatter(_JsonFormatter())
|
||||
else:
|
||||
_syslog_handler.setFormatter(logging.Formatter("%(name)s - %(levelname)s - %(message)s"))
|
||||
logging.root.addHandler(_syslog_handler)
|
||||
|
||||
# Keep noisy third-party loggers quiet at DEBUG level
|
||||
if _effective_level_int <= logging.DEBUG:
|
||||
for _noisy in (
|
||||
"httpx",
|
||||
"httpcore",
|
||||
"authlib",
|
||||
"urllib3",
|
||||
"hpack",
|
||||
"multipart",
|
||||
"watchfiles",
|
||||
):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
|
||||
_startup_logger = logging.getLogger(__name__)
|
||||
_startup_logger.info(
|
||||
"Root logging level set to %s (debug=%s, format=%s, syslog=%s)",
|
||||
_effective_level,
|
||||
settings.debug,
|
||||
settings.log_format,
|
||||
settings.log_syslog_enabled,
|
||||
)
|
||||
|
||||
# Load configuration from .env for the session key
|
||||
config = Config(".env")
|
||||
# Use settings.session_secret which has proper validation
|
||||
@@ -59,6 +170,12 @@ async def lifespan(app: FastAPI):
|
||||
# Startup: Initialize database
|
||||
init_db() # Create tables if they don't exist
|
||||
|
||||
# Factory reset on startup — wipe all user data before anything else
|
||||
if settings.factory_reset_on_startup:
|
||||
from app.utils.system_reset import perform_startup_reset
|
||||
|
||||
perform_startup_reset()
|
||||
|
||||
# Load settings from database after DB initialization
|
||||
from app.database import SessionLocal
|
||||
from app.utils.config_loader import load_settings_from_db
|
||||
@@ -207,8 +324,19 @@ app.add_middleware(CSRFMiddleware, config=settings)
|
||||
# See SECURITY_AUDIT.md – Infrastructure Security section
|
||||
app.add_middleware(AuditLogMiddleware, config=settings)
|
||||
|
||||
|
||||
# 3) Session Middleware (for request.session to work)
|
||||
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
|
||||
def _get_session_max_age() -> int:
|
||||
"""Compute session max-age at startup time."""
|
||||
try:
|
||||
from app.utils.session_manager import get_session_max_age_seconds
|
||||
|
||||
return get_session_max_age_seconds()
|
||||
except Exception:
|
||||
return 30 * 86400 # 30 days default fallback
|
||||
|
||||
|
||||
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, max_age=_get_session_max_age())
|
||||
|
||||
# 3a) CORS Middleware - handles cross-origin requests and preflight (OPTIONS) responses.
|
||||
# Disabled by default: set CORS_ENABLED=True only when NOT using a reverse proxy
|
||||
|
||||
@@ -20,6 +20,9 @@ How it works:
|
||||
|
||||
Exempt paths (CSRF is not checked even for state-changing methods):
|
||||
- ``/oauth-callback`` – OAuth 2.0 callback; protected by the ``state`` parameter.
|
||||
- ``/api/qr-auth/claim`` – Called by the unauthenticated mobile app; the
|
||||
cryptographically-random, single-use challenge token provides equivalent
|
||||
protection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -39,6 +42,10 @@ CSRF_PROTECTED_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
|
||||
# their own replay-protection mechanism).
|
||||
CSRF_EXEMPT_PATHS = {
|
||||
"/oauth-callback",
|
||||
# The mobile app calls this endpoint without a browser session/CSRF token.
|
||||
# The cryptographically-random, single-use challenge token already provides
|
||||
# equivalent protection against cross-site request forgery.
|
||||
"/api/qr-auth/claim",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+101
@@ -84,6 +84,19 @@ class FileRecord(Base):
|
||||
# Processing pipeline assigned to this file (NULL = use system default)
|
||||
pipeline_id = Column(Integer, ForeignKey(_PIPELINES_ID_FK), nullable=True, index=True)
|
||||
|
||||
# Detected document language (ISO 639-1 code, e.g. "de", "en", "fr")
|
||||
# Extracted from AI metadata during processing; cached here for fast access.
|
||||
detected_language = Column(String(10), nullable=True)
|
||||
|
||||
# Default-language translation of the extracted text.
|
||||
# Stored when the detected language differs from the user's/system default
|
||||
# document language. Only the original text and this translation are persisted;
|
||||
# other languages are translated on the fly via the AI provider.
|
||||
default_language_text = Column(Text, nullable=True)
|
||||
|
||||
# ISO 639-1 code of the default-language translation stored above (e.g. "en").
|
||||
default_language_code = Column(String(10), nullable=True)
|
||||
|
||||
# Timestamp when we inserted this record
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
@@ -282,6 +295,12 @@ class UserProfile(Base):
|
||||
# NULL means "auto-detect from browser Accept-Language header"
|
||||
preferred_language = Column(String(10), nullable=True)
|
||||
|
||||
# Default document language for translated versions (ISO 639-1 code).
|
||||
# When a document's detected language differs from this value, the system
|
||||
# automatically generates and stores a translation into this language.
|
||||
# NULL means "use the global DEFAULT_DOCUMENT_LANGUAGE setting".
|
||||
default_document_language = Column(String(10), nullable=True)
|
||||
|
||||
# UI colour scheme preference: "light" | "dark" | "system" (NULL = "system")
|
||||
preferred_theme = Column(String(10), nullable=True)
|
||||
|
||||
@@ -584,6 +603,7 @@ class IntegrationType:
|
||||
EMAIL = "EMAIL"
|
||||
PAPERLESS = "PAPERLESS"
|
||||
RCLONE = "RCLONE"
|
||||
SHAREPOINT = "SHAREPOINT"
|
||||
ICLOUD = "ICLOUD"
|
||||
|
||||
ALL = {
|
||||
@@ -601,6 +621,7 @@ class IntegrationType:
|
||||
EMAIL,
|
||||
PAPERLESS,
|
||||
RCLONE,
|
||||
SHAREPOINT,
|
||||
ICLOUD,
|
||||
}
|
||||
|
||||
@@ -950,6 +971,86 @@ class MobileDevice(Base):
|
||||
__table_args__ = (UniqueConstraint("owner_id", "push_token", name="uq_mobile_device_owner_token"),)
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
"""Server-side session tracking for invalidation and device management.
|
||||
|
||||
Each row represents an active browser or app session. The ``session_token``
|
||||
is stored in the user's cookie and validated on every authenticated request.
|
||||
Revoking a row (``is_revoked=True``) immediately terminates that session
|
||||
on the next request.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Cryptographically random token stored in the session cookie.
|
||||
session_token = Column(String(128), unique=True, nullable=False, index=True)
|
||||
|
||||
# Stable owner identifier — matches FileRecord.owner_id.
|
||||
user_id = Column(String, nullable=False, index=True)
|
||||
|
||||
# Client metadata for display in the session management UI.
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(String(512), nullable=True)
|
||||
device_info = Column(String(255), nullable=True)
|
||||
|
||||
is_revoked = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
last_active_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class QRLoginChallenge(Base):
|
||||
"""Time-limited QR code login challenge for mobile app authentication.
|
||||
|
||||
A logged-in web user generates a challenge that produces a QR code. The
|
||||
mobile app scans the QR code and calls the claim endpoint with the
|
||||
``challenge_token``. The server verifies the challenge is still valid,
|
||||
unclaimed, and unexpired, then issues an API token for the mobile app.
|
||||
|
||||
Security properties:
|
||||
* Time-bound (default 2 minutes).
|
||||
* Single-use (``is_claimed`` prevents replay).
|
||||
* Cryptographically random 64-byte token.
|
||||
* Bound to the creating user — only that user's mobile device receives a
|
||||
token.
|
||||
"""
|
||||
|
||||
__tablename__ = "qr_login_challenges"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Cryptographically random token encoded in the QR code.
|
||||
challenge_token = Column(String(128), unique=True, nullable=False, index=True)
|
||||
|
||||
# The user who created this challenge (from the web session).
|
||||
user_id = Column(String, nullable=False, index=True)
|
||||
|
||||
# Whether the challenge has been successfully claimed by a mobile app.
|
||||
is_claimed = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Whether the challenge has been explicitly cancelled or expired.
|
||||
is_cancelled = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# IP address of the web client that created the challenge.
|
||||
created_by_ip = Column(String(45), nullable=True)
|
||||
|
||||
# IP address of the mobile client that claimed the challenge.
|
||||
claimed_by_ip = Column(String(45), nullable=True)
|
||||
|
||||
# Device name provided by the mobile app when claiming.
|
||||
device_name = Column(String(255), nullable=True)
|
||||
|
||||
# The API token ID that was issued to the mobile app (for audit trail).
|
||||
issued_token_id = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
claimed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ComplianceTemplate(Base):
|
||||
"""Pre-built compliance configuration templates (GDPR, HIPAA, SOC2).
|
||||
|
||||
|
||||
@@ -216,6 +216,30 @@ def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, met
|
||||
except Exception as search_exc:
|
||||
logger.warning(f"[{task_id}] Meilisearch indexing failed (non-fatal): {search_exc}")
|
||||
|
||||
# Cache the detected language on the FileRecord and trigger
|
||||
# default-language translation when the document is in a
|
||||
# different language.
|
||||
detected_lang = metadata.get("language") if metadata else None
|
||||
if detected_lang and extracted_text:
|
||||
try:
|
||||
file_record.detected_language = detected_lang
|
||||
db.commit()
|
||||
|
||||
from app.tasks.translate_to_default_language import translate_to_default_language
|
||||
|
||||
translate_to_default_language.delay(
|
||||
file_id,
|
||||
extracted_text,
|
||||
detected_lang,
|
||||
owner_id=file_record.owner_id,
|
||||
)
|
||||
logger.info(
|
||||
f"[{task_id}] Queued default-language translation for file {file_id} "
|
||||
f"(detected: {detected_lang})"
|
||||
)
|
||||
except Exception as trans_exc:
|
||||
logger.warning(f"[{task_id}] Could not queue translation task (non-fatal): {trans_exc}")
|
||||
|
||||
# Persist the metadata into a JSON file with the same base name.
|
||||
# Include file path references for traceability
|
||||
logger.info(f"[{task_id}] Persisting metadata to JSON")
|
||||
|
||||
@@ -21,8 +21,9 @@ from app.tasks.send_to_all import (
|
||||
# Import database and logging utils from main
|
||||
from app.utils import log_task_progress
|
||||
|
||||
# Import notification utility
|
||||
# Import notification utilities
|
||||
from app.utils.notification import notify_file_processed
|
||||
from app.utils.user_notification import notify_user_document_processed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -139,4 +140,15 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
|
||||
except Exception as e:
|
||||
logger.warning(f"[WARNING] Failed to send file processed notification: {e}")
|
||||
|
||||
# 6. Send per-user notification
|
||||
if owner_id:
|
||||
try:
|
||||
notify_user_document_processed(
|
||||
owner_id=owner_id,
|
||||
filename=os.path.basename(processed_file),
|
||||
file_id=file_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WARNING] Failed to send per-user processed notification: {e}")
|
||||
|
||||
return {"status": "Completed", "file": processed_file}
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.utils.allowed_types import (
|
||||
DEFAULT_CATEGORIES,
|
||||
get_allowed_types_for_categories,
|
||||
)
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
# Database session for per-user IMAP accounts (imported lazily to avoid circular imports)
|
||||
_db_session_factory = None
|
||||
@@ -405,6 +406,11 @@ def pull_inbox(
|
||||
)
|
||||
processed_emails = load_processed_emails()
|
||||
|
||||
# Security: Prevent SSRF by blocking connections to internal IPs
|
||||
if is_private_ip(host):
|
||||
logger.warning("SSRF blocked: Attempt to pull mailbox from private IP %s", host)
|
||||
return
|
||||
|
||||
try:
|
||||
mail = imaplib.IMAP4_SSL(host, port) if use_ssl else imaplib.IMAP4(host, port)
|
||||
mail.login(username, password)
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.tasks.upload_to_onedrive import upload_to_onedrive
|
||||
from app.tasks.upload_to_paperless import upload_to_paperless
|
||||
from app.tasks.upload_to_s3 import upload_to_s3
|
||||
from app.tasks.upload_to_sftp import upload_to_sftp
|
||||
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
|
||||
from app.tasks.upload_to_webdav import upload_to_webdav
|
||||
from app.utils.config_validator import get_provider_status
|
||||
from app.utils.logging import log_task_progress
|
||||
@@ -121,6 +122,18 @@ def _should_upload_to_icloud():
|
||||
return bool(getattr(settings, "icloud_enabled", True) and settings.icloud_username and settings.icloud_password)
|
||||
|
||||
|
||||
def _should_upload_to_sharepoint():
|
||||
return bool(
|
||||
settings.sharepoint_client_id
|
||||
and settings.sharepoint_client_secret
|
||||
and settings.sharepoint_site_url
|
||||
and (
|
||||
settings.sharepoint_refresh_token
|
||||
or (settings.sharepoint_tenant_id and settings.sharepoint_tenant_id != "common")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_configured_services_from_validator():
|
||||
"""
|
||||
Use the config validator to determine which services are configured and enabled.
|
||||
@@ -140,6 +153,7 @@ def get_configured_services_from_validator():
|
||||
"Email": "email",
|
||||
"OneDrive": "onedrive",
|
||||
"S3 Storage": "s3",
|
||||
"SharePoint": "sharepoint",
|
||||
"iCloud Drive": "icloud",
|
||||
}
|
||||
|
||||
@@ -250,6 +264,11 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
|
||||
"should_upload": _should_upload_to_s3,
|
||||
"upload_func": upload_to_s3,
|
||||
},
|
||||
{
|
||||
"name": "sharepoint",
|
||||
"should_upload": _should_upload_to_sharepoint,
|
||||
"upload_func": upload_to_sharepoint,
|
||||
},
|
||||
{
|
||||
"name": "icloud",
|
||||
"should_upload": _should_upload_to_icloud,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Celery task to translate extracted document text into the default target language.
|
||||
|
||||
This task is triggered after metadata extraction when the detected document
|
||||
language differs from the user's (or system) default document language. The
|
||||
translated text is persisted in ``FileRecord.default_language_text`` so that
|
||||
users can always read a reference copy in their preferred language.
|
||||
|
||||
Other ad-hoc translations are generated on the fly via the ``/api/files/{id}/translate``
|
||||
endpoint and are NOT persisted.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app.celery_app import celery
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models import FileRecord, UserProfile
|
||||
from app.tasks.retry_config import BaseTaskWithRetry
|
||||
from app.utils import log_task_progress
|
||||
from app.utils.ai_provider import get_ai_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_default_language(owner_id: str | None) -> str:
|
||||
"""Return the default document language for the given owner.
|
||||
|
||||
Resolution order:
|
||||
1. ``UserProfile.default_document_language`` (per-user override)
|
||||
2. ``settings.default_document_language`` (global setting)
|
||||
"""
|
||||
if owner_id:
|
||||
with SessionLocal() as db:
|
||||
profile = db.query(UserProfile).filter_by(user_id=owner_id).first()
|
||||
if profile and profile.default_document_language:
|
||||
return profile.default_document_language
|
||||
return settings.default_document_language
|
||||
|
||||
|
||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||
def translate_to_default_language(
|
||||
self,
|
||||
file_id: int,
|
||||
extracted_text: str,
|
||||
detected_language: str,
|
||||
owner_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Translate *extracted_text* into the default document language and persist the result.
|
||||
|
||||
Args:
|
||||
file_id: Primary key of the :class:`FileRecord`.
|
||||
extracted_text: The OCR / refined text in the document's original language.
|
||||
detected_language: ISO 639-1 code of the document's detected language.
|
||||
owner_id: Owner identifier used to resolve per-user language preference.
|
||||
|
||||
Returns:
|
||||
A dict with ``status``, ``target_language``, and the translated text length.
|
||||
"""
|
||||
task_id = self.request.id
|
||||
target_language = _resolve_default_language(owner_id)
|
||||
|
||||
# Nothing to do when the document is already in the target language.
|
||||
if detected_language == target_language:
|
||||
logger.info(
|
||||
f"[{task_id}] Document {file_id} already in target language '{target_language}', skipping translation"
|
||||
)
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"translate_to_default_language",
|
||||
"skipped",
|
||||
f"Document already in {target_language}",
|
||||
file_id=file_id,
|
||||
)
|
||||
return {"status": "skipped", "reason": "already_in_target_language"}
|
||||
|
||||
logger.info(f"[{task_id}] Translating document {file_id} from '{detected_language}' to '{target_language}'")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"translate_to_default_language",
|
||||
"in_progress",
|
||||
f"Translating from {detected_language} to {target_language}",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
try:
|
||||
provider = get_ai_provider()
|
||||
model = settings.ai_model or settings.openai_model
|
||||
translated_text = provider.chat_completion(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
f"You are a professional translator. Translate the following text "
|
||||
f"from {detected_language} to {target_language}. "
|
||||
f"Preserve the original formatting, paragraph structure, and meaning. "
|
||||
f"Do not add any commentary or explanation — output ONLY the translated text."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": extracted_text},
|
||||
],
|
||||
model=model,
|
||||
temperature=0.3,
|
||||
)
|
||||
|
||||
# Persist the translation.
|
||||
with SessionLocal() as db:
|
||||
record = db.query(FileRecord).filter_by(id=file_id).first()
|
||||
if record:
|
||||
record.default_language_text = translated_text
|
||||
record.default_language_code = target_language
|
||||
record.detected_language = detected_language
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"[{task_id}] Stored default-language translation ({len(translated_text)} chars) for file {file_id}"
|
||||
)
|
||||
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"translate_to_default_language",
|
||||
"success",
|
||||
f"Translated {len(extracted_text)} → {len(translated_text)} chars ({detected_language} → {target_language})",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"target_language": target_language,
|
||||
"translated_length": len(translated_text),
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(f"[{task_id}] Translation failed for file {file_id}: {exc}")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"translate_to_default_language",
|
||||
"failure",
|
||||
f"Exception: {exc}",
|
||||
file_id=file_id,
|
||||
)
|
||||
raise
|
||||
@@ -11,6 +11,7 @@ from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import pypdf
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from app.celery_app import celery
|
||||
@@ -23,6 +24,15 @@ logger = logging.getLogger(__name__)
|
||||
# Constants
|
||||
_LOGO_FILENAME = "logo.png"
|
||||
|
||||
# Mapping from PDF metadata keys (with leading slash stripped) to application-specific names.
|
||||
# This mirrors the inverse of the mapping used in app/tasks/embed_metadata_into_pdf.py.
|
||||
_PDF_METADATA_KEY_MAP = {
|
||||
"Title": "filename",
|
||||
"Author": "absender",
|
||||
"Subject": "document_type",
|
||||
"Keywords": "tags",
|
||||
}
|
||||
|
||||
|
||||
def get_email_template(template_name="default.html"):
|
||||
"""
|
||||
@@ -63,9 +73,12 @@ def extract_metadata_from_file(file_path):
|
||||
"""
|
||||
Try to extract metadata from a file using several methods:
|
||||
1. Check for a .json metadata file with the same name
|
||||
2. Extract metadata from PDF if it's embedded
|
||||
2. Extract embedded metadata from PDF using pypdf
|
||||
|
||||
Returns a dictionary of metadata or None if not found
|
||||
JSON metadata takes precedence; embedded PDF metadata fills in any missing
|
||||
fields using the application's standard key mapping (e.g., /Title → filename).
|
||||
|
||||
Returns a dictionary of metadata (may be empty if none found).
|
||||
"""
|
||||
metadata = {}
|
||||
|
||||
@@ -76,12 +89,28 @@ def extract_metadata_from_file(file_path):
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
logger.info(f"Loaded metadata from external JSON file: {metadata_path}")
|
||||
return metadata
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load metadata from JSON file: {str(e)}")
|
||||
|
||||
# TODO: For PDF files, try to extract embedded metadata using PyPDF2
|
||||
# This would require additional dependencies, so for now we'll just check for external JSON
|
||||
# Try to extract embedded metadata from PDF
|
||||
if file_path.lower().endswith(".pdf") and os.path.exists(file_path):
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
pdf_reader = pypdf.PdfReader(f)
|
||||
pdf_metadata = pdf_reader.metadata
|
||||
if pdf_metadata:
|
||||
for key, value in pdf_metadata.items():
|
||||
# Remove the leading slash from PDF metadata keys (e.g., '/Title' -> 'Title')
|
||||
clean_key = key[1:] if key.startswith("/") else key
|
||||
# Map to application-specific key names where possible
|
||||
mapped_key = _PDF_METADATA_KEY_MAP.get(clean_key, clean_key)
|
||||
# Only set if not already present (JSON metadata takes precedence)
|
||||
if mapped_key not in metadata:
|
||||
metadata[mapped_key] = str(value)
|
||||
|
||||
logger.info(f"Extracted embedded metadata from PDF: {file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract metadata from PDF {file_path}: {str(e)}")
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Upload documents to Microsoft SharePoint via the Microsoft Graph API.
|
||||
|
||||
This module authenticates using MSAL (same OAuth2 flow as OneDrive) and
|
||||
uploads files to a configurable SharePoint Online document library using
|
||||
the chunked upload session approach for reliability with large files.
|
||||
|
||||
Key differences from the OneDrive provider:
|
||||
- Uses ``/sites/{siteId}/drives/{driveId}`` instead of ``/me/drive``
|
||||
- Requires a SharePoint site URL to resolve the site and drive IDs
|
||||
- Targets a named document library (default: ``Documents``)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import msal
|
||||
import requests
|
||||
|
||||
from app.celery_app import celery
|
||||
from app.config import settings
|
||||
from app.tasks.retry_config import UploadTaskWithRetry
|
||||
from app.utils import log_task_progress
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_sharepoint_token() -> str:
|
||||
"""Acquire a Microsoft Graph API access token for SharePoint.
|
||||
|
||||
Uses MSAL ``ConfidentialClientApplication`` with the refresh-token flow
|
||||
(delegated permissions) or the client-credentials flow (application
|
||||
permissions) depending on configuration.
|
||||
|
||||
Returns:
|
||||
A valid access token string.
|
||||
|
||||
Raises:
|
||||
ValueError: When required settings are missing or token acquisition fails.
|
||||
"""
|
||||
if not settings.sharepoint_client_id or not settings.sharepoint_client_secret:
|
||||
raise ValueError("SharePoint client ID and client secret must be configured")
|
||||
|
||||
tenant = settings.sharepoint_tenant_id or "common"
|
||||
logger.info("Using SharePoint tenant: %s", tenant)
|
||||
|
||||
scopes = ["https://graph.microsoft.com/.default"]
|
||||
|
||||
if settings.sharepoint_refresh_token:
|
||||
app = msal.ConfidentialClientApplication(
|
||||
client_id=settings.sharepoint_client_id,
|
||||
client_credential=settings.sharepoint_client_secret,
|
||||
authority=f"https://login.microsoftonline.com/{tenant}",
|
||||
)
|
||||
|
||||
logger.info("Attempting to acquire SharePoint token using refresh token")
|
||||
token_response = app.acquire_token_by_refresh_token(
|
||||
refresh_token=settings.sharepoint_refresh_token, scopes=scopes
|
||||
)
|
||||
|
||||
if "access_token" not in token_response:
|
||||
error = token_response.get("error", "")
|
||||
error_desc = token_response.get("error_description", "Unknown error")
|
||||
logger.error("Failed to get SharePoint access token: %s - %s", error, error_desc)
|
||||
raise ValueError(f"Failed to get SharePoint access token: {error} - {error_desc}")
|
||||
|
||||
if "refresh_token" in token_response:
|
||||
settings.sharepoint_refresh_token = token_response["refresh_token"]
|
||||
logger.info("Updated SharePoint refresh token in memory")
|
||||
|
||||
return token_response["access_token"]
|
||||
|
||||
elif settings.sharepoint_tenant_id and settings.sharepoint_tenant_id != "common":
|
||||
authority = f"https://login.microsoftonline.com/{settings.sharepoint_tenant_id}"
|
||||
app = msal.ConfidentialClientApplication(
|
||||
client_id=settings.sharepoint_client_id,
|
||||
client_credential=settings.sharepoint_client_secret,
|
||||
authority=authority,
|
||||
)
|
||||
|
||||
token_response = app.acquire_token_for_client(scopes=scopes)
|
||||
|
||||
if "access_token" not in token_response:
|
||||
error = token_response.get("error", "")
|
||||
error_desc = token_response.get("error_description", "Unknown error")
|
||||
raise ValueError(f"Failed to get SharePoint access token: {error} - {error_desc}")
|
||||
|
||||
return token_response["access_token"]
|
||||
|
||||
else:
|
||||
raise ValueError("For SharePoint, either a refresh token or a non-'common' tenant ID is required")
|
||||
|
||||
|
||||
def resolve_sharepoint_drive(access_token: str, site_url: str, library_name: str) -> tuple[str, str]:
|
||||
"""Resolve the Graph API site ID and drive ID for a SharePoint site.
|
||||
|
||||
Args:
|
||||
access_token: Valid Microsoft Graph API token.
|
||||
site_url: Full SharePoint site URL, e.g.
|
||||
``https://tenant.sharepoint.com/sites/sitename``.
|
||||
library_name: Display name of the document library (e.g. ``Documents``).
|
||||
|
||||
Returns:
|
||||
A ``(site_id, drive_id)`` tuple.
|
||||
|
||||
Raises:
|
||||
ValueError: When the site URL cannot be parsed.
|
||||
RuntimeError: When the Graph API call fails.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(site_url)
|
||||
hostname = parsed.hostname
|
||||
site_path = parsed.path.rstrip("/")
|
||||
|
||||
if not hostname or not site_path:
|
||||
raise ValueError(
|
||||
f"Invalid SharePoint site URL '{site_url}'. Expected format: https://tenant.sharepoint.com/sites/sitename"
|
||||
)
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
# Resolve site ID
|
||||
site_api_url = f"https://graph.microsoft.com/v1.0/sites/{hostname}:{site_path}"
|
||||
logger.info("Resolving SharePoint site: %s", site_api_url)
|
||||
resp = requests.get(site_api_url, headers=headers, timeout=settings.http_request_timeout)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Failed to resolve SharePoint site: {resp.status_code} - {resp.text}")
|
||||
|
||||
site_id = resp.json()["id"]
|
||||
logger.info("Resolved SharePoint site ID: %s", site_id)
|
||||
|
||||
# Resolve drive ID from the document library name
|
||||
drives_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives"
|
||||
resp = requests.get(drives_url, headers=headers, timeout=settings.http_request_timeout)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Failed to list SharePoint drives: {resp.status_code} - {resp.text}")
|
||||
|
||||
drives = resp.json().get("value", [])
|
||||
drive_id = None
|
||||
for drive in drives:
|
||||
if drive.get("name", "").lower() == library_name.lower():
|
||||
drive_id = drive["id"]
|
||||
break
|
||||
|
||||
if not drive_id:
|
||||
available = [d.get("name") for d in drives]
|
||||
raise RuntimeError(f"Document library '{library_name}' not found on site. Available libraries: {available}")
|
||||
|
||||
logger.info("Resolved SharePoint drive ID: %s (library: %s)", drive_id, library_name)
|
||||
return site_id, drive_id
|
||||
|
||||
|
||||
def create_sharepoint_upload_session(
|
||||
filename: str, folder_path: str | None, drive_id: str, site_id: str, access_token: str
|
||||
) -> str:
|
||||
"""Create a resumable upload session on a SharePoint document library.
|
||||
|
||||
Args:
|
||||
filename: Name of the file to upload.
|
||||
folder_path: Optional subfolder path inside the library.
|
||||
drive_id: Graph API drive ID of the document library.
|
||||
site_id: Graph API site ID.
|
||||
access_token: Valid access token.
|
||||
|
||||
Returns:
|
||||
The upload session URL for chunked PUT requests.
|
||||
|
||||
Raises:
|
||||
RuntimeError: When session creation fails.
|
||||
"""
|
||||
base_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}"
|
||||
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip("/")
|
||||
path_components = folder_path.split("/")
|
||||
encoded_path = "/".join(urllib.parse.quote(component) for component in path_components)
|
||||
encoded_filename = urllib.parse.quote(filename)
|
||||
item_path = f"/root:/{encoded_path}/{encoded_filename}:/createUploadSession"
|
||||
else:
|
||||
encoded_filename = urllib.parse.quote(filename)
|
||||
item_path = f"/root:/{encoded_filename}:/createUploadSession"
|
||||
|
||||
url = f"{base_url}{item_path}"
|
||||
request_body = {"item": {"@microsoft.graph.conflictBehavior": "replace"}}
|
||||
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
|
||||
|
||||
logger.info("Creating SharePoint upload session for %s at path %s", filename, folder_path)
|
||||
response = requests.post(url, headers=headers, json=request_body, timeout=settings.http_request_timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
upload_url = response.json().get("uploadUrl")
|
||||
logger.info("SharePoint upload session created for %s", filename)
|
||||
return upload_url
|
||||
else:
|
||||
raise RuntimeError(f"Failed to create SharePoint upload session: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def upload_large_file_sharepoint(file_path: str, upload_url: str) -> dict:
|
||||
"""Upload a file to SharePoint using a chunked upload session.
|
||||
|
||||
Args:
|
||||
file_path: Local path to the file.
|
||||
upload_url: The upload session URL from ``create_sharepoint_upload_session``.
|
||||
|
||||
Returns:
|
||||
The Graph API response dict containing file metadata.
|
||||
|
||||
Raises:
|
||||
RuntimeError: When a chunk upload fails after retries.
|
||||
"""
|
||||
file_size = os.path.getsize(file_path)
|
||||
chunk_size = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
response = None
|
||||
with open(file_path, "rb") as f:
|
||||
chunk_number = 0
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
chunk_start = chunk_number * chunk_size
|
||||
chunk_end = chunk_start + len(chunk) - 1
|
||||
content_range = f"bytes {chunk_start}-{chunk_end}/{file_size}"
|
||||
|
||||
headers = {"Content-Length": str(len(chunk)), "Content-Range": content_range}
|
||||
|
||||
max_retries = 3
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = requests.put(
|
||||
upload_url, headers=headers, data=chunk, timeout=settings.http_request_timeout
|
||||
)
|
||||
if response.status_code in (201, 202):
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
"SharePoint chunk upload failed (attempt %d): %d", attempt + 1, response.status_code
|
||||
)
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(retry_delay * (attempt + 1))
|
||||
except Exception as e:
|
||||
logger.warning("SharePoint chunk upload error (attempt %d): %s", attempt + 1, str(e))
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(retry_delay * (attempt + 1))
|
||||
|
||||
if response is None or response.status_code not in (201, 202):
|
||||
status = response.status_code if response else "no response"
|
||||
text = response.text if response else ""
|
||||
raise RuntimeError(f"Failed to upload chunk after {max_retries} attempts: {status} - {text}")
|
||||
|
||||
chunk_number += 1
|
||||
|
||||
return response.json() if response else {}
|
||||
|
||||
|
||||
@celery.task(base=UploadTaskWithRetry, bind=True)
|
||||
def upload_to_sharepoint(self, file_path: str, file_id: int = None, folder_override: str = None):
|
||||
"""Upload a file to SharePoint Online.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to upload.
|
||||
file_id: Optional file ID to associate with logs.
|
||||
folder_override: Optional folder path override.
|
||||
|
||||
Returns:
|
||||
A dict with upload status and file details.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: When the file does not exist.
|
||||
ValueError: When SharePoint is not configured.
|
||||
RuntimeError: When the upload fails.
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info("[%s] Starting SharePoint upload: %s", task_id, file_path)
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"upload_to_sharepoint",
|
||||
"in_progress",
|
||||
f"Uploading to SharePoint: {os.path.basename(file_path)}",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
error_msg = f"File not found: {file_path}"
|
||||
logger.error("[%s] %s", task_id, error_msg)
|
||||
log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id)
|
||||
raise FileNotFoundError(error_msg)
|
||||
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
if not settings.sharepoint_client_id:
|
||||
error_msg = "SharePoint client ID is not configured"
|
||||
logger.error("[%s] %s", task_id, error_msg)
|
||||
log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
if not settings.sharepoint_site_url:
|
||||
error_msg = "SharePoint site URL is not configured"
|
||||
logger.error("[%s] %s", task_id, error_msg)
|
||||
log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
try:
|
||||
access_token = get_sharepoint_token()
|
||||
|
||||
library_name = settings.sharepoint_document_library or "Documents"
|
||||
site_id, drive_id = resolve_sharepoint_drive(access_token, settings.sharepoint_site_url, library_name)
|
||||
|
||||
folder_path = folder_override if folder_override is not None else settings.sharepoint_folder_path
|
||||
|
||||
upload_url = create_sharepoint_upload_session(filename, folder_path, drive_id, site_id, access_token)
|
||||
result = upload_large_file_sharepoint(file_path, upload_url)
|
||||
|
||||
web_url = result.get("webUrl", "Not available")
|
||||
logger.info("[%s] Successfully uploaded %s to SharePoint", task_id, filename)
|
||||
logger.info("[%s] File accessible at: %s", task_id, web_url)
|
||||
log_task_progress(
|
||||
task_id, "upload_to_sharepoint", "success", f"Uploaded to SharePoint: {filename}", file_id=file_id
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "Completed",
|
||||
"file_path": file_path,
|
||||
"sharepoint_path": f"{folder_path or ''}/{filename}",
|
||||
"web_url": web_url,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to upload {filename} to SharePoint: {str(e)}"
|
||||
logger.error("[%s] %s", task_id, error_msg)
|
||||
log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id)
|
||||
raise RuntimeError(error_msg) from e
|
||||
@@ -571,6 +571,113 @@ def _upload_rclone(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], t
|
||||
return {"status": "Completed", "rclone_dest": dest}
|
||||
|
||||
|
||||
def _upload_sharepoint(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||||
"""Upload *file_path* to SharePoint using per-user MSAL credentials."""
|
||||
import urllib.parse
|
||||
|
||||
import msal
|
||||
import requests as _requests
|
||||
|
||||
client_id = creds.get("client_id") or ""
|
||||
client_secret = creds.get("client_secret") or ""
|
||||
refresh_token = creds.get("refresh_token") or ""
|
||||
tenant = cfg.get("tenant_id") or "common"
|
||||
site_url = cfg.get("site_url") or ""
|
||||
library_name = cfg.get("document_library") or "Documents"
|
||||
folder_path = cfg.get("folder_path") or ""
|
||||
|
||||
if not (client_id and client_secret):
|
||||
raise ValueError("SharePoint integration is missing client_id or client_secret in credentials")
|
||||
if not site_url:
|
||||
raise ValueError("SharePoint integration is missing site_url in config")
|
||||
|
||||
scopes = ["https://graph.microsoft.com/.default"]
|
||||
msal_app = msal.ConfidentialClientApplication(
|
||||
client_id=client_id,
|
||||
client_credential=client_secret,
|
||||
authority=f"https://login.microsoftonline.com/{tenant}",
|
||||
)
|
||||
|
||||
if refresh_token:
|
||||
token_resp = msal_app.acquire_token_by_refresh_token(refresh_token=refresh_token, scopes=scopes)
|
||||
else:
|
||||
token_resp = msal_app.acquire_token_for_client(scopes=scopes)
|
||||
|
||||
if "access_token" not in token_resp:
|
||||
raise ValueError(f"SharePoint token acquisition failed: {token_resp.get('error_description', 'unknown')}")
|
||||
|
||||
access_token = token_resp["access_token"]
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
# Resolve site ID
|
||||
parsed = urllib.parse.urlparse(site_url)
|
||||
hostname = parsed.hostname
|
||||
site_path = parsed.path.rstrip("/")
|
||||
if not hostname or not site_path:
|
||||
raise ValueError(f"Invalid SharePoint site URL: {site_url}")
|
||||
|
||||
resp = _requests.get(f"https://graph.microsoft.com/v1.0/sites/{hostname}:{site_path}", headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
site_id = resp.json()["id"]
|
||||
|
||||
# Resolve drive ID
|
||||
resp = _requests.get(f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives", headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
drive_id = None
|
||||
for drive in resp.json().get("value", []):
|
||||
if drive.get("name", "").lower() == library_name.lower():
|
||||
drive_id = drive["id"]
|
||||
break
|
||||
if not drive_id:
|
||||
raise RuntimeError(f"Document library '{library_name}' not found on SharePoint site")
|
||||
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
# Build upload-session URL
|
||||
base_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}"
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip("/")
|
||||
encoded_path = "/".join(urllib.parse.quote(p) for p in folder_path.split("/"))
|
||||
encoded_file = urllib.parse.quote(filename)
|
||||
item_path = f"/root:/{encoded_path}/{encoded_file}:/createUploadSession"
|
||||
else:
|
||||
encoded_file = urllib.parse.quote(filename)
|
||||
item_path = f"/root:/{encoded_file}:/createUploadSession"
|
||||
|
||||
session_url = f"{base_url}{item_path}"
|
||||
session_headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
|
||||
resp = _requests.post(
|
||||
session_url,
|
||||
headers=session_headers,
|
||||
json={"item": {"@microsoft.graph.conflictBehavior": "replace"}},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
upload_url = resp.json()["uploadUrl"]
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
chunk_size = 10 * 1024 * 1024
|
||||
with open(file_path, "rb") as fh:
|
||||
chunk_num = 0
|
||||
while True:
|
||||
chunk = fh.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
start = chunk_num * chunk_size
|
||||
end = start + len(chunk) - 1
|
||||
upload_headers = {
|
||||
"Content-Length": str(len(chunk)),
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
}
|
||||
upload_resp = _requests.put(upload_url, headers=upload_headers, data=chunk, timeout=120)
|
||||
if upload_resp.status_code not in (201, 202):
|
||||
raise RuntimeError(f"SharePoint chunk upload failed: {upload_resp.status_code}")
|
||||
chunk_num += 1
|
||||
|
||||
logger.info("[%s] SharePoint upload complete: %s/%s", task_id, folder_path, filename)
|
||||
return {"status": "Completed", "sharepoint_folder": folder_path, "filename": filename}
|
||||
|
||||
|
||||
def _upload_icloud(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||||
"""Upload *file_path* to iCloud Drive using per-user credentials.
|
||||
|
||||
@@ -615,6 +722,7 @@ _UPLOAD_HANDLERS = {
|
||||
IntegrationType.PAPERLESS: _upload_paperless,
|
||||
IntegrationType.EMAIL: _upload_email,
|
||||
IntegrationType.RCLONE: _upload_rclone,
|
||||
IntegrationType.SHAREPOINT: _upload_sharepoint,
|
||||
IntegrationType.ICLOUD: _upload_icloud,
|
||||
}
|
||||
|
||||
|
||||
@@ -55,12 +55,12 @@ def upload_with_rclone(self, file_path: str, destination: str):
|
||||
|
||||
try:
|
||||
# Ensure the remote path exists (create folders if needed)
|
||||
mkdir_cmd = ["rclone", "mkdir", "--config", rclone_config_path, destination]
|
||||
mkdir_cmd = ["rclone", "mkdir", "--config", rclone_config_path, "--", destination]
|
||||
|
||||
subprocess.run(mkdir_cmd, check=True, capture_output=True) # noqa: S603
|
||||
|
||||
# Construct the upload command
|
||||
upload_cmd = ["rclone", "copy", "--config", rclone_config_path, file_path, destination, "--progress"]
|
||||
upload_cmd = ["rclone", "copy", "--config", rclone_config_path, "--progress", "--", file_path, destination]
|
||||
|
||||
log_task_progress(task_id, "rclone_upload", "in_progress", f"Executing rclone copy to {destination}")
|
||||
|
||||
@@ -71,7 +71,7 @@ def upload_with_rclone(self, file_path: str, destination: str):
|
||||
if result.returncode == 0:
|
||||
# Try to get a public link if possible
|
||||
try:
|
||||
link_cmd = ["rclone", "link", "--config", rclone_config_path, f"{destination}/{filename}"]
|
||||
link_cmd = ["rclone", "link", "--config", rclone_config_path, "--", f"{destination}/{filename}"]
|
||||
link_result = subprocess.run(link_cmd, capture_output=True, text=True, check=False) # noqa: S603
|
||||
public_url = link_result.stdout.strip() if link_result.returncode == 0 else None
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
|
||||
@@ -296,6 +296,28 @@ def get_provider_status() -> dict[str, dict[str, object]]:
|
||||
},
|
||||
}
|
||||
|
||||
# Check SharePoint configuration
|
||||
providers["SharePoint"] = {
|
||||
"name": "SharePoint",
|
||||
"icon": "fa-brands fa-microsoft",
|
||||
"configured": bool(
|
||||
getattr(settings, "sharepoint_client_id", None)
|
||||
and getattr(settings, "sharepoint_client_secret", None)
|
||||
and getattr(settings, "sharepoint_site_url", None)
|
||||
),
|
||||
"enabled": True,
|
||||
"description": "Store documents in Microsoft SharePoint Online",
|
||||
"details": {
|
||||
"client_id": getattr(settings, "sharepoint_client_id", "Not set"),
|
||||
"client_secret": mask_sensitive_value(getattr(settings, "sharepoint_client_secret", None)),
|
||||
"tenant_id": getattr(settings, "sharepoint_tenant_id", "Not set"),
|
||||
"refresh_token": mask_sensitive_value(getattr(settings, "sharepoint_refresh_token", None)),
|
||||
"site_url": getattr(settings, "sharepoint_site_url", "Not set"),
|
||||
"document_library": getattr(settings, "sharepoint_document_library", "Not set"),
|
||||
"folder_path": getattr(settings, "sharepoint_folder_path", "Not set"),
|
||||
},
|
||||
}
|
||||
|
||||
# Check S3 configuration
|
||||
providers["S3 Storage"] = {
|
||||
"name": "S3 Storage",
|
||||
|
||||
@@ -12,6 +12,7 @@ The utility:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import MetaData, create_engine, inspect, text
|
||||
@@ -84,6 +85,9 @@ def preview_migration(source_url: str) -> dict[str, Any]:
|
||||
total = 0
|
||||
with src_engine.connect() as conn:
|
||||
for table_name in tables:
|
||||
if not re.match(r"^[a-zA-Z0-9_]+$", table_name):
|
||||
logger.warning(f"Skipping table with invalid name format: {table_name}")
|
||||
continue
|
||||
# table_name is safe — sourced from inspect().get_table_names(), not user input
|
||||
quoted_table = conn.dialect.identifier_preparer.quote(table_name)
|
||||
row = conn.execute(text(f"SELECT COUNT(*) FROM {quoted_table}")).fetchone() # noqa: S608
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def update_env_file(settings_to_update: dict[str, str]) -> bool:
|
||||
"""
|
||||
Updates the .env file with the given settings (best-effort).
|
||||
Creates or modifies existing keys.
|
||||
|
||||
Args:
|
||||
settings_to_update: A dictionary mapping uppercase env var names to their new string values.
|
||||
|
||||
Returns:
|
||||
True if the file was successfully updated, False otherwise.
|
||||
"""
|
||||
try:
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
||||
if not os.path.exists(env_path):
|
||||
logger.warning(f".env file not found at {env_path}, skipping file write")
|
||||
return False
|
||||
|
||||
logger.info(f"Updating settings in {env_path}")
|
||||
|
||||
with open(env_path, "r") as f:
|
||||
env_lines = f.readlines()
|
||||
|
||||
updated = set()
|
||||
new_env_lines = []
|
||||
for line in env_lines:
|
||||
stripped_line = line.rstrip()
|
||||
is_updated = False
|
||||
for key, value in settings_to_update.items():
|
||||
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
|
||||
new_env_lines.append(f"{key}={value}")
|
||||
updated.add(key)
|
||||
is_updated = True
|
||||
break
|
||||
if not is_updated:
|
||||
new_env_lines.append(stripped_line)
|
||||
|
||||
for key, value in settings_to_update.items():
|
||||
if key not in updated:
|
||||
new_env_lines.append(f"{key}={value}")
|
||||
|
||||
with open(env_path, "w") as f:
|
||||
f.write("\n".join(new_env_lines) + "\n")
|
||||
|
||||
logger.info("Successfully updated settings in .env file")
|
||||
return True
|
||||
except Exception as env_err:
|
||||
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
|
||||
return False
|
||||
@@ -7,8 +7,19 @@ def hash_file(filepath: str | Path, chunk_size: int = 65536) -> str:
|
||||
Returns the SHA-256 hash of the file at 'filepath'.
|
||||
Reads the file in chunks to handle large files efficiently.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
filepath_obj = Path(filepath).resolve()
|
||||
workdir_obj = Path(settings.workdir).resolve()
|
||||
|
||||
# Security check: Ensure the resolved path is strictly within the allowed workdir
|
||||
try:
|
||||
filepath_obj.relative_to(workdir_obj)
|
||||
except ValueError:
|
||||
raise FileNotFoundError(f"Access denied: path traversal attempt or file outside workdir '{filepath}'")
|
||||
|
||||
sha256 = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
with open(filepath_obj, "rb") as f:
|
||||
while True:
|
||||
data = f.read(chunk_size)
|
||||
if not data:
|
||||
|
||||
+79
-77
@@ -34,89 +34,91 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_LANGUAGES: list[dict[str, str]] = [
|
||||
# --- Tier 1: Primary European languages ---
|
||||
{"code": "en", "name": "English", "native": "English", "flag": "🇬🇧"},
|
||||
{"code": "de", "name": "German", "native": "Deutsch", "flag": "🇩🇪"},
|
||||
{"code": "fr", "name": "French", "native": "Français", "flag": "🇫🇷"},
|
||||
{"code": "es", "name": "Spanish", "native": "Español", "flag": "🇪🇸"},
|
||||
{"code": "it", "name": "Italian", "native": "Italiano", "flag": "🇮🇹"},
|
||||
{"code": "pt", "name": "Portuguese", "native": "Português", "flag": "🇵🇹"},
|
||||
# flag: lowercase ISO 3166-1 alpha-2 country code used with the flag-icons CSS library
|
||||
# (e.g. "gb" → <span class="fi fi-gb">). Regional codes like "gb-wls" are also supported.
|
||||
{"code": "en", "name": "English", "native": "English", "flag": "gb"},
|
||||
{"code": "de", "name": "German", "native": "Deutsch", "flag": "de"},
|
||||
{"code": "fr", "name": "French", "native": "Français", "flag": "fr"},
|
||||
{"code": "es", "name": "Spanish", "native": "Español", "flag": "es"},
|
||||
{"code": "it", "name": "Italian", "native": "Italiano", "flag": "it"},
|
||||
{"code": "pt", "name": "Portuguese", "native": "Português", "flag": "pt"},
|
||||
# --- Tier 2: Western & Northern European ---
|
||||
{"code": "nl", "name": "Dutch", "native": "Nederlands", "flag": "🇳🇱"},
|
||||
{"code": "nb", "name": "Norwegian Bokmål", "native": "Norsk bokmål", "flag": "🇳🇴"},
|
||||
{"code": "no", "name": "Norwegian", "native": "Norsk", "flag": "🇳🇴"},
|
||||
{"code": "da", "name": "Danish", "native": "Dansk", "flag": "🇩🇰"},
|
||||
{"code": "sv", "name": "Swedish", "native": "Svenska", "flag": "🇸🇪"},
|
||||
{"code": "fi", "name": "Finnish", "native": "Suomi", "flag": "🇫🇮"},
|
||||
{"code": "is", "name": "Icelandic", "native": "Íslenska", "flag": "🇮🇸"},
|
||||
{"code": "ga", "name": "Irish", "native": "Gaeilge", "flag": "🇮🇪"},
|
||||
{"code": "lb", "name": "Luxembourgish", "native": "Lëtzebuergesch", "flag": "🇱🇺"},
|
||||
{"code": "ca", "name": "Catalan", "native": "Català", "flag": "🏴"},
|
||||
{"code": "cy", "name": "Welsh", "native": "Cymraeg", "flag": "🏴"}, # Wales subdivision flag (U+1F3F4 + tag chars)
|
||||
{"code": "fy", "name": "Western Frisian", "native": "Frysk", "flag": "🇳🇱"},
|
||||
{"code": "gl", "name": "Galician", "native": "Galego", "flag": "🇪🇸"},
|
||||
{"code": "li", "name": "Limburgish", "native": "Limburgs", "flag": "🇳🇱"},
|
||||
{"code": "vls", "name": "Flemish", "native": "West-Vlams", "flag": "🇧🇪"},
|
||||
{"code": "nds", "name": "Low German", "native": "Plattdüütsch", "flag": "🇩🇪"},
|
||||
{"code": "nl", "name": "Dutch", "native": "Nederlands", "flag": "nl"},
|
||||
{"code": "nb", "name": "Norwegian Bokmål", "native": "Norsk bokmål", "flag": "no"},
|
||||
{"code": "no", "name": "Norwegian", "native": "Norsk", "flag": "no"},
|
||||
{"code": "da", "name": "Danish", "native": "Dansk", "flag": "dk"},
|
||||
{"code": "sv", "name": "Swedish", "native": "Svenska", "flag": "se"},
|
||||
{"code": "fi", "name": "Finnish", "native": "Suomi", "flag": "fi"},
|
||||
{"code": "is", "name": "Icelandic", "native": "Íslenska", "flag": "is"},
|
||||
{"code": "ga", "name": "Irish", "native": "Gaeilge", "flag": "ie"},
|
||||
{"code": "lb", "name": "Luxembourgish", "native": "Lëtzebuergesch", "flag": "lu"},
|
||||
{"code": "ca", "name": "Catalan", "native": "Català", "flag": "es"}, # no dedicated ISO flag; use Spain
|
||||
{"code": "cy", "name": "Welsh", "native": "Cymraeg", "flag": "gb-wls"}, # flag-icons GB region code
|
||||
{"code": "fy", "name": "Western Frisian", "native": "Frysk", "flag": "nl"},
|
||||
{"code": "gl", "name": "Galician", "native": "Galego", "flag": "es"},
|
||||
{"code": "li", "name": "Limburgish", "native": "Limburgs", "flag": "nl"},
|
||||
{"code": "vls", "name": "Flemish", "native": "West-Vlams", "flag": "be"},
|
||||
{"code": "nds", "name": "Low German", "native": "Plattdüütsch", "flag": "de"},
|
||||
# --- Tier 3: Central & Eastern European ---
|
||||
{"code": "pl", "name": "Polish", "native": "Polski", "flag": "🇵🇱"},
|
||||
{"code": "cs", "name": "Czech", "native": "Čeština", "flag": "🇨🇿"},
|
||||
{"code": "sk", "name": "Slovak", "native": "Slovenčina", "flag": "🇸🇰"},
|
||||
{"code": "hu", "name": "Hungarian", "native": "Magyar", "flag": "🇭🇺"},
|
||||
{"code": "sl", "name": "Slovenian", "native": "Slovenščina", "flag": "🇸🇮"},
|
||||
{"code": "hr", "name": "Croatian", "native": "Hrvatski", "flag": "🇭🇷"},
|
||||
{"code": "ro", "name": "Romanian", "native": "Română", "flag": "🇷🇴"},
|
||||
{"code": "bg", "name": "Bulgarian", "native": "Български", "flag": "🇧🇬"},
|
||||
{"code": "el", "name": "Greek", "native": "Ελληνικά", "flag": "🇬🇷"},
|
||||
{"code": "et", "name": "Estonian", "native": "Eesti", "flag": "🇪🇪"},
|
||||
{"code": "lv", "name": "Latvian", "native": "Latviešu", "flag": "🇱🇻"},
|
||||
{"code": "lt", "name": "Lithuanian", "native": "Lietuvių", "flag": "🇱🇹"},
|
||||
{"code": "sr", "name": "Serbian", "native": "Српски", "flag": "🇷🇸"},
|
||||
{"code": "pl", "name": "Polish", "native": "Polski", "flag": "pl"},
|
||||
{"code": "cs", "name": "Czech", "native": "Čeština", "flag": "cz"},
|
||||
{"code": "sk", "name": "Slovak", "native": "Slovenčina", "flag": "sk"},
|
||||
{"code": "hu", "name": "Hungarian", "native": "Magyar", "flag": "hu"},
|
||||
{"code": "sl", "name": "Slovenian", "native": "Slovenščina", "flag": "si"},
|
||||
{"code": "hr", "name": "Croatian", "native": "Hrvatski", "flag": "hr"},
|
||||
{"code": "ro", "name": "Romanian", "native": "Română", "flag": "ro"},
|
||||
{"code": "bg", "name": "Bulgarian", "native": "Български", "flag": "bg"},
|
||||
{"code": "el", "name": "Greek", "native": "Ελληνικά", "flag": "gr"},
|
||||
{"code": "et", "name": "Estonian", "native": "Eesti", "flag": "ee"},
|
||||
{"code": "lv", "name": "Latvian", "native": "Latviešu", "flag": "lv"},
|
||||
{"code": "lt", "name": "Lithuanian", "native": "Lietuvių", "flag": "lt"},
|
||||
{"code": "sr", "name": "Serbian", "native": "Српски", "flag": "rs"},
|
||||
# --- Tier 4: Non-EU European, Middle Eastern & African ---
|
||||
{"code": "tr", "name": "Turkish", "native": "Türkçe", "flag": "🇹🇷"},
|
||||
{"code": "uk", "name": "Ukrainian", "native": "Українська", "flag": "🇺🇦"},
|
||||
{"code": "he", "name": "Hebrew", "native": "עברית", "flag": "🇮🇱"},
|
||||
{"code": "ar", "name": "Arabic", "native": "العربية", "flag": "🇸🇦"},
|
||||
{"code": "fa", "name": "Persian", "native": "فارسی", "flag": "🇮🇷"},
|
||||
{"code": "af", "name": "Afrikaans", "native": "Afrikaans", "flag": "🇿🇦"},
|
||||
{"code": "tr", "name": "Turkish", "native": "Türkçe", "flag": "tr"},
|
||||
{"code": "uk", "name": "Ukrainian", "native": "Українська", "flag": "ua"},
|
||||
{"code": "he", "name": "Hebrew", "native": "עברית", "flag": "il"},
|
||||
{"code": "ar", "name": "Arabic", "native": "العربية", "flag": "sa"},
|
||||
{"code": "fa", "name": "Persian", "native": "فارسی", "flag": "ir"},
|
||||
{"code": "af", "name": "Afrikaans", "native": "Afrikaans", "flag": "za"},
|
||||
# --- Tier 5: Asian languages ---
|
||||
{"code": "zh", "name": "Chinese", "native": "中文", "flag": "🇨🇳"},
|
||||
{"code": "zh-TW", "name": "Traditional Chinese", "native": "繁體中文", "flag": "🇹🇼"},
|
||||
{"code": "ja", "name": "Japanese", "native": "日本語", "flag": "🇯🇵"},
|
||||
{"code": "ko", "name": "Korean", "native": "한국어", "flag": "🇰🇷"},
|
||||
{"code": "vi", "name": "Vietnamese", "native": "Tiếng Việt", "flag": "🇻🇳"},
|
||||
{"code": "pa", "name": "Punjabi", "native": "ਪੰਜਾਬੀ", "flag": "🇮🇳"},
|
||||
{"code": "kn", "name": "Kannada", "native": "ಕನ್ನಡ", "flag": "🇮🇳"},
|
||||
{"code": "hi", "name": "Hindi", "native": "हिन्दी", "flag": "🇮🇳"},
|
||||
{"code": "bn", "name": "Bengali", "native": "বাংলা", "flag": "🇧🇩"},
|
||||
{"code": "gu", "name": "Gujarati", "native": "ગુજરાતી", "flag": "🇮🇳"},
|
||||
{"code": "ml", "name": "Malayalam", "native": "മലയാളം", "flag": "🇮🇳"},
|
||||
{"code": "mr", "name": "Marathi", "native": "मराठी", "flag": "🇮🇳"},
|
||||
{"code": "ta", "name": "Tamil", "native": "தமிழ்", "flag": "🇮🇳"},
|
||||
{"code": "te", "name": "Telugu", "native": "తెలుగు", "flag": "🇮🇳"},
|
||||
{"code": "ur", "name": "Urdu", "native": "اردو", "flag": "🇵🇰"},
|
||||
{"code": "si", "name": "Sinhala", "native": "සිංහල", "flag": "🇱🇰"},
|
||||
{"code": "ne", "name": "Nepali", "native": "नेपाली", "flag": "🇳🇵"},
|
||||
{"code": "th", "name": "Thai", "native": "ไทย", "flag": "🇹🇭"},
|
||||
{"code": "km", "name": "Khmer", "native": "ខ្មែរ", "flag": "🇰🇭"},
|
||||
{"code": "id", "name": "Indonesian", "native": "Bahasa Indonesia", "flag": "🇮🇩"},
|
||||
{"code": "ms", "name": "Malay", "native": "Bahasa Melayu", "flag": "🇲🇾"},
|
||||
{"code": "jv", "name": "Javanese", "native": "Basa Jawa", "flag": "🇮🇩"},
|
||||
{"code": "tl", "name": "Tagalog", "native": "Filipino", "flag": "🇵🇭"},
|
||||
{"code": "mn", "name": "Mongolian", "native": "Монгол", "flag": "🇲🇳"},
|
||||
{"code": "kk", "name": "Kazakh", "native": "Қазақ тілі", "flag": "🇰🇿"},
|
||||
{"code": "uz", "name": "Uzbek", "native": "Oʻzbekcha", "flag": "🇺🇿"},
|
||||
{"code": "az", "name": "Azerbaijani", "native": "Azərbaycan dili", "flag": "🇦🇿"},
|
||||
{"code": "hy", "name": "Armenian", "native": "Հայերեն", "flag": "🇦🇲"},
|
||||
{"code": "ka", "name": "Georgian", "native": "ქართული", "flag": "🇬🇪"},
|
||||
{"code": "zh", "name": "Chinese", "native": "中文", "flag": "cn"},
|
||||
{"code": "zh-TW", "name": "Traditional Chinese", "native": "繁體中文", "flag": "tw"},
|
||||
{"code": "ja", "name": "Japanese", "native": "日本語", "flag": "jp"},
|
||||
{"code": "ko", "name": "Korean", "native": "한국어", "flag": "kr"},
|
||||
{"code": "vi", "name": "Vietnamese", "native": "Tiếng Việt", "flag": "vn"},
|
||||
{"code": "pa", "name": "Punjabi", "native": "ਪੰਜਾਬੀ", "flag": "in"},
|
||||
{"code": "kn", "name": "Kannada", "native": "ಕನ್ನಡ", "flag": "in"},
|
||||
{"code": "hi", "name": "Hindi", "native": "हिन्दी", "flag": "in"},
|
||||
{"code": "bn", "name": "Bengali", "native": "বাংলা", "flag": "bd"},
|
||||
{"code": "gu", "name": "Gujarati", "native": "ગુજરાતી", "flag": "in"},
|
||||
{"code": "ml", "name": "Malayalam", "native": "മലയാളം", "flag": "in"},
|
||||
{"code": "mr", "name": "Marathi", "native": "मराठी", "flag": "in"},
|
||||
{"code": "ta", "name": "Tamil", "native": "தமிழ்", "flag": "in"},
|
||||
{"code": "te", "name": "Telugu", "native": "తెలుగు", "flag": "in"},
|
||||
{"code": "ur", "name": "Urdu", "native": "اردو", "flag": "pk"},
|
||||
{"code": "si", "name": "Sinhala", "native": "සිංහල", "flag": "lk"},
|
||||
{"code": "ne", "name": "Nepali", "native": "नेपाली", "flag": "np"},
|
||||
{"code": "th", "name": "Thai", "native": "ไทย", "flag": "th"},
|
||||
{"code": "km", "name": "Khmer", "native": "ខ្មែរ", "flag": "kh"},
|
||||
{"code": "id", "name": "Indonesian", "native": "Bahasa Indonesia", "flag": "id"},
|
||||
{"code": "ms", "name": "Malay", "native": "Bahasa Melayu", "flag": "my"},
|
||||
{"code": "jv", "name": "Javanese", "native": "Basa Jawa", "flag": "id"},
|
||||
{"code": "tl", "name": "Tagalog", "native": "Filipino", "flag": "ph"},
|
||||
{"code": "mn", "name": "Mongolian", "native": "Монгол", "flag": "mn"},
|
||||
{"code": "kk", "name": "Kazakh", "native": "Қазақ тілі", "flag": "kz"},
|
||||
{"code": "uz", "name": "Uzbek", "native": "Oʻzbekcha", "flag": "uz"},
|
||||
{"code": "az", "name": "Azerbaijani", "native": "Azərbaycan dili", "flag": "az"},
|
||||
{"code": "hy", "name": "Armenian", "native": "Հայերեն", "flag": "am"},
|
||||
{"code": "ka", "name": "Georgian", "native": "ქართული", "flag": "ge"},
|
||||
# --- Tier 6: African languages ---
|
||||
{"code": "sw", "name": "Swahili", "native": "Kiswahili", "flag": "🇰🇪"},
|
||||
{"code": "am", "name": "Amharic", "native": "አማርኛ", "flag": "🇪🇹"},
|
||||
{"code": "ha", "name": "Hausa", "native": "Hausa", "flag": "🇳🇬"},
|
||||
{"code": "yo", "name": "Yoruba", "native": "Yorùbá", "flag": "🇳🇬"},
|
||||
{"code": "ig", "name": "Igbo", "native": "Igbo", "flag": "🇳🇬"},
|
||||
{"code": "zu", "name": "Zulu", "native": "isiZulu", "flag": "🇿🇦"},
|
||||
{"code": "sw", "name": "Swahili", "native": "Kiswahili", "flag": "ke"},
|
||||
{"code": "am", "name": "Amharic", "native": "አማርኛ", "flag": "et"},
|
||||
{"code": "ha", "name": "Hausa", "native": "Hausa", "flag": "ng"},
|
||||
{"code": "yo", "name": "Yoruba", "native": "Yorùbá", "flag": "ng"},
|
||||
{"code": "ig", "name": "Igbo", "native": "Igbo", "flag": "ng"},
|
||||
{"code": "zu", "name": "Zulu", "native": "isiZulu", "flag": "za"},
|
||||
# --- Tier 7: Constructed & other languages ---
|
||||
{"code": "eo", "name": "Esperanto", "native": "Esperanto", "flag": "🌍"},
|
||||
{"code": "eo", "name": "Esperanto", "native": "Esperanto", "flag": "un"}, # UN flag for international language
|
||||
]
|
||||
|
||||
SUPPORTED_LANGUAGE_CODES: set[str] = {lang["code"] for lang in SUPPORTED_LANGUAGES}
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Server-side session management utilities.
|
||||
|
||||
Provides helpers for creating, validating, and revoking user sessions.
|
||||
Sessions are tracked in the ``user_sessions`` table and referenced by a
|
||||
cryptographically random token stored in the browser cookie. This enables
|
||||
the "log off everywhere" feature and per-session revocation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import ApiToken, QRLoginChallenge, UserSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_tz_aware(dt: datetime | None) -> datetime | None:
|
||||
"""Return *dt* with UTC tzinfo if it is naive, or unchanged if already aware.
|
||||
|
||||
SQLite does not persist timezone information, so datetimes read back from
|
||||
the database are offset-naive. This helper normalises them for safe
|
||||
comparison with ``datetime.now(timezone.utc)``.
|
||||
"""
|
||||
if dt is not None and dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def get_session_lifetime_days() -> int:
|
||||
"""Return the effective session lifetime in days.
|
||||
|
||||
If ``session_lifetime_custom_days`` is set it takes precedence over
|
||||
``session_lifetime_days``.
|
||||
"""
|
||||
custom = getattr(settings, "session_lifetime_custom_days", None)
|
||||
if custom is not None and isinstance(custom, int) and custom > 0:
|
||||
return custom
|
||||
return max(1, getattr(settings, "session_lifetime_days", 30))
|
||||
|
||||
|
||||
def get_session_max_age_seconds() -> int:
|
||||
"""Return the session max-age in seconds for the cookie."""
|
||||
return get_session_lifetime_days() * 86400
|
||||
|
||||
|
||||
def create_session(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> UserSession:
|
||||
"""Create a new server-side session record.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: Stable owner identifier.
|
||||
ip_address: Client IP address.
|
||||
user_agent: Client User-Agent header.
|
||||
|
||||
Returns:
|
||||
The newly created ``UserSession`` instance.
|
||||
"""
|
||||
session_token = secrets.token_urlsafe(64)
|
||||
now = datetime.now(timezone.utc)
|
||||
lifetime_days = get_session_lifetime_days()
|
||||
expires_at = now + timedelta(days=lifetime_days)
|
||||
|
||||
device_info = _parse_device_info(user_agent)
|
||||
|
||||
user_session = UserSession(
|
||||
session_token=session_token,
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=(user_agent or "")[:512],
|
||||
device_info=device_info,
|
||||
created_at=now,
|
||||
last_active_at=now,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
try:
|
||||
db.add(user_session)
|
||||
db.commit()
|
||||
db.refresh(user_session)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to create session for user_id=%s", user_id)
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"[SESSION] Created session id=%s user=%s device=%r expires=%s",
|
||||
user_session.id,
|
||||
user_id,
|
||||
device_info,
|
||||
expires_at.isoformat(),
|
||||
)
|
||||
return user_session
|
||||
|
||||
|
||||
def validate_session(db: Session, session_token: str) -> UserSession | None:
|
||||
"""Validate a session token and return the session if valid.
|
||||
|
||||
A session is valid when:
|
||||
* It exists in the database.
|
||||
* ``is_revoked`` is ``False``.
|
||||
* ``expires_at`` is in the future.
|
||||
|
||||
Side-effect: updates ``last_active_at`` on valid sessions.
|
||||
|
||||
Returns:
|
||||
The ``UserSession`` if valid, else ``None``.
|
||||
"""
|
||||
if not session_token:
|
||||
return None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
user_session = db.query(UserSession).filter(UserSession.session_token == session_token).first()
|
||||
|
||||
if not user_session:
|
||||
logger.debug("[SESSION] Token not found in database")
|
||||
return None
|
||||
|
||||
if user_session.is_revoked:
|
||||
logger.debug("[SESSION] Session id=%s is revoked", user_session.id)
|
||||
return None
|
||||
|
||||
if user_session.expires_at:
|
||||
expires = _ensure_tz_aware(user_session.expires_at)
|
||||
if expires < now:
|
||||
logger.debug("[SESSION] Session id=%s has expired", user_session.id)
|
||||
return None
|
||||
|
||||
# Update last_active_at (throttled to avoid excessive writes)
|
||||
last_active = _ensure_tz_aware(user_session.last_active_at)
|
||||
if not last_active or (now - last_active).total_seconds() > 60:
|
||||
try:
|
||||
user_session.last_active_at = now
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.debug("[SESSION] Failed to update last_active_at for session id=%s", user_session.id)
|
||||
|
||||
return user_session
|
||||
|
||||
|
||||
def revoke_session(db: Session, session_id: int, user_id: str) -> bool:
|
||||
"""Revoke a single session by ID.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
session_id: The session record ID to revoke.
|
||||
user_id: The owner — ensures a user can only revoke their own sessions.
|
||||
|
||||
Returns:
|
||||
``True`` if the session was found and revoked, ``False`` otherwise.
|
||||
"""
|
||||
user_session = db.get(UserSession, session_id)
|
||||
if not user_session or user_session.user_id != user_id:
|
||||
return False
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
user_session.is_revoked = True
|
||||
user_session.revoked_at = now
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("[SESSION] Revoked session id=%s user=%s", session_id, user_id)
|
||||
return True
|
||||
|
||||
|
||||
def revoke_all_sessions(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
*,
|
||||
except_session_id: int | None = None,
|
||||
revoke_api_tokens: bool = True,
|
||||
) -> int:
|
||||
"""Revoke all active sessions for a user ("log off everywhere").
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The owner whose sessions should be revoked.
|
||||
except_session_id: If provided, keep this session active (the
|
||||
current browser session).
|
||||
revoke_api_tokens: If ``True``, also revoke all active API tokens.
|
||||
|
||||
Returns:
|
||||
Number of sessions revoked.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
query = db.query(UserSession).filter(
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.is_revoked.is_(False),
|
||||
)
|
||||
if except_session_id is not None:
|
||||
query = query.filter(UserSession.id != except_session_id)
|
||||
|
||||
sessions = query.all()
|
||||
count = 0
|
||||
for s in sessions:
|
||||
s.is_revoked = True
|
||||
s.revoked_at = now
|
||||
count += 1
|
||||
|
||||
if revoke_api_tokens:
|
||||
tokens = (
|
||||
db.query(ApiToken)
|
||||
.filter(
|
||||
ApiToken.owner_id == user_id,
|
||||
ApiToken.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for t in tokens:
|
||||
t.is_active = False
|
||||
t.revoked_at = now
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"[SESSION] Revoked all sessions for user=%s (count=%d, except_session_id=%s, tokens_revoked=%s)",
|
||||
user_id,
|
||||
count,
|
||||
except_session_id,
|
||||
revoke_api_tokens,
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def list_user_sessions(db: Session, user_id: str) -> list[UserSession]:
|
||||
"""Return all non-revoked, non-expired sessions for a user.
|
||||
|
||||
Results are ordered by most recently active first.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
sessions = (
|
||||
db.query(UserSession)
|
||||
.filter(
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.is_revoked.is_(False),
|
||||
)
|
||||
.order_by(UserSession.last_active_at.desc())
|
||||
.all()
|
||||
)
|
||||
# Filter expired sessions in Python to handle timezone-naive datetimes (SQLite)
|
||||
result = []
|
||||
for s in sessions:
|
||||
expires = _ensure_tz_aware(s.expires_at)
|
||||
if expires and expires > now:
|
||||
result.append(s)
|
||||
return result
|
||||
|
||||
|
||||
def cleanup_expired_sessions(db: Session) -> int:
|
||||
"""Delete sessions that expired more than 7 days ago.
|
||||
|
||||
Intended to be called periodically (e.g. via Celery beat) to keep the
|
||||
table from growing unbounded.
|
||||
|
||||
Returns:
|
||||
Number of rows deleted.
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
count = db.query(UserSession).filter(UserSession.expires_at < cutoff).delete(synchronize_session=False)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
if count:
|
||||
logger.info("[SESSION] Cleaned up %d expired sessions", count)
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR login helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_qr_challenge(db: Session, user_id: str, ip_address: str | None = None) -> QRLoginChallenge:
|
||||
"""Create a new QR login challenge.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The authenticated web user creating the challenge.
|
||||
ip_address: IP address of the web client.
|
||||
|
||||
Returns:
|
||||
The newly created ``QRLoginChallenge``.
|
||||
"""
|
||||
token = secrets.token_urlsafe(64)
|
||||
ttl = getattr(settings, "qr_login_challenge_ttl_seconds", 120)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(seconds=ttl)
|
||||
|
||||
challenge = QRLoginChallenge(
|
||||
challenge_token=token,
|
||||
user_id=user_id,
|
||||
created_by_ip=ip_address,
|
||||
created_at=now,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
try:
|
||||
db.add(challenge)
|
||||
db.commit()
|
||||
db.refresh(challenge)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to create QR login challenge for user_id=%s", user_id)
|
||||
raise
|
||||
|
||||
logger.info("[QR_AUTH] Challenge created: id=%s user=%s expires=%s", challenge.id, user_id, expires_at.isoformat())
|
||||
return challenge
|
||||
|
||||
|
||||
def validate_qr_challenge(db: Session, challenge_token: str) -> QRLoginChallenge | None:
|
||||
"""Validate a QR challenge token without claiming it.
|
||||
|
||||
Returns the challenge if it exists, is not expired, not claimed,
|
||||
and not cancelled. Returns ``None`` otherwise.
|
||||
"""
|
||||
if not challenge_token:
|
||||
return None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
challenge = db.query(QRLoginChallenge).filter(QRLoginChallenge.challenge_token == challenge_token).first()
|
||||
|
||||
if not challenge:
|
||||
return None
|
||||
if challenge.is_claimed or challenge.is_cancelled:
|
||||
return None
|
||||
|
||||
expires = _ensure_tz_aware(challenge.expires_at)
|
||||
if expires and expires < now:
|
||||
return None
|
||||
|
||||
return challenge
|
||||
|
||||
|
||||
def claim_qr_challenge(
|
||||
db: Session,
|
||||
challenge_token: str,
|
||||
device_name: str = "Mobile App",
|
||||
ip_address: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Claim a QR challenge and issue an API token.
|
||||
|
||||
This is the critical security path. The challenge is validated,
|
||||
marked as claimed atomically, and an API token is issued for the
|
||||
user who created the challenge.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
challenge_token: The token from the QR code.
|
||||
device_name: Name provided by the mobile app.
|
||||
ip_address: IP address of the claiming mobile device.
|
||||
|
||||
Returns:
|
||||
Dict with ``token`` (plaintext), ``token_id``, ``name``, ``owner_id``
|
||||
and ``created_at`` on success, or ``None`` if the challenge is invalid.
|
||||
"""
|
||||
from app.api.api_tokens import generate_api_token, hash_token
|
||||
|
||||
challenge = validate_qr_challenge(db, challenge_token)
|
||||
if not challenge:
|
||||
logger.warning("[QR_AUTH] Invalid or expired challenge token attempted")
|
||||
return None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Mark as claimed first to prevent race conditions
|
||||
challenge.is_claimed = True
|
||||
challenge.claimed_at = now
|
||||
challenge.claimed_by_ip = ip_address
|
||||
challenge.device_name = device_name
|
||||
|
||||
# Generate API token for the mobile app
|
||||
token_name = f"Mobile App (QR) – {device_name}"
|
||||
plaintext = generate_api_token()
|
||||
token_hash_value = hash_token(plaintext)
|
||||
prefix = plaintext[:12]
|
||||
|
||||
db_token = ApiToken(
|
||||
owner_id=challenge.user_id,
|
||||
name=token_name,
|
||||
token_hash=token_hash_value,
|
||||
token_prefix=prefix,
|
||||
)
|
||||
|
||||
try:
|
||||
db.add(db_token)
|
||||
db.flush()
|
||||
challenge.issued_token_id = db_token.id
|
||||
db.commit()
|
||||
db.refresh(db_token)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("[QR_AUTH] Failed to issue token for challenge id=%s", challenge.id)
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"[QR_AUTH] Challenge claimed: id=%s user=%s device=%r token_id=%s",
|
||||
challenge.id,
|
||||
challenge.user_id,
|
||||
device_name,
|
||||
db_token.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"token": plaintext,
|
||||
"token_id": db_token.id,
|
||||
"name": token_name,
|
||||
"owner_id": challenge.user_id,
|
||||
"created_at": db_token.created_at,
|
||||
}
|
||||
|
||||
|
||||
def get_challenge_status(db: Session, challenge_id: int, user_id: str) -> dict | None:
|
||||
"""Get the current status of a QR challenge (for polling from the web UI).
|
||||
|
||||
Returns:
|
||||
Dict with ``status`` ("pending", "claimed", "expired", "cancelled")
|
||||
and metadata, or ``None`` if the challenge doesn't belong to the user.
|
||||
"""
|
||||
challenge = db.get(QRLoginChallenge, challenge_id)
|
||||
if not challenge or challenge.user_id != user_id:
|
||||
return None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = _ensure_tz_aware(challenge.expires_at)
|
||||
|
||||
if challenge.is_claimed:
|
||||
status = "claimed"
|
||||
elif challenge.is_cancelled:
|
||||
status = "cancelled"
|
||||
elif expires and expires < now:
|
||||
status = "expired"
|
||||
else:
|
||||
status = "pending"
|
||||
|
||||
return {
|
||||
"id": challenge.id,
|
||||
"status": status,
|
||||
"device_name": challenge.device_name,
|
||||
"claimed_at": challenge.claimed_at,
|
||||
"expires_at": challenge.expires_at,
|
||||
}
|
||||
|
||||
|
||||
def _parse_device_info(user_agent: str | None) -> str | None:
|
||||
"""Extract a human-readable device description from User-Agent.
|
||||
|
||||
This is a lightweight parser — not a full UA library — that covers
|
||||
the most common browsers and platforms.
|
||||
"""
|
||||
if not user_agent:
|
||||
return None
|
||||
|
||||
ua = user_agent.lower()
|
||||
|
||||
# Platform detection
|
||||
platform = "Unknown"
|
||||
if "iphone" in ua:
|
||||
platform = "iPhone"
|
||||
elif "ipad" in ua:
|
||||
platform = "iPad"
|
||||
elif "android" in ua:
|
||||
platform = "Android"
|
||||
elif "macintosh" in ua or "mac os" in ua:
|
||||
platform = "macOS"
|
||||
elif "windows" in ua:
|
||||
platform = "Windows"
|
||||
elif "linux" in ua:
|
||||
platform = "Linux"
|
||||
elif "cros" in ua:
|
||||
platform = "ChromeOS"
|
||||
|
||||
# Browser detection
|
||||
browser = "Unknown Browser"
|
||||
if "edg/" in ua or "edge/" in ua:
|
||||
browser = "Edge"
|
||||
elif "opr/" in ua or "opera" in ua:
|
||||
browser = "Opera"
|
||||
elif "chrome/" in ua and "safari/" in ua:
|
||||
browser = "Chrome"
|
||||
elif "safari/" in ua and "chrome/" not in ua:
|
||||
browser = "Safari"
|
||||
elif "firefox/" in ua:
|
||||
browser = "Firefox"
|
||||
elif "docuelevate" in ua:
|
||||
browser = "DocuElevate App"
|
||||
|
||||
return f"{browser} on {platform}"
|
||||
@@ -135,6 +135,30 @@ SETTING_METADATA = {
|
||||
"required": True, # Required when auth_enabled=True (validated in config.py)
|
||||
"restart_required": True,
|
||||
},
|
||||
"session_lifetime_days": {
|
||||
"category": "Authentication",
|
||||
"description": "Session lifetime in days (default 30). Determines how long a user stays logged in.",
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"session_lifetime_custom_days": {
|
||||
"category": "Authentication",
|
||||
"description": "Override session_lifetime_days with a custom value. Takes precedence when set.",
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"qr_login_challenge_ttl_seconds": {
|
||||
"category": "Authentication",
|
||||
"description": "Time-to-live in seconds for QR login challenges (default 120).",
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"admin_username": {
|
||||
"category": "Authentication",
|
||||
"description": "Admin username for local authentication",
|
||||
@@ -523,6 +547,19 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Document Translation
|
||||
"default_document_language": {
|
||||
"category": "AI Services",
|
||||
"description": (
|
||||
"ISO 639-1 language code for the default document translation target "
|
||||
"(e.g. 'en', 'de', 'fr'). Documents whose detected language differs "
|
||||
"are automatically translated into this language after processing."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# OCR Engine Configuration
|
||||
"ocr_providers": {
|
||||
"category": "OCR Engines",
|
||||
@@ -863,6 +900,63 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Storage Providers - SharePoint
|
||||
"sharepoint_client_id": {
|
||||
"category": "Storage Providers",
|
||||
"description": "SharePoint Azure AD application (client) ID",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"sharepoint_client_secret": {
|
||||
"category": "Storage Providers",
|
||||
"description": "SharePoint Azure AD client secret",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"sharepoint_tenant_id": {
|
||||
"category": "Storage Providers",
|
||||
"description": "SharePoint Azure AD tenant ID (use 'common' for multi-tenant apps)",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"sharepoint_refresh_token": {
|
||||
"category": "Storage Providers",
|
||||
"description": "SharePoint OAuth refresh token",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"sharepoint_site_url": {
|
||||
"category": "Storage Providers",
|
||||
"description": "SharePoint site URL (e.g. https://tenant.sharepoint.com/sites/sitename)",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"sharepoint_document_library": {
|
||||
"category": "Storage Providers",
|
||||
"description": "SharePoint document library name (default: 'Documents')",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"sharepoint_folder_path": {
|
||||
"category": "Storage Providers",
|
||||
"description": "Subfolder path inside the SharePoint document library",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Storage Providers - WebDAV
|
||||
"webdav_enabled": {
|
||||
"category": "Storage Providers",
|
||||
@@ -1888,6 +1982,28 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"factory_reset_on_startup": {
|
||||
"category": "Feature Flags",
|
||||
"description": (
|
||||
"Wipe all user data on every startup so the instance always starts fresh. "
|
||||
"Useful for demo/testing environments. Default: False."
|
||||
),
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"enable_factory_reset": {
|
||||
"category": "Feature Flags",
|
||||
"description": (
|
||||
"Show the System Reset page in the admin UI. Allows administrators to "
|
||||
"trigger a full data wipe or a wipe-and-reimport from the web interface. Default: False."
|
||||
),
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Backup / Restore
|
||||
"backup_enabled": {
|
||||
"category": "Backup",
|
||||
@@ -1911,14 +2027,26 @@ SETTING_METADATA = {
|
||||
"category": "Backup",
|
||||
"description": (
|
||||
"Storage provider for remote backup copies. "
|
||||
"Accepted values: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email. "
|
||||
"Accepted values: s3, dropbox, google_drive, onedrive, sharepoint, nextcloud, webdav, ftp, sftp, email. "
|
||||
"Leave empty to keep backups local only."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
"options": ["", "s3", "dropbox", "google_drive", "onedrive", "nextcloud", "webdav", "ftp", "sftp", "email"],
|
||||
"options": [
|
||||
"",
|
||||
"s3",
|
||||
"dropbox",
|
||||
"google_drive",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"nextcloud",
|
||||
"webdav",
|
||||
"ftp",
|
||||
"sftp",
|
||||
"email",
|
||||
],
|
||||
},
|
||||
"backup_remote_folder": {
|
||||
"category": "Backup",
|
||||
@@ -2430,6 +2558,72 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Database Connection Pool
|
||||
"db_pool_size": {
|
||||
"category": "Core",
|
||||
"description": (
|
||||
"Number of persistent connections kept in the SQLAlchemy QueuePool. "
|
||||
"Has no effect for SQLite databases. Default: 5."
|
||||
),
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"db_max_overflow": {
|
||||
"category": "Core",
|
||||
"description": (
|
||||
"Maximum extra connections that can be opened beyond db_pool_size. "
|
||||
"Has no effect for SQLite databases. Default: 10."
|
||||
),
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"db_pool_timeout": {
|
||||
"category": "Core",
|
||||
"description": (
|
||||
"Seconds to wait for a connection from the pool before raising an error. "
|
||||
"Has no effect for SQLite databases. Default: 30."
|
||||
),
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"db_pool_recycle": {
|
||||
"category": "Core",
|
||||
"description": (
|
||||
"Seconds after which idle connections are recycled to prevent stale connections. "
|
||||
"Has no effect for SQLite databases. Default: 1800 (30 minutes)."
|
||||
),
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
# Per-user upload rate limiting
|
||||
"upload_rate_limit_per_user": {
|
||||
"category": "Security",
|
||||
"description": (
|
||||
"Maximum number of uploads a single user may submit within upload_rate_limit_window seconds. "
|
||||
"The health-aware limiter may reduce this dynamically under high Redis queue depth or CPU load. "
|
||||
"Default: 20."
|
||||
),
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"upload_rate_limit_window": {
|
||||
"category": "Security",
|
||||
"description": ("Sliding window in seconds over which upload_rate_limit_per_user is enforced. Default: 60."),
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Rate Limiting
|
||||
"rate_limiting_enabled": {
|
||||
"category": "Security",
|
||||
@@ -2629,6 +2823,63 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Logging
|
||||
"log_level": {
|
||||
"category": "Observability",
|
||||
"description": (
|
||||
"Python logging level for the application root logger. "
|
||||
"Accepts: DEBUG, INFO, WARNING, ERROR, CRITICAL. "
|
||||
"When DEBUG=True and LOG_LEVEL is not explicitly set, "
|
||||
"the effective level is automatically lowered to DEBUG."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"log_format": {
|
||||
"category": "Observability",
|
||||
"description": (
|
||||
"Log output format: 'text' (human-readable, default) or "
|
||||
"'json' (structured JSON lines for SIEM / log aggregation)."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"log_syslog_enabled": {
|
||||
"category": "Observability",
|
||||
"description": "Forward application logs to a syslog receiver in addition to stdout.",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"log_syslog_host": {
|
||||
"category": "Observability",
|
||||
"description": "Hostname or IP of the syslog receiver for application logs.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"log_syslog_port": {
|
||||
"category": "Observability",
|
||||
"description": "Port of the syslog receiver for application logs.",
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"log_syslog_protocol": {
|
||||
"category": "Observability",
|
||||
"description": "Protocol for syslog transport: 'udp' or 'tcp'.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
# Observability – Sentry
|
||||
"sentry_dsn": {
|
||||
"category": "Observability",
|
||||
@@ -3123,7 +3374,7 @@ def get_settings_for_export(db: Session, source: str = "db") -> Dict[str, str]:
|
||||
return {k.upper(): v for k, v in sorted(db_settings.items()) if v is not None}
|
||||
|
||||
|
||||
def update_env_file(env_path: str, settings_to_update: Dict[str, str]) -> bool:
|
||||
def update_env_file(env_path: str, settings_to_update: dict[str, str]) -> bool:
|
||||
"""
|
||||
Update an .env file with new settings.
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
System reset utilities for DocuElevate.
|
||||
|
||||
Provides functions to:
|
||||
- Wipe all user data (database rows + work-files on disk) for a fresh start.
|
||||
- Wipe with re-import: move original files to a dedicated folder, wipe
|
||||
everything, then let the watch-folder mechanism re-ingest the files.
|
||||
|
||||
Security: All public functions in this module require admin-level access.
|
||||
They MUST only be invoked from admin-guarded API/view endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Subdirectories inside *workdir* that contain user-generated data.
|
||||
# Everything else (app code, static assets, config) is left untouched.
|
||||
_USER_DATA_SUBDIRS = ("original", "processed", "tmp", "pdfa", "backups")
|
||||
|
||||
# JSON cache files written by watch-folder / ingest tasks.
|
||||
_CACHE_FILES = (
|
||||
"watch_folder_processed.json",
|
||||
"ftp_ingest_processed.json",
|
||||
"sftp_ingest_processed.json",
|
||||
"dropbox_ingest_processed.json",
|
||||
"gdrive_ingest_processed.json",
|
||||
"onedrive_ingest_processed.json",
|
||||
"nextcloud_ingest_processed.json",
|
||||
"s3_ingest_processed.json",
|
||||
"webdav_ingest_processed.json",
|
||||
"processed_mails.json",
|
||||
"credential_failures.json",
|
||||
)
|
||||
|
||||
# The folder name used for storing files prior to re-import.
|
||||
REIMPORT_FOLDER_NAME = "reimport"
|
||||
|
||||
|
||||
def _wipe_workdir_data(workdir: str) -> dict[str, int]:
|
||||
"""Delete user data subdirectories and cache files inside *workdir*.
|
||||
|
||||
Leaves the workdir directory itself intact so the application can
|
||||
continue to write into it. Also leaves any files that do not belong
|
||||
to the known data subdirectories or caches.
|
||||
|
||||
Returns:
|
||||
A dict with counts of deleted directories and files.
|
||||
"""
|
||||
workdir_path = Path(workdir)
|
||||
deleted_dirs = 0
|
||||
deleted_files = 0
|
||||
|
||||
# Remove data subdirectories
|
||||
for subdir in _USER_DATA_SUBDIRS:
|
||||
target = workdir_path / subdir
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
logger.info("Deleted data directory: %s", target)
|
||||
deleted_dirs += 1
|
||||
|
||||
# Remove cache / state JSON files
|
||||
for cache_file in _CACHE_FILES:
|
||||
target = workdir_path / cache_file
|
||||
if target.is_file():
|
||||
target.unlink()
|
||||
logger.info("Deleted cache file: %s", target)
|
||||
deleted_files += 1
|
||||
|
||||
# Also remove user_wf_*.json files (per-user watch folder caches)
|
||||
for f in workdir_path.glob("user_wf_*.json"):
|
||||
f.unlink()
|
||||
logger.info("Deleted user watch-folder cache: %s", f)
|
||||
deleted_files += 1
|
||||
|
||||
# Remove loose files in workdir root that are user uploads (uuid-named
|
||||
# files like "a1b2c3d4-…pdf") but NOT application config files.
|
||||
for entry in workdir_path.iterdir():
|
||||
if entry.is_file() and entry.suffix.lower() in {
|
||||
".pdf",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".tiff",
|
||||
".tif",
|
||||
".docx",
|
||||
".doc",
|
||||
".xlsx",
|
||||
".xls",
|
||||
".pptx",
|
||||
".heic",
|
||||
".heif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".gif",
|
||||
".txt",
|
||||
".rtf",
|
||||
".odt",
|
||||
".ods",
|
||||
".odp",
|
||||
".csv",
|
||||
".pages",
|
||||
".numbers",
|
||||
".keynote",
|
||||
}:
|
||||
entry.unlink()
|
||||
logger.info("Deleted loose workdir file: %s", entry)
|
||||
deleted_files += 1
|
||||
|
||||
return {"deleted_dirs": deleted_dirs, "deleted_files": deleted_files}
|
||||
|
||||
|
||||
def _wipe_database(db: Session) -> dict[str, int]:
|
||||
"""Delete all user-generated rows from the database.
|
||||
|
||||
Preserves schema (tables, migrations) and system-seeded rows that will
|
||||
be re-created on the next startup (subscription plans, default pipeline,
|
||||
scheduled jobs, compliance templates).
|
||||
|
||||
Returns:
|
||||
A dict mapping table name → number of rows deleted.
|
||||
"""
|
||||
from app.models import (
|
||||
AuditLog,
|
||||
BackupRecord,
|
||||
DocumentMetadata,
|
||||
FileProcessingStep,
|
||||
FileRecord,
|
||||
InAppNotification,
|
||||
ProcessingLog,
|
||||
SavedSearch,
|
||||
SettingsAuditLog,
|
||||
SharedLink,
|
||||
UserImapAccount,
|
||||
UserIntegration,
|
||||
UserNotificationPreference,
|
||||
UserNotificationTarget,
|
||||
)
|
||||
|
||||
# Order matters: delete children before parents to respect FK constraints.
|
||||
tables_to_wipe: list[tuple[str, type]] = [
|
||||
("file_processing_steps", FileProcessingStep),
|
||||
("processing_logs", ProcessingLog),
|
||||
("shared_links", SharedLink),
|
||||
("in_app_notifications", InAppNotification),
|
||||
("user_notification_preferences", UserNotificationPreference),
|
||||
("user_notification_targets", UserNotificationTarget),
|
||||
("user_imap_accounts", UserImapAccount),
|
||||
("user_integrations", UserIntegration),
|
||||
("saved_searches", SavedSearch),
|
||||
("settings_audit_log", SettingsAuditLog),
|
||||
("audit_logs", AuditLog),
|
||||
("backup_records", BackupRecord),
|
||||
("document_metadata", DocumentMetadata),
|
||||
("files", FileRecord),
|
||||
]
|
||||
|
||||
result: dict[str, int] = {}
|
||||
for table_name, model in tables_to_wipe:
|
||||
try:
|
||||
count = db.query(model).delete()
|
||||
result[table_name] = count
|
||||
logger.info("Wiped %d rows from %s", count, table_name)
|
||||
except Exception:
|
||||
logger.exception("Failed to wipe table %s during system reset", table_name)
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
def perform_full_reset(db: Session) -> dict:
|
||||
"""Perform a complete system reset: wipe database rows + work-files.
|
||||
|
||||
Args:
|
||||
db: An active SQLAlchemy session.
|
||||
|
||||
Returns:
|
||||
Summary dict with ``database`` and ``filesystem`` sub-dicts.
|
||||
"""
|
||||
logger.warning(">>> SYSTEM RESET: wiping all user data <<<")
|
||||
|
||||
db_result = _wipe_database(db)
|
||||
fs_result = _wipe_workdir_data(settings.workdir)
|
||||
|
||||
logger.warning(">>> SYSTEM RESET complete <<<")
|
||||
return {"database": db_result, "filesystem": fs_result}
|
||||
|
||||
|
||||
def perform_reset_and_reimport(db: Session) -> dict:
|
||||
"""Move original files to a reimport folder, wipe everything, then
|
||||
configure the reimport folder as a watch folder for re-ingestion.
|
||||
|
||||
The watch-folder scanner (``scan_all_watch_folders``) will pick up
|
||||
the files on its next periodic run and process them exactly as if
|
||||
they had been freshly uploaded — respecting the same backoff
|
||||
strategy, size limits, and rate limits.
|
||||
|
||||
Args:
|
||||
db: An active SQLAlchemy session.
|
||||
|
||||
Returns:
|
||||
Summary dict with ``database``, ``filesystem``, and ``reimport`` sub-dicts.
|
||||
"""
|
||||
workdir_path = Path(settings.workdir)
|
||||
reimport_dir = workdir_path / REIMPORT_FOLDER_NAME
|
||||
original_dir = workdir_path / "original"
|
||||
|
||||
# 1. Collect original files
|
||||
files_moved = 0
|
||||
reimport_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if original_dir.is_dir():
|
||||
for entry in original_dir.iterdir():
|
||||
if entry.is_file():
|
||||
# Validate the resolved path stays within original_dir (path traversal guard)
|
||||
try:
|
||||
entry.resolve().relative_to(original_dir.resolve())
|
||||
except ValueError:
|
||||
logger.warning("Skipping file outside original dir: %s", entry)
|
||||
continue
|
||||
dest = reimport_dir / entry.name
|
||||
# Avoid overwriting: append counter if name clash
|
||||
if dest.exists():
|
||||
stem = dest.stem
|
||||
suffix = dest.suffix
|
||||
counter = 1
|
||||
while dest.exists():
|
||||
dest = reimport_dir / f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
shutil.copy2(str(entry), str(dest))
|
||||
files_moved += 1
|
||||
|
||||
logger.info("Copied %d original files to reimport folder: %s", files_moved, reimport_dir)
|
||||
|
||||
# 2. Perform the full reset (wipe DB + other workdir data)
|
||||
reset_result = perform_full_reset(db)
|
||||
|
||||
# 3. Ensure the reimport folder survived the wipe (it's not in _USER_DATA_SUBDIRS)
|
||||
# and set up watch folder config to point at it.
|
||||
_configure_reimport_watch_folder(str(reimport_dir))
|
||||
|
||||
reset_result["reimport"] = {
|
||||
"files_moved": files_moved,
|
||||
"reimport_folder": str(reimport_dir),
|
||||
}
|
||||
logger.warning(">>> SYSTEM RESET with re-import configured — %d files staged <<<", files_moved)
|
||||
return reset_result
|
||||
|
||||
|
||||
def _configure_reimport_watch_folder(reimport_path: str) -> None:
|
||||
"""Append *reimport_path* to the application's watch-folder list.
|
||||
|
||||
The watch-folder scanner uses ``settings.watch_folders`` (a
|
||||
comma-separated string). We mutate the runtime setting so the
|
||||
next scan picks up the folder. We also set
|
||||
``watch_folder_delete_after_process = True`` so files are cleaned
|
||||
up after successful processing.
|
||||
"""
|
||||
current = getattr(settings, "watch_folders", None) or ""
|
||||
folders = [f.strip() for f in current.split(",") if f.strip()]
|
||||
|
||||
if reimport_path not in folders:
|
||||
folders.append(reimport_path)
|
||||
|
||||
# Mutate runtime settings (not persisted to .env — ephemeral)
|
||||
object.__setattr__(settings, "watch_folders", ",".join(folders))
|
||||
object.__setattr__(settings, "watch_folder_delete_after_process", True)
|
||||
logger.info("Configured reimport watch folder: %s", reimport_path)
|
||||
|
||||
|
||||
def perform_startup_reset() -> None:
|
||||
"""Called during application startup when ``FACTORY_RESET_ON_STARTUP=True``.
|
||||
|
||||
Wipes database and filesystem data so the instance starts completely
|
||||
fresh. Uses its own DB session so it runs before the normal lifespan
|
||||
seeding logic.
|
||||
"""
|
||||
from app.database import SessionLocal
|
||||
|
||||
logger.warning("FACTORY_RESET_ON_STARTUP is enabled — wiping all data")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
perform_full_reset(db)
|
||||
except Exception:
|
||||
logger.exception("Factory reset on startup failed")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
+55
-7
@@ -20,13 +20,31 @@ from app.models import FileRecord
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _owner_id_from_user(user: dict) -> str | None:
|
||||
"""Extract the owner identifier from a user dict.
|
||||
|
||||
Priority: ``sub`` (OAuth subject) → ``preferred_username`` → ``email`` → ``id``.
|
||||
"""
|
||||
return user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
|
||||
|
||||
|
||||
def get_current_owner_id(request: Request) -> str | None:
|
||||
"""Extract the owner identifier for the current authenticated user.
|
||||
|
||||
The owner ID is derived from the user's session data. It uses the
|
||||
``sub`` claim (OAuth subject) when available, falling back to
|
||||
``preferred_username`` or ``email``. Returns ``None`` when no user
|
||||
is authenticated.
|
||||
The owner ID is derived from the user's session data or, when no session
|
||||
is present, from a valid Bearer API token in the ``Authorization`` header.
|
||||
This ensures that both browser-based (session cookie) and mobile/API
|
||||
(Bearer token) requests are correctly identified.
|
||||
|
||||
Priority for user resolution:
|
||||
|
||||
1. Session ``user`` dict (set by OAuth or local login).
|
||||
2. ``request.state.api_token_user`` (set by ``require_login`` or an
|
||||
earlier call to this function during the same request).
|
||||
3. Direct Bearer token look-up against the database.
|
||||
|
||||
Within the resolved user dict the owner ID is chosen as:
|
||||
``sub`` → ``preferred_username`` → ``email`` → ``id``.
|
||||
|
||||
Args:
|
||||
request: The current FastAPI request with session data.
|
||||
@@ -34,11 +52,41 @@ def get_current_owner_id(request: Request) -> str | None:
|
||||
Returns:
|
||||
A stable string identifier for the user, or ``None``.
|
||||
"""
|
||||
# 1. Session-based auth (most common for web UI)
|
||||
user = request.session.get("user")
|
||||
if not user or not isinstance(user, dict):
|
||||
if user and isinstance(user, dict):
|
||||
return _owner_id_from_user(user)
|
||||
|
||||
# 2. Already-resolved API token user (cached by require_login or a
|
||||
# prior dependency call during this request)
|
||||
api_user = getattr(request.state, "api_token_user", None)
|
||||
if isinstance(api_user, dict):
|
||||
return _owner_id_from_user(api_user)
|
||||
|
||||
# 3. Direct Bearer token resolution – necessary when this function is
|
||||
# invoked as a FastAPI dependency (via Depends) which runs *before*
|
||||
# the @require_login decorator wrapper has had a chance to resolve
|
||||
# the token and populate request.state.api_token_user.
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if isinstance(auth_header, str) and auth_header.startswith("Bearer "):
|
||||
try:
|
||||
from app.auth import _resolve_bearer_user
|
||||
from app.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
resolved = _resolve_bearer_user(request, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if resolved:
|
||||
# Cache so subsequent calls (and require_login) skip the DB
|
||||
request.state.api_token_user = resolved
|
||||
return _owner_id_from_user(resolved)
|
||||
except Exception:
|
||||
logger.debug("Bearer token resolution failed in get_current_owner_id", exc_info=True)
|
||||
|
||||
return None
|
||||
# Prefer 'sub' (OAuth subject), then 'preferred_username', then 'email', then 'id'
|
||||
return user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
|
||||
|
||||
|
||||
def apply_owner_filter(query: Query, request: Request) -> Query:
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.views.audit_logs import router as audit_logs_router
|
||||
from app.views.backup import router as backup_router
|
||||
from app.views.compliance import router as compliance_router
|
||||
from app.views.db_wizard import router as db_wizard_router
|
||||
from app.views.devices import router as devices_router # Mobile devices dashboard
|
||||
from app.views.dropbox import router as dropbox_router
|
||||
from app.views.filemanager import router as filemanager_router
|
||||
|
||||
@@ -26,6 +27,7 @@ from app.views.onedrive import router as onedrive_router
|
||||
from app.views.pipelines import router as pipelines_router # Processing pipelines
|
||||
from app.views.plans import router as plans_router # Admin Plan Designer
|
||||
from app.views.profile import router as profile_router # User self-service profile
|
||||
from app.views.qr_login import router as qr_login_router # QR code mobile login
|
||||
from app.views.queue import router as queue_router
|
||||
from app.views.scheduled_jobs import router as scheduled_jobs_router # Scheduled batch jobs
|
||||
from app.views.search import router as search_router
|
||||
@@ -34,6 +36,7 @@ from app.views.share import router as share_router
|
||||
from app.views.shared_links import router as shared_links_router
|
||||
from app.views.status import router as status_router
|
||||
from app.views.subscriptions import router as subscriptions_router # Pricing + subscription pages
|
||||
from app.views.system_reset import router as system_reset_router # System reset / factory reset
|
||||
from app.views.wizard import router as wizard_router
|
||||
|
||||
# Create a main router that includes all the view routers
|
||||
@@ -60,6 +63,7 @@ router.include_router(plans_router) # Admin Plan Designer
|
||||
router.include_router(onboarding_router) # User onboarding wizard
|
||||
router.include_router(pipelines_router) # Processing pipelines
|
||||
router.include_router(profile_router) # User self-service profile settings
|
||||
router.include_router(qr_login_router) # QR code mobile login page
|
||||
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
|
||||
router.include_router(integrations_router) # Unified integrations dashboard
|
||||
router.include_router(notifications_router) # User notification dashboard
|
||||
@@ -67,3 +71,5 @@ router.include_router(scheduled_jobs_router) # Admin scheduled batch jobs
|
||||
router.include_router(audit_logs_router) # Comprehensive audit log viewer
|
||||
router.include_router(help_router) # Built-in help / How-To docs
|
||||
router.include_router(compliance_router) # Compliance templates dashboard
|
||||
router.include_router(devices_router) # Mobile devices dashboard
|
||||
router.include_router(system_reset_router) # System reset / factory reset
|
||||
|
||||
@@ -94,6 +94,7 @@ def _inject_global_context(ctx: dict) -> None:
|
||||
"allow_signup",
|
||||
getattr(settings, "multi_user_enabled", False) and getattr(settings, "allow_local_signup", False),
|
||||
)
|
||||
ctx.setdefault("enable_factory_reset", getattr(settings, "enable_factory_reset", False))
|
||||
|
||||
req = ctx.get("request")
|
||||
if req is not None:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""View route for the Devices management page.
|
||||
|
||||
Renders the ``devices.html`` template where users can see their registered
|
||||
mobile devices, mobile API tokens (created via the mobile SSO flow or QR
|
||||
code login), and revoke access per-device.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.views.base import require_login, templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/devices", include_in_schema=False)
|
||||
@require_login
|
||||
async def devices_page(request: Request):
|
||||
"""Render the Devices management page."""
|
||||
return templates.TemplateResponse(
|
||||
"devices.html",
|
||||
{"request": request, "page_title": "Devices"},
|
||||
)
|
||||
@@ -832,6 +832,34 @@ def get_processed_text(request: Request, file_id: int, db: Session = Depends(get
|
||||
)
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/text/default-language")
|
||||
@require_login
|
||||
def get_default_language_text(request: Request, file_id: int, db: Session = Depends(get_db)):
|
||||
"""Return the persisted default-language translation for the file view."""
|
||||
from fastapi import status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.models import FileRecord
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=_FILE_NOT_FOUND)
|
||||
|
||||
if not file_record.default_language_text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No default-language translation available",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"text": file_record.default_language_text,
|
||||
"language_code": file_record.default_language_code,
|
||||
"detected_language": file_record.detected_language,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/duplicates")
|
||||
@require_login
|
||||
def duplicates_page(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""View route for the QR code mobile login page.
|
||||
|
||||
Route:
|
||||
GET /qr-login — renders the QR login page (requires login)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.views.base import APIRouter, require_login, templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/qr-login", include_in_schema=False)
|
||||
@require_login
|
||||
async def qr_login_page(request: Request):
|
||||
"""Serve the QR code login page for mobile app authentication."""
|
||||
return templates.TemplateResponse(
|
||||
"qr_login.html",
|
||||
{"request": request},
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
System reset view — admin-only UI page.
|
||||
|
||||
Renders a confirmation-heavy page that allows administrators to:
|
||||
1. **Full Reset** — wipe all user data (DB + disk) for a fresh start.
|
||||
2. **Reset & Re-import** — move originals to a reimport folder, wipe,
|
||||
and let the watch-folder mechanism re-ingest them.
|
||||
|
||||
Both options are gated behind the ``ENABLE_FACTORY_RESET`` feature flag.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.views.base import APIRouter, get_db, require_login, templates
|
||||
from app.views.settings import require_admin_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/admin/system-reset")
|
||||
@require_login
|
||||
@require_admin_access
|
||||
async def system_reset_page(request: Request, db: Session = Depends(get_db)) -> Response:
|
||||
"""Render the system reset administration page."""
|
||||
if not settings.enable_factory_reset:
|
||||
return RedirectResponse(url="/settings", status_code=302)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"system_reset.html",
|
||||
{
|
||||
"request": request,
|
||||
"factory_reset_on_startup": settings.factory_reset_on_startup,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
# Mock settings before app imports to bypass validation
|
||||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
os.environ["REDIS_URL"] = "redis://localhost:6379"
|
||||
os.environ["OPENAI_API_KEY"] = "mock_key"
|
||||
os.environ["WORKDIR"] = "/tmp/workdir"
|
||||
os.environ["AZURE_AI_KEY"] = "mock"
|
||||
os.environ["AZURE_REGION"] = "mock"
|
||||
os.environ["AZURE_ENDPOINT"] = "http://mock"
|
||||
os.environ["GOTENBERG_URL"] = "http://mock"
|
||||
os.environ["SESSION_SECRET"] = "mock_secret_mock_secret_mock_secret_mock_secret"
|
||||
|
||||
# Ensure app package is accessible
|
||||
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.database import Base
|
||||
from app.models import FileRecord
|
||||
from app.api.duplicates import list_duplicate_groups
|
||||
|
||||
# Mocking Request object
|
||||
class MockRequest:
|
||||
def __init__(self):
|
||||
self.session = {"user": {"username": "testuser"}}
|
||||
self.state = type('State', (), {'user': {"username": "testuser"}})()
|
||||
|
||||
class MockURL:
|
||||
def include_query_params(self, **kwargs):
|
||||
return f"http://testserver/api/duplicates?page={kwargs.get('page')}"
|
||||
url = MockURL()
|
||||
|
||||
def setup_db():
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
return db
|
||||
|
||||
def populate_data(db, num_groups, duplicates_per_group):
|
||||
for i in range(num_groups):
|
||||
filehash = f"hash_{i}"
|
||||
# Original
|
||||
original = FileRecord(
|
||||
filehash=filehash,
|
||||
local_filename=f"orig_{i}.txt",
|
||||
file_size=100,
|
||||
is_duplicate=False
|
||||
)
|
||||
db.add(original)
|
||||
# Duplicates
|
||||
for j in range(duplicates_per_group):
|
||||
dup = FileRecord(
|
||||
filehash=filehash,
|
||||
local_filename=f"dup_{i}_{j}.txt",
|
||||
file_size=100,
|
||||
is_duplicate=True
|
||||
)
|
||||
db.add(dup)
|
||||
db.commit()
|
||||
|
||||
async def run_benchmark(db):
|
||||
request = MockRequest()
|
||||
start_time = time.time()
|
||||
|
||||
# Run the function we want to benchmark
|
||||
result = list_duplicate_groups(request=request, db=db, page=1, per_page=500)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
end_time = time.time()
|
||||
return end_time - start_time, result
|
||||
|
||||
async def main():
|
||||
db = setup_db()
|
||||
# 500 groups, each with 20 duplicates = 10500 records total
|
||||
print("Populating data...")
|
||||
populate_data(db, 500, 20)
|
||||
print("Data populated. Running baseline benchmark...")
|
||||
|
||||
# Warmup
|
||||
result = list_duplicate_groups(request=MockRequest(), db=db, page=1, per_page=500)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
|
||||
# Benchmark
|
||||
total_time = 0
|
||||
iterations = 10
|
||||
for _ in range(iterations):
|
||||
time_taken, _ = await run_benchmark(db)
|
||||
total_time += time_taken
|
||||
|
||||
avg_time = total_time / iterations
|
||||
print(f"Average time over {iterations} iterations: {avg_time:.4f} seconds")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
import time
|
||||
import pytest
|
||||
from app.database import get_db
|
||||
from app.models import UserNotificationTarget, UserNotificationPreference
|
||||
from app.main import app
|
||||
from tests.test_notifications_api import _make_client, _OWNER, _cleanup
|
||||
import statistics
|
||||
|
||||
def run_benchmark(notif_engine, notif_session, client, items_count, iterations=5):
|
||||
# Setup
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type="webhook",
|
||||
name="My Webhook",
|
||||
config=json.dumps({"url": "https://x.com"}),
|
||||
)
|
||||
notif_session.add(target)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(target)
|
||||
|
||||
# Generate big payload
|
||||
preferences = []
|
||||
for i in range(items_count):
|
||||
preferences.append({
|
||||
"event_type": f"event.type.{i}",
|
||||
"channel_type": "webhook",
|
||||
"is_enabled": True,
|
||||
"target_id": target.id,
|
||||
})
|
||||
|
||||
payload = {"preferences": preferences}
|
||||
|
||||
# Warm up
|
||||
client.put("/api/user-notifications/preferences", json=payload)
|
||||
|
||||
times = []
|
||||
for _ in range(iterations):
|
||||
# Alter the values a bit so it's a real update
|
||||
for p in payload["preferences"]:
|
||||
p["is_enabled"] = not p["is_enabled"]
|
||||
|
||||
start = time.time()
|
||||
resp = client.put("/api/user-notifications/preferences", json=payload)
|
||||
end = time.time()
|
||||
|
||||
assert resp.status_code == 200
|
||||
times.append(end - start)
|
||||
|
||||
return statistics.mean(times)
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
import time
|
||||
import httpx
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from app.api.onedrive import test_onedrive_token
|
||||
from app.config import settings
|
||||
|
||||
settings.onedrive_refresh_token = "dummy"
|
||||
settings.onedrive_client_id = "dummy"
|
||||
settings.onedrive_client_secret = "dummy"
|
||||
|
||||
class DummyRequest:
|
||||
def __init__(self):
|
||||
self.session = {"user": "dummy"}
|
||||
|
||||
async def run_benchmark(func_name, mock_post, mock_get):
|
||||
mock_post_resp = MagicMock()
|
||||
mock_post_resp.status_code = 200
|
||||
mock_post_resp.json.return_value = {
|
||||
"access_token": "dummy_access",
|
||||
"expires_in": 3600
|
||||
}
|
||||
mock_post.return_value = mock_post_resp
|
||||
|
||||
mock_get_resp = MagicMock()
|
||||
mock_get_resp.status_code = 200
|
||||
mock_get_resp.json.return_value = {
|
||||
"displayName": "Test User",
|
||||
"userPrincipalName": "test@example.com"
|
||||
}
|
||||
mock_get.return_value = mock_get_resp
|
||||
|
||||
start_time = time.time()
|
||||
for _ in range(100):
|
||||
await test_onedrive_token(DummyRequest())
|
||||
end_time = time.time()
|
||||
print(f"{func_name} took {end_time - start_time:.4f} seconds")
|
||||
|
||||
async def run_benchmark_async(func_name, mock_post, mock_get):
|
||||
mock_post_resp = MagicMock()
|
||||
mock_post_resp.status_code = 200
|
||||
mock_post_resp.json = MagicMock(return_value={
|
||||
"access_token": "dummy_access",
|
||||
"expires_in": 3600
|
||||
})
|
||||
mock_post.return_value = mock_post_resp
|
||||
|
||||
mock_get_resp = MagicMock()
|
||||
mock_get_resp.status_code = 200
|
||||
mock_get_resp.json = MagicMock(return_value={
|
||||
"displayName": "Test User",
|
||||
"userPrincipalName": "test@example.com"
|
||||
})
|
||||
mock_get.return_value = mock_get_resp
|
||||
|
||||
start_time = time.time()
|
||||
for _ in range(100):
|
||||
await test_onedrive_token(DummyRequest())
|
||||
end_time = time.time()
|
||||
print(f"{func_name} took {end_time - start_time:.4f} seconds")
|
||||
|
||||
|
||||
@patch('app.api.onedrive.requests.get')
|
||||
@patch('app.api.onedrive.requests.post')
|
||||
def benchmark_sync(mock_post, mock_get):
|
||||
asyncio.run(run_benchmark("Sync requests (baseline)", mock_post, mock_get))
|
||||
|
||||
if __name__ == "__main__":
|
||||
benchmark_sync()
|
||||
@@ -0,0 +1,49 @@
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app.api.url_upload import process_url, URLUploadRequest
|
||||
from app.config import settings
|
||||
|
||||
async def main():
|
||||
# Mock request and URLUploadRequest
|
||||
request = Mock()
|
||||
url_request = URLUploadRequest(url="https://example.com/file.pdf")
|
||||
|
||||
# Generate a large chunk
|
||||
large_chunk = b"A" * 8192
|
||||
num_chunks = 10000 # 8192 * 10000 = ~80MB
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf"}
|
||||
mock_response.iter_content = Mock(return_value=[large_chunk] * num_chunks)
|
||||
|
||||
# For async client later
|
||||
class AsyncMockResponse:
|
||||
def __init__(self):
|
||||
self.status_code = 200
|
||||
self.headers = {"Content-Type": "application/pdf"}
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
async def aiter_bytes(self, chunk_size):
|
||||
for _ in range(num_chunks):
|
||||
yield large_chunk
|
||||
|
||||
async_mock_response = AsyncMockResponse()
|
||||
|
||||
# We will mock requests.get for synchronous, httpx.AsyncClient.get for asynchronous
|
||||
|
||||
# Test sync
|
||||
start_time = time.time()
|
||||
with patch("app.api.url_upload.requests.get", return_value=mock_response), \
|
||||
patch("app.api.url_upload.process_document"):
|
||||
try:
|
||||
await process_url(request=request, url_request=url_request)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
end_time = time.time()
|
||||
print(f"Original execution time (sync writing): {end_time - start_time:.4f} seconds")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
import asyncio
|
||||
import time
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest.mock import Mock, patch
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.url_upload import process_url, URLUploadRequest
|
||||
from app.config import settings
|
||||
|
||||
async def main():
|
||||
# Setup test dir
|
||||
test_dir = tempfile.mkdtemp()
|
||||
settings.workdir = test_dir
|
||||
|
||||
# Mock request and URLUploadRequest
|
||||
request = Mock()
|
||||
url_request = URLUploadRequest(url="https://example.com/file.pdf")
|
||||
|
||||
# Generate a large chunk
|
||||
chunk_size = 8192
|
||||
num_chunks = 20000 # 20000 * 8192 = ~160MB
|
||||
large_chunk = b"A" * chunk_size
|
||||
|
||||
class SyncMockResponse:
|
||||
def __init__(self):
|
||||
self.status_code = 200
|
||||
self.headers = {"Content-Type": "application/pdf"}
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def iter_content(self, chunk_size):
|
||||
for _ in range(num_chunks):
|
||||
# sleep slightly to simulate network latency, otherwise OS file cache obscures the difference
|
||||
time.sleep(0.0001)
|
||||
yield large_chunk
|
||||
|
||||
sync_mock_response = SyncMockResponse()
|
||||
|
||||
class AsyncMockResponse:
|
||||
def __init__(self):
|
||||
self.status_code = 200
|
||||
self.headers = {"Content-Type": "application/pdf"}
|
||||
self.is_success = True
|
||||
self.status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
async def aiter_bytes(self, chunk_size=8192):
|
||||
for _ in range(num_chunks):
|
||||
await asyncio.sleep(0.0001)
|
||||
yield large_chunk
|
||||
|
||||
class AsyncMockContext:
|
||||
async def __aenter__(self):
|
||||
return AsyncMockResponse()
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
async_mock_response = AsyncMockResponse()
|
||||
|
||||
# Test sync
|
||||
start_time = time.time()
|
||||
with patch("app.api.url_upload.requests.get", return_value=sync_mock_response), \
|
||||
patch("app.api.url_upload.process_document"):
|
||||
try:
|
||||
await process_url(request=request, url_request=url_request)
|
||||
except Exception as e:
|
||||
print(f"Error (sync): {e}")
|
||||
end_time = time.time()
|
||||
print(f"Original execution time (sync writing): {end_time - start_time:.4f} seconds")
|
||||
|
||||
shutil.rmtree(test_dir)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+61
@@ -2405,3 +2405,64 @@ query GetDocument($id: Int!) {
|
||||
}
|
||||
```
|
||||
Variables: `{ "id": 42 }`
|
||||
|
||||
## System Reset
|
||||
|
||||
Admin-only endpoints for resetting the system to a clean state. Requires `ENABLE_FACTORY_RESET=True`.
|
||||
|
||||
### GET /api/admin/system-reset/status
|
||||
|
||||
Check whether the system reset feature is enabled.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"factory_reset_on_startup": false
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/admin/system-reset/full
|
||||
|
||||
Wipe all user data (database + work-files).
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"confirmation": "DELETE"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"database": { "files": 42, "processing_logs": 100 },
|
||||
"filesystem": { "deleted_dirs": 5, "deleted_files": 12 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/admin/system-reset/reimport
|
||||
|
||||
Move original files to a reimport folder, wipe everything, and configure the reimport folder as a watch folder for re-ingestion.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"confirmation": "REIMPORT"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"database": { "files": 42 },
|
||||
"filesystem": { "deleted_dirs": 5, "deleted_files": 12 },
|
||||
"reimport": { "files_moved": 42, "reimport_folder": "/workdir/reimport" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -154,6 +154,62 @@ DocuElevate can work with any OpenID Connect-compliant provider, not just Authen
|
||||
OAUTH_PROVIDER_NAME=Auth0
|
||||
```
|
||||
|
||||
## Server-Side Session Management
|
||||
|
||||
DocuElevate supports server-side session tracking. Every login creates a `UserSession` record that can be listed and revoked individually or all at once ("log off everywhere").
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `SESSION_LIFETIME_DAYS` | Number of days before a session expires | `30` |
|
||||
| `SESSION_LIFETIME_CUSTOM_DAYS` | Override for `SESSION_LIFETIME_DAYS` when set | — |
|
||||
|
||||
### Managing Sessions
|
||||
|
||||
Users can manage their active sessions from the **Profile → Security** section:
|
||||
|
||||
- **View active sessions** — see browser, device, IP address, and last activity for each session.
|
||||
- **Revoke a single session** — immediately invalidate one session.
|
||||
- **Log off everywhere** — revoke all sessions (optionally keeping the current one) and all API tokens at once.
|
||||
|
||||
Expired sessions are automatically cleaned up by a periodic background task.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/sessions` | List the current user's active sessions |
|
||||
| `DELETE` | `/api/sessions/{id}` | Revoke a single session |
|
||||
| `POST` | `/api/sessions/revoke-all` | Revoke all sessions for the current user |
|
||||
|
||||
## QR Code Login
|
||||
|
||||
QR code login allows users to authenticate a mobile device by scanning a QR code displayed in the web UI, without manually entering credentials on the phone.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. The authenticated web user opens the **QR Login** page and a challenge QR code is displayed.
|
||||
2. The user opens the DocuElevate mobile app and taps **Scan QR Code to Login**, which opens the device camera.
|
||||
3. The mobile app scans the QR code. The QR code contains both the challenge token and the server URL (`docuelevate://qr-login?token=...&server=...`), so there is no need to enter the server URL manually.
|
||||
4. An API token is issued for the mobile device and the web UI is notified via polling.
|
||||
|
||||
> **Note:** The countdown timer on the web page uses server-relative time (TTL in seconds) rather than absolute timestamps, so it works correctly even when the client's clock is not in sync with the server.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `QR_LOGIN_CHALLENGE_TTL_SECONDS` | How long a QR challenge is valid (seconds) | `120` |
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/qr-auth/challenge` | Create a new QR login challenge (returns `ttl_seconds` for client countdown) |
|
||||
| `GET` | `/api/qr-auth/challenge/{id}/status` | Poll the status of a challenge |
|
||||
| `POST` | `/api/qr-auth/claim` | Claim a challenge from a mobile device |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Always use HTTPS** in production to protect authentication tokens and passwords
|
||||
|
||||
@@ -17,6 +17,8 @@ Configuration is primarily done through environment variables specified in a `.e
|
||||
| `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docuelevate.example.com` |
|
||||
| `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` |
|
||||
| `COMPLIANCE_ENABLED` | Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). | `true` |
|
||||
| `FACTORY_RESET_ON_STARTUP` | Wipe all user data on every startup (demo/testing). | `false` |
|
||||
| `ENABLE_FACTORY_RESET` | Show the System Reset page in the admin UI. | `false` |
|
||||
|
||||
### Batch Processing Settings
|
||||
|
||||
@@ -364,6 +366,9 @@ Credentials are encrypted at rest using Fernet encryption.
|
||||
|-------------------------|---------------------------------------------------------------|
|
||||
| `AUTH_ENABLED` | Enable or disable authentication (`true`/`false`). |
|
||||
| `SESSION_SECRET` | Secret key used to encrypt sessions and cookies (at least 32 chars). |
|
||||
| `SESSION_LIFETIME_DAYS` | Number of days before a server-side session expires. Default: `30`. |
|
||||
| `SESSION_LIFETIME_CUSTOM_DAYS` | Override for `SESSION_LIFETIME_DAYS` when set. |
|
||||
| `QR_LOGIN_CHALLENGE_TTL_SECONDS` | How long a QR login challenge is valid (seconds). Default: `120`. |
|
||||
| `ADMIN_USERNAME` | Username for basic authentication (when not using OIDC). |
|
||||
| `ADMIN_PASSWORD` | Password for basic authentication (when not using OIDC). |
|
||||
| `ADMIN_GROUP_NAME` | Group name in OIDC claims that grants admin access. Default: `admin`. |
|
||||
@@ -457,6 +462,87 @@ default overage buffer applied across all plans.
|
||||
|
||||
DocuElevate supports HTTP security headers to improve browser-side security. **These headers are disabled by default** since most deployments use a reverse proxy (Traefik, Nginx, etc.) that already adds them. Enable only if deploying directly without a reverse proxy. See [Deployment Guide - Security Headers](DeploymentGuide.md#security-headers) for detailed configuration examples.
|
||||
|
||||
### Application Logging
|
||||
|
||||
DocuElevate uses Python's standard `logging` module. Two environment variables control log verbosity:
|
||||
|
||||
| **Variable** | **Description** | **Default** |
|
||||
|-------------|----------------|-------------|
|
||||
| `LOG_LEVEL` | Root logger level. Accepts standard Python level names: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. | `INFO` |
|
||||
| `DEBUG` | Enable debug mode. When `true` **and** `LOG_LEVEL` is **not** explicitly set, the effective log level is automatically lowered to `DEBUG`. | `false` |
|
||||
|
||||
**Precedence rules (standard behaviour):**
|
||||
|
||||
1. If `LOG_LEVEL` is explicitly set, it always wins — regardless of `DEBUG`.
|
||||
2. If only `DEBUG=true` is set (no `LOG_LEVEL`), the effective level becomes `DEBUG`.
|
||||
3. If neither is set, the default level is `INFO`.
|
||||
|
||||
```bash
|
||||
# Typical production (default)
|
||||
# LOG_LEVEL=INFO
|
||||
|
||||
# Quick debug mode — sets level to DEBUG automatically
|
||||
DEBUG=true
|
||||
|
||||
# Explicit level override (DEBUG flag is ignored for level selection)
|
||||
LOG_LEVEL=WARNING
|
||||
```
|
||||
|
||||
> **Tip:** At `DEBUG` level, noisy third-party libraries (httpx, authlib, urllib3, etc.) are automatically pinned to `WARNING` so that application debug output remains readable.
|
||||
|
||||
#### Structured JSON Logging
|
||||
|
||||
Set `LOG_FORMAT=json` to emit structured JSON lines on stdout — one JSON object per log message. This is the standard format for log collectors and SIEM tools:
|
||||
|
||||
| **Variable** | **Description** | **Default** |
|
||||
|-------------|----------------|-------------|
|
||||
| `LOG_FORMAT` | Log output format: `text` (human-readable) or `json` (structured JSON lines). | `text` |
|
||||
|
||||
Each JSON log line contains: `timestamp` (ISO 8601), `level`, `logger`, `message`, `module`, `funcName`, `lineno`, and `exc_info` (when an exception is logged).
|
||||
|
||||
```bash
|
||||
# Enable JSON logging for SIEM / log aggregation
|
||||
LOG_FORMAT=json
|
||||
```
|
||||
|
||||
**Example JSON output:**
|
||||
```json
|
||||
{"timestamp": "2025-03-16T09:18:05.192000+00:00", "level": "INFO", "logger": "app.auth", "message": "[SECURITY] OAUTH_LOGIN_SUCCESS user=alice@example.com admin=False", "module": "auth", "funcName": "oauth_callback", "lineno": 654}
|
||||
```
|
||||
|
||||
**Compatible with:**
|
||||
- **Grafana Loki** — Promtail scrapes JSON from Docker stdout
|
||||
- **Splunk** — Universal Forwarder or HEC with JSON sourcetype
|
||||
- **ELK / OpenSearch** — Filebeat with JSON codec
|
||||
- **Datadog** — Agent auto-parses JSON logs
|
||||
- **Fluentd / Vector** — JSON input plugin
|
||||
- **Docker log drivers** — `--log-driver=json-file` (default) preserves structure
|
||||
|
||||
#### Syslog Forwarding (Application Logs)
|
||||
|
||||
For traditional (non-container) deployments, application logs can be forwarded directly to a syslog receiver. This is **separate** from audit-log SIEM forwarding (see below) — it sends _every_ Python log message, not just audit events.
|
||||
|
||||
| **Variable** | **Description** | **Default** |
|
||||
|-------------|----------------|-------------|
|
||||
| `LOG_SYSLOG_ENABLED` | Forward application logs to a syslog receiver in addition to stdout. | `false` |
|
||||
| `LOG_SYSLOG_HOST` | Hostname or IP of the syslog receiver. | `localhost` |
|
||||
| `LOG_SYSLOG_PORT` | Port of the syslog receiver. | `514` |
|
||||
| `LOG_SYSLOG_PROTOCOL` | Protocol: `udp` or `tcp`. | `udp` |
|
||||
|
||||
```bash
|
||||
# Forward all application logs to syslog
|
||||
LOG_SYSLOG_ENABLED=true
|
||||
LOG_SYSLOG_HOST=syslog.internal.example.com
|
||||
LOG_SYSLOG_PORT=514
|
||||
LOG_SYSLOG_PROTOCOL=udp
|
||||
|
||||
# Combine with JSON format for structured syslog messages
|
||||
LOG_FORMAT=json
|
||||
LOG_SYSLOG_ENABLED=true
|
||||
```
|
||||
|
||||
> **Note:** When `LOG_FORMAT=json`, syslog messages are also sent as JSON. When `LOG_FORMAT=text`, syslog messages use the standard `name - level - message` format.
|
||||
|
||||
### Audit Logging
|
||||
|
||||
DocuElevate provides comprehensive audit logging that records significant actions (logins, document CRUD, settings changes) to an append-only database table. Every entry captures the timestamp, user, action, resource, client IP, and optional JSON details.
|
||||
@@ -853,6 +939,48 @@ OPENAI_API_KEY=sk-ant-... # passed as the api_key to LiteLLM
|
||||
|
||||
---
|
||||
|
||||
### Document Translation
|
||||
|
||||
After processing, DocuElevate can automatically translate a document's extracted text into a configurable *default language* (e.g. English). This reference translation is stored alongside the original text so users always have a version in a language they understand.
|
||||
|
||||
Other languages are translated **on the fly** via the AI provider and are not persisted.
|
||||
|
||||
#### Settings
|
||||
|
||||
| **Variable** | **Description** | **Default** |
|
||||
|------------------------------|-----------------------------------------------------------------------------------------------------------|-------------|
|
||||
| `DEFAULT_DOCUMENT_LANGUAGE` | ISO 639-1 code for the default translation target (e.g. `en`, `de`, `fr`). Documents whose detected language differs are automatically translated into this language after processing. | `en` |
|
||||
|
||||
Each user can override this global default in their profile (`UserProfile.default_document_language`).
|
||||
|
||||
#### How It Works
|
||||
|
||||
1. During metadata extraction the AI detects the document language (stored as `detected_language` on the file record).
|
||||
2. If the detected language differs from the default target language, a background Celery task (`translate_to_default_language`) translates the extracted text.
|
||||
3. The translated text is persisted in `default_language_text` and the target code in `default_language_code`.
|
||||
4. The file detail view shows both the original text and the default-language version.
|
||||
5. Users can also request on-the-fly translations to any language via the **Translate** dropdown.
|
||||
|
||||
#### API Endpoints
|
||||
|
||||
| **Endpoint** | **Method** | **Description** |
|
||||
|-----------------------------------------------|------------|------------------------------------------------------------------------|
|
||||
| `/api/files/{id}/translation/default` | GET | Returns the persisted default-language translation (404 if unavailable)|
|
||||
| `/api/files/{id}/translate?lang=xx` | GET | On-the-fly translation to any ISO 639-1 language code |
|
||||
| `/files/{id}/text/default-language` | GET | View endpoint returning the default-language text as JSON |
|
||||
|
||||
#### Example
|
||||
|
||||
```bash
|
||||
# Get the stored English translation of a German document
|
||||
curl http://localhost:8000/api/files/42/translation/default
|
||||
|
||||
# Translate on the fly to French
|
||||
curl "http://localhost:8000/api/files/42/translate?lang=fr"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### OCR Providers
|
||||
|
||||
DocuElevate supports multiple OCR engines that can be used individually or in combination. Configure the list of active providers with `OCR_PROVIDERS` and tune each provider with the settings below.
|
||||
@@ -1161,6 +1289,20 @@ For detailed setup instructions, see the [Google Drive Setup Guide](GoogleDriveS
|
||||
|
||||
For detailed setup instructions, see the [OneDrive Setup Guide](OneDriveSetup.md).
|
||||
|
||||
### SharePoint Online
|
||||
|
||||
| **Variable** | **Description** |
|
||||
|---------------------------------|-------------------------------------------------------|
|
||||
| `SHAREPOINT_CLIENT_ID` | Azure AD application client ID |
|
||||
| `SHAREPOINT_CLIENT_SECRET` | Azure AD application client secret |
|
||||
| `SHAREPOINT_TENANT_ID` | Azure AD tenant ID (use "common" for multi-tenant apps) |
|
||||
| `SHAREPOINT_REFRESH_TOKEN` | OAuth 2.0 refresh token |
|
||||
| `SHAREPOINT_SITE_URL` | SharePoint site URL (e.g. `https://tenant.sharepoint.com/sites/sitename`) |
|
||||
| `SHAREPOINT_DOCUMENT_LIBRARY` | Document library name (default: `Documents`) |
|
||||
| `SHAREPOINT_FOLDER_PATH` | Subfolder path inside the document library |
|
||||
|
||||
SharePoint uses the same Microsoft Graph API as OneDrive. See the [OneDrive Setup Guide](OneDriveSetup.md) for Azure AD app registration instructions — the same app registration can be reused for SharePoint with the `Sites.ReadWrite.All` permission.
|
||||
|
||||
### Amazon S3
|
||||
|
||||
| **Variable** | **Description** |
|
||||
@@ -1500,6 +1642,7 @@ For example:
|
||||
| S3 | `docs/uploads/` | `docs/uploads/pdfa/` |
|
||||
| Nextcloud | `/Files` | `/Files/pdfa` |
|
||||
| OneDrive | `Documents/Uploads` | `Documents/Uploads/pdfa` |
|
||||
| SharePoint | `Uploads` | `Uploads/pdfa` |
|
||||
| Google Drive | *(folder ID)* | `GOOGLE_DRIVE_PDFA_FOLDER_ID` |
|
||||
|
||||
Set `PDFA_UPLOAD_FOLDER` to an empty string to upload PDF/A files into the
|
||||
@@ -1706,6 +1849,15 @@ ONEDRIVE_TENANT_ID=common
|
||||
ONEDRIVE_REFRESH_TOKEN=your_refresh_token
|
||||
ONEDRIVE_FOLDER_PATH=Documents/Uploads
|
||||
|
||||
# SharePoint Online
|
||||
SHAREPOINT_CLIENT_ID=12345678-1234-1234-1234-123456789012
|
||||
SHAREPOINT_CLIENT_SECRET=your_client_secret
|
||||
SHAREPOINT_TENANT_ID=your-tenant-id
|
||||
SHAREPOINT_REFRESH_TOKEN=your_refresh_token
|
||||
SHAREPOINT_SITE_URL=https://tenant.sharepoint.com/sites/sitename
|
||||
SHAREPOINT_DOCUMENT_LIBRARY=Documents
|
||||
SHAREPOINT_FOLDER_PATH=Uploads
|
||||
|
||||
# Amazon S3
|
||||
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
@@ -1733,6 +1885,52 @@ BACKUP_RETAIN_WEEKLY=13
|
||||
|
||||
You can choose which document storage services to use by only including the relevant environment variables. For example, if you only want to use Dropbox, include only the Dropbox variables and omit the Paperless NGX and Nextcloud variables.
|
||||
|
||||
## System Reset / Factory Reset
|
||||
|
||||
DocuElevate provides two mechanisms for resetting the system to a clean state. Both are **disabled by default** and must be explicitly enabled.
|
||||
|
||||
### Automatic Reset on Startup
|
||||
|
||||
Set `FACTORY_RESET_ON_STARTUP=true` to wipe all user data (database rows and work-files) every time the application starts. This is useful for demo, testing, or ephemeral environments where you always want a fresh instance.
|
||||
|
||||
```dotenv
|
||||
FACTORY_RESET_ON_STARTUP=true
|
||||
```
|
||||
|
||||
> **Warning:** This destroys all documents, processing history, audit logs, and backups on every restart. Application settings and configuration are preserved.
|
||||
|
||||
### Admin UI Reset Page
|
||||
|
||||
Set `ENABLE_FACTORY_RESET=true` to display the **System Reset** page in the admin navigation menu. From this page, administrators can:
|
||||
|
||||
| Action | Confirmation | Description |
|
||||
|--------|-------------|-------------|
|
||||
| **Full Reset** | Type `DELETE` | Wipes all database rows and work-files. The system returns to its initial state. |
|
||||
| **Reset & Re-import** | Type `REIMPORT` | Copies original files to a `reimport/` folder inside the workdir, wipes everything, then configures the reimport folder as a watch folder so files are automatically re-ingested with the same processing pipeline, rate limits, and backoff strategy as regular uploads. |
|
||||
|
||||
```dotenv
|
||||
ENABLE_FACTORY_RESET=true
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
When `ENABLE_FACTORY_RESET=true`, two admin-only API endpoints are available:
|
||||
|
||||
- `POST /api/admin/system-reset/full` — body: `{"confirmation": "DELETE"}`
|
||||
- `POST /api/admin/system-reset/reimport` — body: `{"confirmation": "REIMPORT"}`
|
||||
- `GET /api/admin/system-reset/status` — returns current feature-flag state
|
||||
|
||||
### What Gets Deleted
|
||||
|
||||
| Deleted | Preserved |
|
||||
|---------|-----------|
|
||||
| All document records (`files` table) | Application settings (`application_settings` table) |
|
||||
| Processing logs and steps | User accounts and profiles |
|
||||
| Audit logs | Subscription plans |
|
||||
| Backup records | Pipelines and scheduled jobs |
|
||||
| Original, processed, and temporary files | The workdir directory itself |
|
||||
| Watch-folder caches and ingestion state | OAuth and integration configuration |
|
||||
|
||||
## Configuration File Location
|
||||
|
||||
The `.env` file should be placed at the root of the project directory. When using Docker Compose, you can reference it with the `env_file` directive in your `docker-compose.yml`.
|
||||
|
||||
@@ -11,7 +11,7 @@ Credentials fall into two categories:
|
||||
| Category | Examples |
|
||||
|---|---|
|
||||
| **API keys** | OpenAI API key, Azure AI key, Paperless-ngx API token, AWS access keys |
|
||||
| **OAuth tokens / secrets** | Dropbox, Google Drive, OneDrive, Authentik client secrets and refresh tokens |
|
||||
| **OAuth tokens / secrets** | Dropbox, Google Drive, OneDrive, SharePoint, Authentik client secrets and refresh tokens |
|
||||
| **Passwords** | Admin password, Nextcloud, Email (SMTP), IMAP, FTP, SFTP, WebDAV |
|
||||
| **Private keys** | SFTP private key and passphrase |
|
||||
|
||||
@@ -119,6 +119,15 @@ For service-account credentials (`google_drive_credentials_json`):
|
||||
4. Re-authorize via the OAuth flow to get a fresh `onedrive_refresh_token`.
|
||||
5. Delete the old client secret in Azure.
|
||||
|
||||
### SharePoint (Microsoft OAuth)
|
||||
|
||||
1. SharePoint uses the same Azure AD app registration as OneDrive.
|
||||
2. In **Azure App Registrations**, navigate to **Certificates & secrets** for your app.
|
||||
3. Add a new client secret.
|
||||
4. Update `sharepoint_client_secret` in DocuElevate.
|
||||
5. Re-authorize via the OAuth flow to get a fresh `sharepoint_refresh_token`.
|
||||
6. Delete the old client secret in Azure.
|
||||
|
||||
### Authentik (OIDC)
|
||||
|
||||
1. In your Authentik admin panel, navigate to the DocuElevate application and regenerate the client secret.
|
||||
|
||||
@@ -287,6 +287,27 @@ alembic revision --autogenerate -m "describe your change"
|
||||
|
||||
Review the generated file in `migrations/versions/` before applying it.
|
||||
|
||||
> **Tip:** For detailed guidance on naming conventions, idempotent patterns, parallel-branch workflows, and resolving merge conflicts, see the [Migration Workflow Guide](MigrationWorkflow.md).
|
||||
|
||||
### Validating the Migration Chain
|
||||
|
||||
A CI check and pre-commit hook validate that the migration chain has no broken
|
||||
references, duplicate revisions, or diverged heads. Run the check locally:
|
||||
|
||||
```bash
|
||||
python scripts/check_alembic_migrations.py
|
||||
python scripts/check_alembic_migrations.py --verbose # extra detail
|
||||
```
|
||||
|
||||
If you see **"Multiple migration heads detected"**, two branches added
|
||||
migrations from the same parent. Create a merge migration:
|
||||
|
||||
```bash
|
||||
alembic merge heads -m "merge_parallel_branches"
|
||||
```
|
||||
|
||||
For a complete walk-through, see the [Migration Workflow Guide](MigrationWorkflow.md).
|
||||
|
||||
### Automating Migrations in Docker Compose
|
||||
|
||||
Add a short-lived `migrate` service that runs before the API and Worker:
|
||||
|
||||
@@ -19,7 +19,7 @@ This guide covers all supported deployment methods for DocuElevate.
|
||||
- Access to required external services (if configured):
|
||||
- AI provider API key (OpenAI, Anthropic, Gemini, or other configured provider)
|
||||
- Azure Document Intelligence
|
||||
- Dropbox, Google Drive, OneDrive, S3, or other storage APIs
|
||||
- Dropbox, Google Drive, OneDrive, SharePoint, S3, or other storage APIs
|
||||
- SMTP / IMAP server (for email processing)
|
||||
- Notification services (Discord, Telegram, etc.)
|
||||
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
# Migration Workflow
|
||||
|
||||
This guide explains how to create, test, and merge Alembic database migrations in DocuElevate — especially when **multiple feature branches** add migrations in parallel.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Reference](#quick-reference)
|
||||
- [Creating a New Migration](#creating-a-new-migration)
|
||||
- [Migration Naming Convention](#migration-naming-convention)
|
||||
- [Idempotent Migration Patterns](#idempotent-migration-patterns)
|
||||
- [Parallel Branch Development](#parallel-branch-development)
|
||||
- [Resolving Migration Conflicts](#resolving-migration-conflicts)
|
||||
- [CI Validation](#ci-validation)
|
||||
- [Pre-commit Hook](#pre-commit-hook)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Create a new migration after editing app/models.py
|
||||
alembic revision --autogenerate -m "add_foobar_column"
|
||||
|
||||
# Apply all pending migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Check current database version
|
||||
alembic current
|
||||
|
||||
# View migration history
|
||||
alembic history --verbose
|
||||
|
||||
# Detect multiple heads (diverged branches)
|
||||
alembic heads
|
||||
|
||||
# Create a merge migration to resolve multiple heads
|
||||
alembic merge heads -m "merge_parallel_branches"
|
||||
|
||||
# Validate migration chain integrity (CI script)
|
||||
python scripts/check_alembic_migrations.py
|
||||
python scripts/check_alembic_migrations.py --verbose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating a New Migration
|
||||
|
||||
1. **Edit `app/models.py`** — add or modify SQLAlchemy model classes.
|
||||
|
||||
2. **Generate the migration** from the repo root. Use `--rev-id` to set the
|
||||
revision identifier directly (avoids renaming afterwards):
|
||||
|
||||
```bash
|
||||
alembic revision --autogenerate --rev-id 037_add_my_new_table -m "add my new table"
|
||||
```
|
||||
|
||||
This creates `migrations/versions/037_add_my_new_table_add_my_new_table.py`
|
||||
with `revision = "037_add_my_new_table"`. Rename the file to match:
|
||||
|
||||
```bash
|
||||
mv migrations/versions/037_add_my_new_table_add_my_new_table.py \
|
||||
migrations/versions/037_add_my_new_table.py
|
||||
```
|
||||
|
||||
Alternatively, generate with the default hash and then rename:
|
||||
|
||||
```bash
|
||||
alembic revision --autogenerate -m "add_my_new_table"
|
||||
# Rename: mv migrations/versions/<hash>_add_my_new_table.py migrations/versions/037_add_my_new_table.py
|
||||
# Update revision inside the file to match the filename stem.
|
||||
```
|
||||
|
||||
Alembic uses the `migrations/script.py.mako` template to generate the file. The template includes inline comments about idempotent patterns — read them.
|
||||
|
||||
3. **Review the generated code** — autogenerate is helpful but not perfect. Check:
|
||||
- Are new tables and columns detected correctly?
|
||||
- Does the `downgrade()` reverse all changes?
|
||||
- Are SQLite-incompatible operations wrapped in `batch_alter_table()`?
|
||||
|
||||
4. **Test the migration** against a fresh database:
|
||||
|
||||
```bash
|
||||
# Apply
|
||||
alembic upgrade head
|
||||
|
||||
# Rollback
|
||||
alembic downgrade -1
|
||||
|
||||
# Re-apply
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
5. **Run the chain validation**:
|
||||
|
||||
```bash
|
||||
python scripts/check_alembic_migrations.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Naming Convention
|
||||
|
||||
All migration files follow a **sequential numeric prefix** scheme:
|
||||
|
||||
```
|
||||
NNN_short_description.py
|
||||
```
|
||||
|
||||
| Component | Rule |
|
||||
|-----------|------|
|
||||
| `NNN` | Three-digit zero-padded number, incrementing from the previous migration |
|
||||
| `short_description` | Lowercase snake_case summary of the change |
|
||||
|
||||
The **`revision`** variable inside the file **must match the filename stem** exactly:
|
||||
|
||||
```python
|
||||
# File: migrations/versions/037_add_classification_rules.py
|
||||
revision: str = "037_add_classification_rules"
|
||||
down_revision: Union[str, None] = "036_add_document_translation_fields"
|
||||
```
|
||||
|
||||
The CI check (`scripts/check_alembic_migrations.py`) enforces this consistency.
|
||||
|
||||
---
|
||||
|
||||
## Idempotent Migration Patterns
|
||||
|
||||
Migrations should be **idempotent** — safe to run even if the change already exists. This is critical for SQLite compatibility and for recovering from partial failures.
|
||||
|
||||
### Add a Column (only if missing)
|
||||
|
||||
```python
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if "my_table" in inspector.get_table_names():
|
||||
existing = {c["name"] for c in inspector.get_columns("my_table")}
|
||||
if "new_col" not in existing:
|
||||
with op.batch_alter_table("my_table") as batch_op:
|
||||
batch_op.add_column(sa.Column("new_col", sa.String(128), nullable=True))
|
||||
```
|
||||
|
||||
### Create a Table (only if missing)
|
||||
|
||||
```python
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if "new_table" not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
"new_table",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
)
|
||||
```
|
||||
|
||||
### Drop a Column (only if present)
|
||||
|
||||
```python
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if "my_table" in inspector.get_table_names():
|
||||
existing = {c["name"] for c in inspector.get_columns("my_table")}
|
||||
if "new_col" in existing:
|
||||
with op.batch_alter_table("my_table") as batch_op:
|
||||
batch_op.drop_column("new_col")
|
||||
```
|
||||
|
||||
### Use `batch_alter_table` for SQLite
|
||||
|
||||
SQLite does not support `ALTER TABLE DROP COLUMN` or `ALTER TABLE RENAME COLUMN` natively. Alembic's `batch_alter_table` context manager works around this by recreating the table:
|
||||
|
||||
```python
|
||||
with op.batch_alter_table("users") as batch_op:
|
||||
batch_op.add_column(sa.Column("phone", sa.String(20), nullable=True))
|
||||
batch_op.drop_column("fax")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Branch Development
|
||||
|
||||
When two feature branches both add migrations from the same parent, the migration chain **diverges** into multiple heads. This is normal and expected — Alembic supports it — but the heads must be merged before the code reaches `main`.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
main: 001 → 002 → 003
|
||||
↘ Branch A: 004_add_widgets
|
||||
↘ Branch B: 004_add_gadgets ← two heads!
|
||||
```
|
||||
|
||||
### How to Avoid Conflicts
|
||||
|
||||
1. **Coordinate** — if two developers are both adding migrations, assign different sequence numbers (e.g., `037_` and `038_`). Even if both depend on `036_`, different numbers prevent filename collisions.
|
||||
|
||||
2. **Rebase early** — before opening a PR, rebase your branch onto the latest `main`:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
If `main` now has a new migration `037_*`, renumber yours to `038_*` and update `down_revision` to point at `037_*`.
|
||||
|
||||
3. **Check for multiple heads** locally:
|
||||
|
||||
```bash
|
||||
python scripts/check_alembic_migrations.py
|
||||
# or
|
||||
alembic heads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resolving Migration Conflicts
|
||||
|
||||
If your PR's CI check reports **"Multiple migration heads detected"**, follow these steps:
|
||||
|
||||
### Step 1 — Update Your Branch
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git merge origin/main
|
||||
# or
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
### Step 2 — Check Heads
|
||||
|
||||
```bash
|
||||
python scripts/check_alembic_migrations.py --verbose
|
||||
```
|
||||
|
||||
The output lists the conflicting heads.
|
||||
|
||||
### Step 3 — Create a Merge Migration
|
||||
|
||||
```bash
|
||||
alembic merge heads -m "merge_parallel_branches"
|
||||
```
|
||||
|
||||
This generates a new migration with **two parents** (a merge point):
|
||||
|
||||
```python
|
||||
down_revision = ("037_add_widgets", "037_add_gadgets")
|
||||
```
|
||||
|
||||
### Step 4 — Rename and Validate
|
||||
|
||||
Rename the merge migration to the next sequence number:
|
||||
|
||||
```bash
|
||||
mv migrations/versions/<hash>_merge_parallel_branches.py \
|
||||
migrations/versions/038_merge_parallel_branches.py
|
||||
```
|
||||
|
||||
Update the `revision` inside to match, then validate:
|
||||
|
||||
```bash
|
||||
python scripts/check_alembic_migrations.py
|
||||
```
|
||||
|
||||
### Step 5 — Test
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
alembic downgrade -1
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI Validation
|
||||
|
||||
The CI pipeline (`.github/workflows/ci.yml`) includes a **migration-chain** job that runs:
|
||||
|
||||
```bash
|
||||
python scripts/check_alembic_migrations.py
|
||||
```
|
||||
|
||||
This script checks for:
|
||||
|
||||
| Check | Description |
|
||||
|-------|-------------|
|
||||
| Multiple heads | Diverged migration chains that need a merge migration |
|
||||
| Broken references | A `down_revision` that points to a non-existent revision |
|
||||
| Duplicate revisions | Two files declaring the same `revision` identifier |
|
||||
| Filename mismatches | The `revision` variable doesn't match the filename stem |
|
||||
|
||||
The job runs in Stage 1 (fast-fail gates) alongside lint checks. If it fails, the build is blocked until the migration chain is fixed.
|
||||
|
||||
---
|
||||
|
||||
## Pre-commit Hook
|
||||
|
||||
A local pre-commit hook is configured in `.pre-commit-config.yaml` that runs the same check whenever you commit a change to `migrations/versions/`:
|
||||
|
||||
```yaml
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: check-alembic-migrations
|
||||
name: Check Alembic migration chain
|
||||
entry: python scripts/check_alembic_migrations.py
|
||||
language: python
|
||||
pass_filenames: false
|
||||
files: ^migrations/versions/.*\.py$
|
||||
```
|
||||
|
||||
Install the hook:
|
||||
|
||||
```bash
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Multiple migration heads detected"
|
||||
|
||||
See [Resolving Migration Conflicts](#resolving-migration-conflicts) above.
|
||||
|
||||
### "Broken chain: revision X references down_revision Y which does not exist"
|
||||
|
||||
You removed or renamed a migration that another migration depends on. Either restore the missing file or update the dependent migration's `down_revision`.
|
||||
|
||||
### "Filename mismatch: file declares revision=X but filename stem is Y"
|
||||
|
||||
The `revision` string inside the Python file must match the filename (without `.py`). Rename the file or update the variable.
|
||||
|
||||
### "relation already exists" when running `alembic upgrade head`
|
||||
|
||||
The database has a table that a pending migration tries to create. Stamp the current state:
|
||||
|
||||
```bash
|
||||
alembic stamp head
|
||||
```
|
||||
|
||||
### Autogenerate doesn't detect my changes
|
||||
|
||||
Ensure all models are imported in `migrations/env.py`. The `from app.models import ...` block at the top must include your new model class.
|
||||
|
||||
### SQLite "no such column" after downgrade
|
||||
|
||||
SQLite has limited `ALTER TABLE` support. Always use `op.batch_alter_table()` for column operations on existing tables.
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Alembic Tutorial](https://alembic.sqlalchemy.org/en/latest/tutorial.html)
|
||||
- [Alembic Branch / Merge](https://alembic.sqlalchemy.org/en/latest/branches.html)
|
||||
- [Database Configuration Guide](DatabaseConfiguration.md)
|
||||
+111
-6
@@ -8,6 +8,7 @@ DocuElevate includes a native mobile application for iOS and Android built with
|
||||
|---------|-----|---------|
|
||||
| SSO login (OAuth2) | ✅ | ✅ |
|
||||
| Local / basic auth login | ✅ | ✅ |
|
||||
| QR code login (scan from web) | ✅ | ✅ |
|
||||
| Auto-generated API token | ✅ | ✅ |
|
||||
| Camera capture → upload | ✅ | ✅ |
|
||||
| File picker upload | ✅ | ✅ |
|
||||
@@ -57,6 +58,34 @@ eas build --platform android
|
||||
|
||||
See the [EAS Build documentation](https://docs.expo.dev/build/introduction/) for full setup instructions.
|
||||
|
||||
## Automated CI/CD
|
||||
|
||||
An **EAS Cloud Workflow** (`mobile/.eas/workflows/create-builds.yml`) automates production builds and iOS submission:
|
||||
|
||||
- **Path filtering:** The workflow only triggers on pushes to `main` that include changes inside the `mobile/` directory. Backend-only or documentation-only changes do not trigger a mobile build.
|
||||
- **Build:** Both iOS and Android production builds run in parallel on EAS Build.
|
||||
- **Auto-submit (iOS):** After a successful iOS build, the workflow automatically submits the binary to **App Store Connect** using the credentials configured in `eas.json` (`submit.production.ios`). The build then appears in **TestFlight** for internal testing and can be promoted to the App Store from App Store Connect.
|
||||
|
||||
> **Prerequisite:** An App Store Connect API Key must be configured in EAS for non-interactive submission. See [Troubleshooting → "Session expired"](#session-expired-local-session-during-ios-build) below for setup instructions.
|
||||
|
||||
### Version Management
|
||||
|
||||
Build numbers (iOS `buildNumber` / Android `versionCode`) are managed **remotely** by EAS. The `eas.json` configuration uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": { "appVersionSource": "remote" },
|
||||
"build": { "production": { "autoIncrement": true } }
|
||||
}
|
||||
```
|
||||
|
||||
- **`appVersionSource: "remote"`** — EAS stores the current build number on its servers instead of reading it from `app.json`. This ensures every CI build gets a unique, ever-increasing number without needing to commit version bumps back to the repository.
|
||||
- **`autoIncrement: true`** — EAS automatically increments the build number before each production build.
|
||||
|
||||
The `ios.buildNumber` and `android.versionCode` values in `app.json` serve as the **initial seed** when the remote version is first created; after that they are informational only. Do not rely on them for the actual version submitted to the stores.
|
||||
|
||||
> **Tip:** To check or manually set the remote version, use `eas build:version:get` and `eas build:version:set`.
|
||||
|
||||
## Authentication
|
||||
|
||||
### SSO Login Flow
|
||||
@@ -84,6 +113,18 @@ When developing with **Expo Go** the app does not have the `docuelevate://` cust
|
||||
|
||||
No extra configuration is needed — just run `npx expo start` and scan the QR code with the **Expo Go** app.
|
||||
|
||||
### QR Code Login Flow
|
||||
|
||||
As an alternative to SSO, users can log in by scanning a QR code displayed in the web UI:
|
||||
|
||||
1. The authenticated web user navigates to **Profile → Security & Sessions → Log in on mobile via QR code**.
|
||||
2. A QR code is displayed containing a deep link: `docuelevate://qr-login?token=<challenge_token>&server=<server_url>`.
|
||||
3. In the mobile app, the user taps **Scan QR Code to Login**, which opens the device camera.
|
||||
4. The app scans the QR code, extracts both the server URL and the challenge token, and calls `POST /api/qr-auth/claim`.
|
||||
5. An API token is issued and stored securely — no need to enter the server URL manually.
|
||||
|
||||
> **Note:** The QR code already contains the server URL, so users do not need to type it in when using QR login.
|
||||
|
||||
### Auto-generated Mobile Token
|
||||
|
||||
When the mobile app completes login it automatically creates a named API token (`"Mobile App – <device name>"`) via `POST /api/mobile/generate-token`. This token:
|
||||
@@ -126,10 +167,17 @@ curl -X DELETE -H "Authorization: Bearer <token>" https://your-server/api/mobile
|
||||
3. Point the camera at the document and take a photo.
|
||||
4. The image is immediately uploaded and queued for processing.
|
||||
|
||||
### Photo Library
|
||||
|
||||
1. Open the **Upload** tab.
|
||||
2. Tap **Photos**.
|
||||
3. Select an existing photo from the device's photo library.
|
||||
4. The image is uploaded and queued for processing.
|
||||
|
||||
### File Picker
|
||||
|
||||
1. Open the **Upload** tab.
|
||||
2. Tap **File Picker**.
|
||||
2. Tap **Files**.
|
||||
3. Browse to and select one or more files (PDF, DOCX, images, etc.).
|
||||
4. Files are uploaded and queued for processing.
|
||||
|
||||
@@ -140,10 +188,33 @@ The app registers itself as a share target so any file can be sent directly to D
|
||||
1. Open a file in Files, Mail, Safari, or any other app.
|
||||
2. Tap the **Share** button (iOS) or **Share** (Android).
|
||||
3. Find and tap **DocuElevate** in the share sheet.
|
||||
4. The file is immediately uploaded.
|
||||
4. The file is immediately uploaded and queued for processing.
|
||||
|
||||
> **Note:** The app must be installed on the device for it to appear in the share sheet.
|
||||
|
||||
#### iOS implementation
|
||||
|
||||
`app.json` declares `CFBundleDocumentTypes` (with `LSHandlerRank: Alternate`) inside the iOS `infoPlist`. This tells iOS that DocuElevate can open common document types, making it visible in the share sheet without overriding system defaults. When the user selects DocuElevate, iOS opens the app with a URL via `application:openURL:options:`.
|
||||
|
||||
The URL may arrive as a standard `file://` path **or** under the app's custom `docuelevate://` scheme (e.g. `docuelevate://private/var/mobile/Library/…/file.pdf`). The root layout detects the custom-scheme form and rewrites it to a `file://` URL before forwarding it to the Upload screen through `ShareContext`.
|
||||
|
||||
#### Android implementation
|
||||
|
||||
`app.json` declares `ACTION_SEND` and `ACTION_SEND_MULTIPLE` intent filters for `mimeType: "*/*"` in the `android.intentFilters` section. Incoming content URIs are received the same way as on iOS.
|
||||
|
||||
#### Upload status polling
|
||||
|
||||
After a file is uploaded the app polls `/api/files?search=<filename>` every 5 seconds to find the corresponding `FileRecord`, then polls `/api/files/{id}` to track the processing status in real time. Polling stops automatically once the status reaches a terminal state (`completed`, `failed`, or `duplicate`).
|
||||
|
||||
#### Retrying failed uploads
|
||||
|
||||
If a file upload fails (e.g. due to network issues or a server error), the failed item stays visible in the upload list with an error message and a **"Tap to retry"** hint. Users can retry the upload in two ways:
|
||||
|
||||
- **Tap** the failed item to immediately retry the upload.
|
||||
- **Long-press** the failed item to see a confirmation dialog with a **Retry** option.
|
||||
|
||||
The retry re-uses the original file URI so no re-selection is needed.
|
||||
|
||||
## Mobile API Endpoints
|
||||
|
||||
The backend exposes a dedicated `/api/mobile/` namespace:
|
||||
@@ -221,19 +292,34 @@ If you wish to use **direct FCM/APNs** without Expo's relay, replace the `send_e
|
||||
|
||||
```
|
||||
mobile/
|
||||
├── App.tsx # Root component
|
||||
├── App.tsx # Root component (legacy, not used at runtime)
|
||||
├── app/ # Expo Router file-based routes
|
||||
│ ├── _layout.tsx # Root layout (AuthGuard + providers)
|
||||
│ ├── index.tsx # Root redirect → /(auth)/
|
||||
│ ├── (auth)/ # Unauthenticated route group
|
||||
│ │ ├── _layout.tsx # Stack navigator (headerless)
|
||||
│ │ ├── index.tsx # Welcome screen
|
||||
│ │ ├── login.tsx # Login screen
|
||||
│ │ └── qr-scanner.tsx # QR code scanner screen
|
||||
│ └── (tabs)/ # Authenticated route group
|
||||
│ ├── _layout.tsx # Tab navigator
|
||||
│ ├── index.tsx # Upload screen (default tab)
|
||||
│ ├── files.tsx # Files screen
|
||||
│ └── profile.tsx # Profile screen
|
||||
├── app.json # Expo/EAS configuration
|
||||
├── eas.json # EAS Build profiles
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
├── context/
|
||||
│ └── AuthContext.tsx # Auth state + SSO login flow
|
||||
│ ├── AuthContext.tsx # Auth state + SSO login flow
|
||||
│ └── ShareContext.tsx # Shared-file queue (iOS Share Sheet / Android Intent)
|
||||
├── hooks/
|
||||
│ └── usePushNotifications.ts # Push token registration
|
||||
├── screens/
|
||||
│ ├── LoginScreen.tsx # Server URL + SSO button
|
||||
│ ├── UploadScreen.tsx # Camera capture + file picker
|
||||
│ ├── LoginScreen.tsx # Server URL + SSO button + QR code scanner
|
||||
│ ├── QRScannerScreen.tsx # Camera-based QR code scanner for login
|
||||
│ ├── UploadScreen.tsx # Camera capture + photo library + file picker
|
||||
│ ├── FilesScreen.tsx # Processed document list
|
||||
│ └── ProfileScreen.tsx # User profile + sign out
|
||||
└── services/
|
||||
@@ -242,6 +328,25 @@ mobile/
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App shows "Hello World" / default Expo page after update
|
||||
|
||||
If the iOS or Android app shows a generic "Hello World – This is the first page of your app" screen instead of the DocuElevate UI, it means a stale default `index.tsx` file (generated by Expo CLI scaffolding) is being picked up in the `mobile/app/` directory.
|
||||
|
||||
**To fix:**
|
||||
|
||||
1. Delete any leftover default `mobile/app/index.tsx` that is **not** the repository version (the repo version contains a `<Redirect>` to `/(auth)/`).
|
||||
2. Clear the Metro bundler cache and rebuild:
|
||||
```bash
|
||||
cd mobile
|
||||
npx expo start --clear
|
||||
```
|
||||
3. For production builds, run a clean EAS build:
|
||||
```bash
|
||||
eas build --platform ios --clear-cache
|
||||
```
|
||||
|
||||
The repository includes a root `app/index.tsx` that immediately redirects to the authentication flow, so this issue should not recur once the correct file is present.
|
||||
|
||||
### "Session expired Local session" during iOS build
|
||||
|
||||
EAS stores an Apple ID session locally (in `~/.expo/`) to manage code-signing certificates and provisioning profiles. This session expires after a few weeks.
|
||||
|
||||
@@ -22,6 +22,7 @@ Welcome to the DocuElevate documentation. This directory contains comprehensive
|
||||
- [Google Drive Setup](GoogleDriveSetup.md) - How to set up Google Drive integration
|
||||
- [Dropbox Setup](DropboxSetup.md) - How to set up Dropbox integration
|
||||
- [OneDrive Setup](OneDriveSetup.md) - How to set up Microsoft OneDrive/Graph integration
|
||||
- [SharePoint Setup](SharePointSetup.md) - How to set up Microsoft SharePoint Online integration
|
||||
- [Amazon S3 Setup](AmazonS3Setup.md) - How to set up Amazon S3 integration
|
||||
- [Authentication Setup](AuthenticationSetup.md) - How to set up user authentication
|
||||
- [Notifications Setup](NotificationsSetup.md) - How to set up system notifications
|
||||
|
||||
@@ -28,7 +28,7 @@ Settings are organized into logical categories for easy navigation:
|
||||
- **Authentication**: Login settings, session secrets, OAuth configuration, admin group
|
||||
- **AI Services**: AI provider selection, model configuration, embeddings, and credentials (OpenAI, Azure, Anthropic, Gemini, Ollama, OpenRouter, Portkey, LiteLLM)
|
||||
- **OCR Engines**: OCR provider selection and configuration (Tesseract, EasyOCR, Mistral, Google DocAI, AWS Textract)
|
||||
- **Storage Providers**: Dropbox, Google Drive, OneDrive, S3, FTP, SFTP, WebDAV, Nextcloud, Paperless
|
||||
- **Storage Providers**: Dropbox, Google Drive, OneDrive, SharePoint, S3, FTP, SFTP, WebDAV, Nextcloud, Paperless
|
||||
- **Email**: SMTP configuration for sending emails
|
||||
- **IMAP**: Email ingestion configuration (supports two mailbox accounts)
|
||||
- **Monitoring**: Uptime Kuma integration
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Setting up SharePoint Integration
|
||||
|
||||
This guide explains how to set up the Microsoft SharePoint Online integration for DocuElevate.
|
||||
|
||||
## Required Configuration Parameters
|
||||
|
||||
| **Variable** | **Description** |
|
||||
|---------------------------------|-------------------------------------------------------|
|
||||
| `SHAREPOINT_CLIENT_ID` | Azure AD application client ID |
|
||||
| `SHAREPOINT_CLIENT_SECRET` | Azure AD application client secret |
|
||||
| `SHAREPOINT_TENANT_ID` | Azure AD tenant ID (use "common" for multi-tenant apps) |
|
||||
| `SHAREPOINT_REFRESH_TOKEN` | OAuth 2.0 refresh token |
|
||||
| `SHAREPOINT_SITE_URL` | SharePoint site URL (e.g. `https://tenant.sharepoint.com/sites/sitename`) |
|
||||
| `SHAREPOINT_DOCUMENT_LIBRARY` | Document library name (default: `Documents`) |
|
||||
| `SHAREPOINT_FOLDER_PATH` | Subfolder path inside the document library |
|
||||
|
||||
For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md).
|
||||
|
||||
## Overview
|
||||
|
||||
SharePoint Online integration uses the same Microsoft Graph API as OneDrive. The key difference is that SharePoint targets a **site-specific document library** rather than a personal OneDrive. Documents are uploaded via chunked upload sessions for reliability with large files.
|
||||
|
||||
> **Tip:** If you already have an Azure AD app registration for OneDrive, you can reuse it for SharePoint — just add the `Sites.ReadWrite.All` permission.
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Register an application in Azure Active Directory
|
||||
|
||||
If you don't already have an app registration (e.g. from OneDrive setup):
|
||||
|
||||
1. Go to the [Azure Portal](https://portal.azure.com/)
|
||||
2. Navigate to **Azure Active Directory** > **App registrations**
|
||||
3. Click **New registration**
|
||||
4. Enter a name for your application (e.g., "DocuElevate")
|
||||
5. For **Supported account types**, select:
|
||||
- **Single tenant**: "Accounts in this organizational directory only"
|
||||
- **Multi-tenant**: "Accounts in any organizational directory"
|
||||
6. For **Redirect URI**, select "Web" and enter your callback URL (e.g., `https://your-domain.com/onedrive-callback`)
|
||||
7. Click **Register**
|
||||
|
||||
### 2. Get Application (client) ID
|
||||
|
||||
1. After registration, note the **Application (client) ID** from the overview page
|
||||
2. Set this value as `SHAREPOINT_CLIENT_ID`
|
||||
|
||||
### 3. Create a client secret
|
||||
|
||||
1. In your application page, go to **Certificates & secrets**
|
||||
2. Under **Client secrets**, click **New client secret**
|
||||
3. Add a description and select an expiration period
|
||||
4. Click **Add** and immediately copy the secret value (it will only be shown once)
|
||||
5. Set this value as `SHAREPOINT_CLIENT_SECRET`
|
||||
|
||||
### 4. Configure API permissions
|
||||
|
||||
1. In your application page, go to **API permissions**
|
||||
2. Click **Add a permission**
|
||||
3. Select **Microsoft Graph**
|
||||
4. For **delegated permissions** (user-context access), add:
|
||||
- `Sites.ReadWrite.All` — Read and write items in all site collections
|
||||
- `offline_access` — Required for refresh tokens
|
||||
5. For **application permissions** (app-only access without a user), add:
|
||||
- `Sites.ReadWrite.All` — Read and write items in all site collections
|
||||
6. Click **Add permissions**
|
||||
7. Click **Grant admin consent** (requires admin privileges)
|
||||
|
||||
> **Important:** SharePoint access requires `Sites.ReadWrite.All` rather than the `Files.ReadWrite` permission used by OneDrive.
|
||||
|
||||
### 5. Get your Tenant ID
|
||||
|
||||
1. In the Azure Portal, find your **Tenant ID** (also called "Directory ID")
|
||||
2. It is on the **Azure Active Directory** overview page
|
||||
3. Set this value as `SHAREPOINT_TENANT_ID`
|
||||
|
||||
### 6. Generate a Refresh Token
|
||||
|
||||
#### Using the OneDrive Auth Wizard
|
||||
|
||||
The SharePoint integration reuses the same MSAL token flow as OneDrive:
|
||||
|
||||
1. Navigate to `/onedrive-setup`
|
||||
2. Enter your SharePoint Client ID and Tenant ID
|
||||
3. Click **Start Authentication Flow** and follow the prompts
|
||||
4. Copy the generated refresh token and set it as `SHAREPOINT_REFRESH_TOKEN`
|
||||
|
||||
#### Manual Method
|
||||
|
||||
1. Open the following URL in your browser (replace placeholders):
|
||||
```
|
||||
https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REDIRECT_URI&response_mode=query&scope=https://graph.microsoft.com/.default offline_access&prompt=consent
|
||||
```
|
||||
2. Sign in with your Microsoft work account
|
||||
3. After authentication, copy the `code` parameter from the redirect URL
|
||||
4. Exchange the code for tokens:
|
||||
```bash
|
||||
curl -X POST https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "client_id=YOUR_CLIENT_ID&scope=https://graph.microsoft.com/.default offline_access&code=YOUR_AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&grant_type=authorization_code&client_secret=YOUR_CLIENT_SECRET"
|
||||
```
|
||||
5. From the response JSON, copy the `refresh_token` value
|
||||
6. Set this as `SHAREPOINT_REFRESH_TOKEN`
|
||||
|
||||
### 7. Find your SharePoint Site URL
|
||||
|
||||
Your SharePoint site URL follows the pattern:
|
||||
```
|
||||
https://YOUR-TENANT.sharepoint.com/sites/SITE-NAME
|
||||
```
|
||||
|
||||
For example:
|
||||
- `https://contoso.sharepoint.com/sites/documents`
|
||||
- `https://contoso.sharepoint.com/sites/engineering-team`
|
||||
|
||||
Set this as `SHAREPOINT_SITE_URL`.
|
||||
|
||||
### 8. Choose your Document Library
|
||||
|
||||
Each SharePoint site has one or more document libraries. The default library is usually called `Documents` (or `Shared Documents`). You can find your library names by navigating to your SharePoint site in a browser and looking at the left sidebar.
|
||||
|
||||
Set the library name as `SHAREPOINT_DOCUMENT_LIBRARY` (default: `Documents`).
|
||||
|
||||
### 9. Set the Upload Folder (Optional)
|
||||
|
||||
If you want documents to be uploaded into a subfolder inside the library, set `SHAREPOINT_FOLDER_PATH`. For example, `Uploads` or `DocuElevate/Processed`.
|
||||
|
||||
## App-Only Access (No User Token)
|
||||
|
||||
For fully automated scenarios without user interaction:
|
||||
|
||||
1. Add **Application permissions** (not Delegated) for `Sites.ReadWrite.All`
|
||||
2. Grant admin consent
|
||||
3. Set `SHAREPOINT_TENANT_ID` to your organization's tenant ID
|
||||
4. Leave `SHAREPOINT_REFRESH_TOKEN` empty — the app will use the client credentials flow
|
||||
|
||||
> **Note:** Client credentials flow requires a specific tenant ID (not "common").
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
**With Refresh Token (Delegated Permissions):**
|
||||
```dotenv
|
||||
SHAREPOINT_CLIENT_ID=12345678-1234-1234-1234-123456789012
|
||||
SHAREPOINT_CLIENT_SECRET=your_client_secret
|
||||
SHAREPOINT_TENANT_ID=87654321-4321-4321-4321-210987654321
|
||||
SHAREPOINT_REFRESH_TOKEN=your_refresh_token
|
||||
SHAREPOINT_SITE_URL=https://contoso.sharepoint.com/sites/documents
|
||||
SHAREPOINT_DOCUMENT_LIBRARY=Documents
|
||||
SHAREPOINT_FOLDER_PATH=Uploads
|
||||
```
|
||||
|
||||
**App-Only Access (Application Permissions):**
|
||||
```dotenv
|
||||
SHAREPOINT_CLIENT_ID=12345678-1234-1234-1234-123456789012
|
||||
SHAREPOINT_CLIENT_SECRET=your_client_secret
|
||||
SHAREPOINT_TENANT_ID=87654321-4321-4321-4321-210987654321
|
||||
# No refresh token needed for app-only access
|
||||
SHAREPOINT_SITE_URL=https://contoso.sharepoint.com/sites/documents
|
||||
SHAREPOINT_DOCUMENT_LIBRARY=Shared Documents
|
||||
SHAREPOINT_FOLDER_PATH=DocuElevate/Processed
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to resolve SharePoint site"
|
||||
|
||||
- Verify `SHAREPOINT_SITE_URL` is correct and accessible
|
||||
- Ensure your app has `Sites.ReadWrite.All` permission with admin consent
|
||||
- Check that the site exists and your account has access to it
|
||||
|
||||
### "Document library not found"
|
||||
|
||||
- Verify the library name in `SHAREPOINT_DOCUMENT_LIBRARY` matches exactly (case-insensitive)
|
||||
- Navigate to your SharePoint site in a browser to confirm the library name
|
||||
- Common names: `Documents`, `Shared Documents`
|
||||
|
||||
### Token errors
|
||||
|
||||
- If using a refresh token, try re-authorizing via the OAuth flow
|
||||
- Ensure `offline_access` scope is included in your permissions
|
||||
- For app-only access, verify the tenant ID is not set to "common"
|
||||
|
||||
### Permission errors
|
||||
|
||||
- Ensure an admin has granted consent for `Sites.ReadWrite.All`
|
||||
- Verify the app registration has the correct permissions
|
||||
- Check that the site's sharing settings allow API access
|
||||
@@ -341,6 +341,7 @@ in task messages or logs.
|
||||
| `S3` | boto3 `upload_file`, per-user access key |
|
||||
| `GOOGLE_DRIVE` | Google Drive API v3, OAuth or service account |
|
||||
| `ONEDRIVE` | Microsoft Graph API, MSAL confidential-client |
|
||||
| `SHAREPOINT` | Microsoft Graph API, site/drive resolution + chunked upload |
|
||||
| `WEBDAV` | HTTP PUT request, Basic Auth |
|
||||
| `NEXTCLOUD` | WebDAV (same as WEBDAV, Nextcloud-compatible path) |
|
||||
| `FTP` | ftplib FTPS (TLS preferred, plaintext configurable) |
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ The **Integrations** page (`/integrations`) provides a unified view of all your
|
||||
- **S3** — bucket, region, access key, secret key
|
||||
- **WebDAV / Nextcloud** — URL, folder, username, password
|
||||
- **FTP / SFTP** — host, port, remote path, username, password
|
||||
- **Dropbox / Google Drive / OneDrive** — folder path, with a link to the OAuth setup page
|
||||
- **Dropbox / Google Drive / OneDrive / SharePoint** — folder path, with a link to the OAuth setup page
|
||||
- **Email Forward** — recipient email address
|
||||
- **Watch Folder** — source type (Local, S3, Dropbox, Google Drive, OneDrive, Nextcloud, WebDAV), per-type config fields, delete after processing toggle
|
||||
- **Paperless NGX** — URL and API token
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import re
|
||||
|
||||
with open("tests/test_api_saved_searches.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# We need to mock get_current_user in app.api.saved_searches (which is imported from app.auth)
|
||||
# because saved searches uses `_get_user_id` which calls `get_current_user(request)`.
|
||||
# But `_get_user_id` is NOT a dependency injected via `Depends`!
|
||||
# Let's verify `app/api/saved_searches.py` uses `Depends` or just calls it.
|
||||
|
||||
# In `app/api/saved_searches.py`:
|
||||
# def _get_user_id(request: Request) -> str:
|
||||
# user = get_current_user(request)
|
||||
# if user:
|
||||
# return user.get("preferred_username") ...
|
||||
# It's called directly inside the routes: `user_id = _get_user_id(request)`
|
||||
# It doesn't use `Depends(_get_user_id)`.
|
||||
# Ah! But earlier I saw `_get_user_id` wasn't mocked properly. Let's use patch to mock `_get_user_id`.
|
||||
|
||||
# Wait, `TestClient` can be given an active session, but `app.auth.get_current_user` uses `request.session.get("user")` or Bearer token.
|
||||
# Is `AUTH_ENABLED` false? The test env has `os.environ["AUTH_ENABLED"] = "False"` in `tests/conftest.py`.
|
||||
# If `AUTH_ENABLED` is false, `require_login` is a no-op, and `_get_user_id` falls back to "anonymous".
|
||||
# Actually, `_get_user_id` returns "anonymous" if `get_current_user(request)` is None.
|
||||
# If `_OWNER` is "test_user@example.com", we should probably just patch `_get_user_id`.
|
||||
|
||||
replacement = """def _make_client(int_engine, owner_id: str = _OWNER):
|
||||
\"\"\"Return a TestClient with *owner_id* injected as the authenticated user.\"\"\"
|
||||
from app.main import app
|
||||
from unittest.mock import patch
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=int_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
with patch("app.api.saved_searches._get_user_id", return_value=owner_id):
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
yield client
|
||||
app.dependency_overrides.clear()"""
|
||||
|
||||
content = re.sub(
|
||||
r"def _make_client\(int_engine, owner_id: str = _OWNER\):.*?(?=@pytest\.fixture\(\)\ndef int_client\(int_engine\):)",
|
||||
replacement + "\n\n\n",
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
with open("tests/test_api_saved_searches.py", "w") as f:
|
||||
f.write(content)
|
||||
@@ -215,6 +215,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
linksDiv.appendChild(
|
||||
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', window.__i18n.apiTokens || 'API Tokens', 'text-gray-700')
|
||||
);
|
||||
linksDiv.appendChild(
|
||||
_makeMenuLink('/devices', 'fas fa-mobile-alt text-blue-500', window.__i18n.devices || 'Devices', 'text-gray-700')
|
||||
);
|
||||
linksDiv.appendChild(
|
||||
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', window.__i18n.sharedLinks || 'Shared Links', 'text-gray-700')
|
||||
);
|
||||
@@ -311,6 +314,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
tokensLink.appendChild(document.createTextNode(window.__i18n.apiTokens || 'API Tokens'));
|
||||
mobileAuthSection.appendChild(tokensLink);
|
||||
|
||||
// Devices link
|
||||
const devicesLink = document.createElement('a');
|
||||
devicesLink.href = '/devices';
|
||||
devicesLink.className =
|
||||
'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50';
|
||||
const devicesIcon = document.createElement('i');
|
||||
devicesIcon.className = 'fas fa-mobile-alt mr-2 text-blue-500';
|
||||
devicesIcon.setAttribute('aria-hidden', 'true');
|
||||
devicesLink.appendChild(devicesIcon);
|
||||
devicesLink.appendChild(document.createTextNode(window.__i18n.devices || 'Devices'));
|
||||
mobileAuthSection.appendChild(devicesLink);
|
||||
|
||||
// Shared Links link
|
||||
const sharedLinksLink = document.createElement('a');
|
||||
sharedLinksLink.href = '/shared-links';
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<!-- flag-icons: SVG/CSS flag sprites that render correctly on all platforms including Windows -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.3.2/css/flag-icons.min.css"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
{% endblock %}
|
||||
{% block head_extra %}{% endblock %}
|
||||
<!-- CSRF token for AJAX/fetch requests -->
|
||||
@@ -174,6 +177,11 @@
|
||||
<a href="/admin/backup" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-database w-4 mr-2 text-green-600" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
|
||||
</a>
|
||||
{% if enable_factory_reset %}
|
||||
<a href="/admin/system-reset" role="menuitem" class="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-red-50">
|
||||
<i class="fas fa-skull-crossbones w-4 mr-2 text-red-500" aria-hidden="true"></i> {{ _("nav.system_reset") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/admin/audit-logs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-shield-halved w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Audit Logs
|
||||
</a>
|
||||
@@ -250,7 +258,7 @@
|
||||
>
|
||||
<span class="text-base leading-none" aria-hidden="true">
|
||||
{% set cur_lang = suggested_languages | selectattr("code", "equalto", current_locale) | list %}
|
||||
{% if cur_lang %}{{ cur_lang[0].flag }}{% else %}🌐{% endif %}
|
||||
{% if cur_lang %}<span class="fi fi-{{ cur_lang[0].flag }}"></span>{% else %}<i class="fas fa-globe"></i>{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
@@ -290,7 +298,7 @@
|
||||
class="flex items-center w-full px-4 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
:onclick="`setLanguage('${lang.code}')`"
|
||||
>
|
||||
<span class="mr-2 text-base" x-text="lang.flag"></span>
|
||||
<span class="fi mr-2" :class="`fi-${lang.flag}`" aria-hidden="true"></span>
|
||||
<span x-text="lang.native"></span>
|
||||
<i x-show="lang.code === '{{ current_locale }}'" class="fas fa-check ml-auto text-blue-500" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -442,6 +450,11 @@
|
||||
<a href="/admin/backup" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-database mr-2 text-green-500" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
|
||||
</a>
|
||||
{% if enable_factory_reset %}
|
||||
<a href="/admin/system-reset" class="block px-3 py-3 rounded-md text-base font-medium text-red-600 hover:text-red-800 hover:bg-red-50">
|
||||
<i class="fas fa-skull-crossbones mr-2 text-red-500" aria-hidden="true"></i> {{ _("nav.system_reset") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/status" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
||||
{% if request and request.url.path == '/status' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-circle-dot mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.status") }}
|
||||
@@ -557,6 +570,7 @@
|
||||
profileSettings: {{ _("nav.profile_settings") | tojson }},
|
||||
mySubscription: {{ _("nav.my_subscription") | tojson }},
|
||||
apiTokens: {{ _("nav.api_tokens") | tojson }},
|
||||
devices: {{ _("nav.devices") | tojson }},
|
||||
sharedLinks: {{ _("nav.shared_links") | tojson }},
|
||||
signOut: {{ _("nav.sign_out") | tojson }},
|
||||
logIn: {{ _("nav.login") | tojson }},
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _("devices.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="devicesPage()" x-init="init()" class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<i class="fas fa-mobile-alt text-blue-500" aria-hidden="true"></i>
|
||||
{{ _("devices.heading") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
|
||||
{{ _("devices.intro") }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- ── Mobile App Tokens ──────────────────────────────────────────────── -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden mb-6" aria-labelledby="mobile-tokens-heading">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 id="mobile-tokens-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-key text-yellow-500 mr-2" aria-hidden="true"></i>{{ _("devices.mobile_tokens_heading") }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("devices.mobile_tokens_description") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<template x-if="loadingTokens">
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
|
||||
<p class="text-sm">{{ _("devices.loading") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="!loadingTokens && mobileTokens.length === 0">
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-mobile-alt text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
|
||||
<p class="font-medium">{{ _("devices.no_mobile_tokens") }}</p>
|
||||
<p class="text-sm mt-1">{{ _("devices.no_mobile_tokens_help") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tokens table -->
|
||||
<template x-if="!loadingTokens && mobileTokens.length > 0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm" aria-label="{{ _('devices.mobile_tokens_heading') }}">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-750 text-left">
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_device") }}</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_token_prefix") }}</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_created") }}</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_last_used") }}</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_status") }}</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="token in mobileTokens" :key="token.id">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-mobile-alt text-gray-400" aria-hidden="true"></i>
|
||||
<span class="font-medium text-gray-900 dark:text-white" x-text="formatDeviceName(token.name)"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs font-mono" x-text="token.token_prefix + '…'"></code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||
<span x-text="token.last_used_at ? formatDate(token.last_used_at) : '—'"></span>
|
||||
<span x-show="token.last_used_ip" class="block text-xs text-gray-400 mt-0.5">
|
||||
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="token.last_used_ip"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
|
||||
x-text="token.is_active ? '{{ _('devices.status_active') }}' : '{{ _('devices.status_revoked') }}'"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button
|
||||
x-show="token.is_active"
|
||||
type="button"
|
||||
@click="revokeToken(token)"
|
||||
:disabled="revokingToken === token.id"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||
style="min-height:36px; min-width:44px;"
|
||||
:aria-label="'{{ _('devices.revoke_token') }} ' + token.name"
|
||||
>
|
||||
<i :class="revokingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-sign-out-alt'" class="mr-1" aria-hidden="true"></i>
|
||||
{{ _("devices.revoke_token") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<template x-if="tokenError">
|
||||
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
|
||||
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||
<span x-text="tokenError"></span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── Registered Devices (Push Notifications) ────────────────────────── -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden mb-6" aria-labelledby="devices-heading">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 id="devices-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-bell text-purple-500 mr-2" aria-hidden="true"></i>{{ _("devices.registered_devices_heading") }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("devices.registered_devices_description") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<template x-if="loadingDevices">
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
|
||||
<p class="text-sm">{{ _("devices.loading") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="!loadingDevices && devices.length === 0">
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-bell-slash text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
|
||||
<p class="font-medium">{{ _("devices.no_devices") }}</p>
|
||||
<p class="text-sm mt-1">{{ _("devices.no_devices_help") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Devices list -->
|
||||
<template x-if="!loadingDevices && devices.length > 0">
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="device in devices" :key="device.id">
|
||||
<div class="flex items-center justify-between px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<i
|
||||
:class="device.platform === 'ios' ? 'fab fa-apple' :
|
||||
device.platform === 'android' ? 'fab fa-android text-green-500' :
|
||||
'fas fa-globe'"
|
||||
class="text-lg text-gray-400 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<span x-text="device.device_name || 'Unknown Device'"></span>
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="device.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="device.is_active ? '{{ _('devices.status_active') }}' : '{{ _('devices.status_inactive') }}'"
|
||||
></span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-x-3 mt-0.5">
|
||||
<span>
|
||||
<i class="fas fa-microchip mr-1" aria-hidden="true"></i>
|
||||
<span x-text="device.platform.charAt(0).toUpperCase() + device.platform.slice(1)"></span>
|
||||
</span>
|
||||
<span x-show="device.last_seen_at">
|
||||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>{{ _("devices.col_last_seen") }}:
|
||||
<span x-text="formatDate(device.last_seen_at)"></span>
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-calendar mr-1" aria-hidden="true"></i>
|
||||
<span x-text="formatDate(device.created_at)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
x-show="device.is_active"
|
||||
type="button"
|
||||
@click="deactivateDevice(device)"
|
||||
:disabled="deactivatingDevice === device.id"
|
||||
class="flex-shrink-0 inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||
style="min-height:36px; min-width:44px;"
|
||||
:aria-label="'{{ _('devices.deactivate_device') }} ' + (device.device_name || 'device')"
|
||||
>
|
||||
<i :class="deactivatingDevice === device.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
|
||||
{{ _("devices.deactivate_device") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<template x-if="deviceError">
|
||||
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
|
||||
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||
<span x-text="deviceError"></span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── QR Login CTA ───────────────────────────────────────────────────── -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="/qr-login"
|
||||
class="inline-flex items-center px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium
|
||||
rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-qrcode mr-2" aria-hidden="true"></i>
|
||||
{{ _("devices.qr_login_cta") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ── Status banner ──────────────────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="banner.visible"
|
||||
x-transition
|
||||
class="mt-6 rounded-lg p-3 text-sm"
|
||||
:class="banner.error
|
||||
? 'bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-300 dark:border-red-700'
|
||||
: 'bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-300 dark:border-green-700'"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span x-text="banner.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function devicesPage() {
|
||||
const csrfToken = '{{ csrf_token | default("") }}';
|
||||
return {
|
||||
mobileTokens: [],
|
||||
devices: [],
|
||||
loadingTokens: true,
|
||||
loadingDevices: true,
|
||||
revokingToken: null,
|
||||
deactivatingDevice: null,
|
||||
tokenError: null,
|
||||
deviceError: null,
|
||||
banner: { visible: false, error: false, message: '' },
|
||||
|
||||
async init() {
|
||||
await Promise.all([this.loadMobileTokens(), this.loadDevices()]);
|
||||
},
|
||||
|
||||
async loadMobileTokens() {
|
||||
this.loadingTokens = true;
|
||||
this.tokenError = null;
|
||||
try {
|
||||
const res = await fetch('/api/api-tokens/mobile', {
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load mobile tokens');
|
||||
this.mobileTokens = await res.json();
|
||||
} catch (e) {
|
||||
this.tokenError = e.message;
|
||||
} finally {
|
||||
this.loadingTokens = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadDevices() {
|
||||
this.loadingDevices = true;
|
||||
this.deviceError = null;
|
||||
try {
|
||||
const res = await fetch('/api/mobile/devices', {
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load devices');
|
||||
this.devices = await res.json();
|
||||
} catch (e) {
|
||||
this.deviceError = e.message;
|
||||
} finally {
|
||||
this.loadingDevices = false;
|
||||
}
|
||||
},
|
||||
|
||||
async revokeToken(token) {
|
||||
if (!confirm({{ _("devices.confirm_revoke_token") | tojson }})) return;
|
||||
this.revokingToken = token.id;
|
||||
try {
|
||||
const res = await fetch(`/api/api-tokens/${token.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to revoke token');
|
||||
}
|
||||
await this.loadMobileTokens();
|
||||
this._showBanner({{ _("devices.token_revoked_success") | tojson }}, false);
|
||||
} catch (e) {
|
||||
this._showBanner(e.message, true);
|
||||
} finally {
|
||||
this.revokingToken = null;
|
||||
}
|
||||
},
|
||||
|
||||
async deactivateDevice(device) {
|
||||
if (!confirm({{ _("devices.confirm_deactivate_device") | tojson }})) return;
|
||||
this.deactivatingDevice = device.id;
|
||||
try {
|
||||
const res = await fetch(`/api/mobile/devices/${device.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to remove device');
|
||||
}
|
||||
await this.loadDevices();
|
||||
this._showBanner({{ _("devices.device_removed_success") | tojson }}, false);
|
||||
} catch (e) {
|
||||
this._showBanner(e.message, true);
|
||||
} finally {
|
||||
this.deactivatingDevice = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Extract the device name from the full token name (e.g. "Mobile App – iPhone 15 Pro" → "iPhone 15 Pro"). */
|
||||
formatDeviceName(name) {
|
||||
if (!name) return 'Unknown Device';
|
||||
// Match either em dash (–) or hyphen (-) separators used by the mobile flows.
|
||||
const match = name.match(/[–\-]\s*(.+)$/);
|
||||
return match ? match[1].trim() : name;
|
||||
},
|
||||
|
||||
formatDate(d) {
|
||||
if (!d) return '—';
|
||||
const dt = new Date(d);
|
||||
return dt.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
|
||||
' ' + dt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
|
||||
_showBanner(msg, isError) {
|
||||
this.banner = { visible: true, error: isError, message: msg };
|
||||
setTimeout(() => { this.banner.visible = false; }, 5000);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1095,10 +1095,18 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="detail-container">
|
||||
<a href="/files" class="back-button" aria-label="Back to File List">
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
||||
<a href="/files" class="back-button" style="margin-bottom:0;" aria-label="Back to File List">
|
||||
<i class="fas fa-arrow-left" aria-hidden="true"></i>
|
||||
Back to File List
|
||||
</a>
|
||||
{% if file %}
|
||||
<a href="/files/{{ file.id }}" class="back-button" style="margin-bottom:0;" aria-label="View document for {{ file.original_filename }}">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
View Document
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="error-message">
|
||||
|
||||
@@ -439,10 +439,105 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if file.detected_language %}
|
||||
<div style="font-size:0.75rem;color:#6b7280;margin-bottom:0.5rem;">
|
||||
<i class="fas fa-globe" aria-hidden="true" style="margin-right:0.25rem;"></i>
|
||||
Detected language: <strong>{{ file.detected_language }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="ocr-text-block" style="display:none;">
|
||||
<pre class="ocr-text" id="ocr-text-content">{{ file.ocr_text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Default-language translation ── -->
|
||||
{% if file.default_language_text %}
|
||||
<div class="doc-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
|
||||
<div class="doc-card-title" style="margin:0;">
|
||||
<i class="fas fa-language" aria-hidden="true" style="color:#3b82f6;margin-right:0.4rem;"></i>
|
||||
Default Language Version
|
||||
{% if file.default_language_code %}
|
||||
<span style="font-size:0.75rem;color:#6b7280;font-weight:normal;margin-left:0.5rem;">({{ file.default_language_code }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button class="text-toggle" onclick="toggleDefaultLangText()" aria-expanded="false" aria-controls="default-lang-text-block">
|
||||
<i id="default-lang-toggle-icon" class="fas fa-chevron-down" aria-hidden="true"></i>
|
||||
<span id="default-lang-toggle-label">Show text</span>
|
||||
</button>
|
||||
<button class="text-toggle" onclick="copyDefaultLangText()" id="default-lang-copy-btn" style="color:#10b981;" aria-label="Copy default language text">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="default-lang-text-block" style="display:none;">
|
||||
<pre class="ocr-text" id="default-lang-text-content">{{ file.default_language_text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% elif file.detected_language %}
|
||||
<!-- Translation pending or document already in default language -->
|
||||
<div class="doc-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div class="doc-card-title" style="margin:0;">
|
||||
<i class="fas fa-language" aria-hidden="true" style="color:#3b82f6;margin-right:0.4rem;"></i>
|
||||
Default Language Version
|
||||
</div>
|
||||
<button class="action-btn btn-secondary" onclick="loadDefaultLangText({{ file.id }})">
|
||||
<i class="fas fa-language" aria-hidden="true"></i> Load translation
|
||||
</button>
|
||||
</div>
|
||||
<div id="default-lang-load-area" style="margin-top:0.75rem;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── On-the-fly translation ── -->
|
||||
{% if file.ocr_text %}
|
||||
<div class="doc-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
|
||||
<div class="doc-card-title" style="margin:0;">
|
||||
<i class="fas fa-exchange-alt" aria-hidden="true" style="color:#f59e0b;margin-right:0.4rem;"></i>
|
||||
Translate to Another Language
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||
<label for="translate-lang-select" class="sr-only">Target language</label>
|
||||
<select id="translate-lang-select" style="padding:0.4rem 0.6rem;border:1px solid #d1d5db;border-radius:0.375rem;font-size:0.85rem;min-width:160px;" aria-label="Select target language for translation">
|
||||
<option value="">Select language…</option>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="nl">Nederlands</option>
|
||||
<option value="pl">Polski</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="ar">العربية</option>
|
||||
<option value="hi">हिन्दी</option>
|
||||
<option value="tr">Türkçe</option>
|
||||
<option value="sv">Svenska</option>
|
||||
<option value="da">Dansk</option>
|
||||
<option value="no">Norsk</option>
|
||||
<option value="fi">Suomi</option>
|
||||
<option value="cs">Čeština</option>
|
||||
<option value="ro">Română</option>
|
||||
<option value="uk">Українська</option>
|
||||
</select>
|
||||
<button class="action-btn btn-secondary" onclick="translateOnTheFly({{ file.id }})" id="translate-btn" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-language" aria-hidden="true"></i> Translate
|
||||
</button>
|
||||
<button class="text-toggle" onclick="copyTranslatedText()" id="translate-copy-btn" style="color:#10b981;display:none;" aria-label="Copy translated text">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
<div id="translate-result-area" style="margin-top:0.75rem;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elif processed_file_exists or original_file_exists %}
|
||||
<div class="doc-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
@@ -688,6 +783,149 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Default-language text toggle ──
|
||||
function toggleDefaultLangText() {
|
||||
var block = document.getElementById('default-lang-text-block');
|
||||
var icon = document.getElementById('default-lang-toggle-icon');
|
||||
var label = document.getElementById('default-lang-toggle-label');
|
||||
var btn = icon ? icon.closest('button') : null;
|
||||
if (!block) return;
|
||||
if (block.style.display === 'none') {
|
||||
block.style.display = 'block';
|
||||
if (icon) icon.className = 'fas fa-chevron-up';
|
||||
if (label) label.textContent = 'Hide text';
|
||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
block.style.display = 'none';
|
||||
if (icon) icon.className = 'fas fa-chevron-down';
|
||||
if (label) label.textContent = 'Show text';
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Copy default-language text ──
|
||||
function copyDefaultLangText() {
|
||||
var content = document.getElementById('default-lang-text-content');
|
||||
if (!content) return;
|
||||
var text = content.textContent;
|
||||
var btn = document.getElementById('default-lang-copy-btn');
|
||||
if (!btn) return;
|
||||
var orig = btn.innerHTML;
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
}).catch(function() {
|
||||
try {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load default-language text on demand ──
|
||||
function loadDefaultLangText(fileId) {
|
||||
var area = document.getElementById('default-lang-load-area');
|
||||
if (!area) return;
|
||||
area.innerHTML = '<div style="text-align:center;padding:1.5rem;color:#6b7280;"><i class="fas fa-spinner fa-spin fa-2x" aria-hidden="true"></i><p style="margin-top:0.5rem;">Loading translation…</p></div>';
|
||||
fetch('/files/' + fileId + '/text/default-language')
|
||||
.then(function(r) {
|
||||
if (r.status === 404) throw new Error('No translation available yet — it may still be processing');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
area.innerHTML = '';
|
||||
var info = document.createElement('div');
|
||||
info.style.cssText = 'font-size:0.75rem;color:#6b7280;margin-bottom:0.5rem;';
|
||||
info.textContent = 'Translated to: ' + data.language_code + (data.detected_language ? ' (from ' + data.detected_language + ')' : '');
|
||||
area.appendChild(info);
|
||||
var pre = document.createElement('pre');
|
||||
pre.className = 'ocr-text';
|
||||
pre.textContent = data.text;
|
||||
area.appendChild(pre);
|
||||
})
|
||||
.catch(function(err) {
|
||||
area.innerHTML = '<div style="color:#92400e;padding:0.75rem;background:#fef3c7;border-radius:0.375rem;font-size:0.875rem;"><i class="fas fa-info-circle" aria-hidden="true"></i> ' + err.message + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// ── On-the-fly translation ──
|
||||
var _translateCache = {};
|
||||
function translateOnTheFly(fileId) {
|
||||
var select = document.getElementById('translate-lang-select');
|
||||
var area = document.getElementById('translate-result-area');
|
||||
var copyBtn = document.getElementById('translate-copy-btn');
|
||||
if (!select || !area) return;
|
||||
var lang = select.value;
|
||||
if (!lang) { area.innerHTML = '<div style="color:#92400e;padding:0.75rem;background:#fef3c7;border-radius:0.375rem;font-size:0.875rem;">Please select a target language.</div>'; return; }
|
||||
|
||||
// Check cache
|
||||
if (_translateCache[lang]) {
|
||||
renderTranslation(area, _translateCache[lang], lang);
|
||||
if (copyBtn) copyBtn.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
area.innerHTML = '<div style="text-align:center;padding:1.5rem;color:#6b7280;" aria-live="polite"><i class="fas fa-spinner fa-spin fa-2x" aria-hidden="true"></i><p style="margin-top:0.5rem;">Translating…</p></div>';
|
||||
if (copyBtn) copyBtn.style.display = 'none';
|
||||
|
||||
fetch('/api/files/' + fileId + '/translate?lang=' + encodeURIComponent(lang))
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function(data) {
|
||||
_translateCache[lang] = data.text;
|
||||
renderTranslation(area, data.text, lang);
|
||||
if (copyBtn) copyBtn.style.display = '';
|
||||
})
|
||||
.catch(function(err) {
|
||||
area.innerHTML = '<div style="color:#dc2626;padding:0.75rem;background:#fee2e2;border-radius:0.375rem;font-size:0.875rem;"><i class="fas fa-exclamation-triangle" aria-hidden="true"></i> Translation failed: ' + err.message + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTranslation(container, text, lang) {
|
||||
container.innerHTML = '';
|
||||
var info = document.createElement('div');
|
||||
info.style.cssText = 'font-size:0.75rem;color:#6b7280;margin-bottom:0.5rem;';
|
||||
info.textContent = 'Translated to: ' + lang;
|
||||
container.appendChild(info);
|
||||
var pre = document.createElement('pre');
|
||||
pre.className = 'ocr-text';
|
||||
pre.id = 'translated-text-content';
|
||||
pre.textContent = text;
|
||||
container.appendChild(pre);
|
||||
}
|
||||
|
||||
function copyTranslatedText() {
|
||||
var content = document.getElementById('translated-text-content');
|
||||
if (!content) return;
|
||||
var text = content.textContent;
|
||||
var btn = document.getElementById('translate-copy-btn');
|
||||
if (!btn) return;
|
||||
var orig = btn.innerHTML;
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
}).catch(function() {
|
||||
try {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initialise on load ──
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% if (processed_file_exists or original_file_exists) and file %}
|
||||
|
||||
+105
-56
@@ -524,6 +524,7 @@
|
||||
<option value="webdav" {% if storage_provider == "webdav" %}selected{% endif %}>WebDAV</option>
|
||||
<option value="ftp" {% if storage_provider == "ftp" %}selected{% endif %}>FTP</option>
|
||||
<option value="sftp" {% if storage_provider == "sftp" %}selected{% endif %}>SFTP</option>
|
||||
<option value="sharepoint" {% if storage_provider == "sharepoint" %}selected{% endif %}>SharePoint</option>
|
||||
<option value="icloud" {% if storage_provider == "icloud" %}selected{% endif %}>iCloud Drive</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -716,19 +717,19 @@
|
||||
<td>{{ file.mime_type }}</td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ file.processing_status }}">
|
||||
{{ file.processing_status | title }}
|
||||
{% if file.processing_status == 'pending' %}{{ _("common.pending") }}{% elif file.processing_status == 'processing' %}{{ _("common.processing") }}{% elif file.processing_status == 'completed' %}{{ _("common.completed") }}{% elif file.processing_status == 'failed' %}{{ _("common.failed") }}{% elif file.processing_status == 'duplicate' %}{{ _("files.status_duplicate") }}{% else %}{{ file.processing_status | title }}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else 'N/A' }}</td>
|
||||
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else _('common.not_available') }}</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="Quick preview" aria-label="Preview {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="{{ _('files.action_preview') }}" aria-label="{{ _('files.action_preview') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details" aria-label="View details for {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="{{ _('files.action_details') }}" aria-label="{{ _('files.action_details') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="Delete file" aria-label="Delete {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="{{ _('files.action_delete') }}" aria-label="{{ _('files.action_delete') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -752,25 +753,25 @@
|
||||
tabindex="0"
|
||||
onclick="viewFileDetail({{ file.id }}, event)"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){viewFileDetail({{ file.id }}, event);}"
|
||||
aria-label="View details for {{ file.original_filename }}"
|
||||
aria-label="{{ _('files.action_details') }} {{ file.original_filename }}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-gray-800 truncate">{{ file.original_filename }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">ID: {{ file.id }} · {{ (file.file_size / 1024) | round(2) }} KB</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ file.mime_type }}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else 'N/A' }}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else _('common.not_available') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<span class="status-badge status-{{ file.processing_status }}">{{ file.processing_status | title }}</span>
|
||||
<div class="flex gap-1" role="group" aria-label="File actions" onclick="event.stopPropagation();" onkeydown="event.stopPropagation();">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="Quick preview" aria-label="Preview {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<span class="status-badge status-{{ file.processing_status }}">{% if file.processing_status == 'pending' %}{{ _("common.pending") }}{% elif file.processing_status == 'processing' %}{{ _("common.processing") }}{% elif file.processing_status == 'completed' %}{{ _("common.completed") }}{% elif file.processing_status == 'failed' %}{{ _("common.failed") }}{% elif file.processing_status == 'duplicate' %}{{ _("files.status_duplicate") }}{% else %}{{ file.processing_status | title }}{% endif %}</span>
|
||||
<div class="flex gap-1" role="group" aria-label="{{ _('files.file_actions_aria') }}" onclick="event.stopPropagation();" onkeydown="event.stopPropagation();">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="{{ _('files.action_preview') }}" aria-label="{{ _('files.action_preview') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details" aria-label="View details for {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="{{ _('files.action_details') }}" aria-label="{{ _('files.action_details') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="Delete file" aria-label="Delete {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="{{ _('files.action_delete') }}" aria-label="{{ _('files.action_delete') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -841,6 +842,54 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Translations for JavaScript ──
|
||||
const __i18n = {
|
||||
previewFallback: {{ _("files.js_preview_fallback") | tojson }},
|
||||
fullView: {{ _("files.js_full_view") | tojson }},
|
||||
download: {{ _("files.js_download") | tojson }},
|
||||
loading: {{ _("files.js_loading") | tojson }},
|
||||
failedLoadPdf: {{ _("files.js_failed_load_pdf") | tojson }},
|
||||
pdfPageInfo: {{ _("files.js_pdf_page_info") | tojson }},
|
||||
failedLoadFile: {{ _("files.js_failed_load_file") | tojson }},
|
||||
openFullViewAria: {{ _("files.js_open_full_view_aria") | tojson }},
|
||||
downloadFileAria: {{ _("files.js_download_file_aria") | tojson }},
|
||||
failedDeleteFile: {{ _("files.js_failed_delete_file") | tojson }},
|
||||
errorDeletingFile: {{ _("files.js_error_deleting_file") | tojson }},
|
||||
noSavedSearches: {{ _("files.js_no_saved_searches") | tojson }},
|
||||
loadSavedSearchesError: {{ _("files.js_load_saved_searches_error") | tojson }},
|
||||
noFiltersToSave: {{ _("files.js_no_filters_to_save") | tojson }},
|
||||
enterSearchName: {{ _("files.js_enter_search_name") | tojson }},
|
||||
deleteSavedSearchConfirm: {{ _("files.js_delete_saved_search_confirm") | tojson }},
|
||||
deleteSavedSearchAria: {{ _("files.js_delete_saved_search_aria") | tojson }},
|
||||
selectFilesDelete: {{ _("files.js_select_files_delete") | tojson }},
|
||||
confirmDeleteFiles: {{ _("files.js_confirm_delete_files") | tojson }},
|
||||
failedDeleteFiles: {{ _("files.js_failed_delete_files") | tojson }},
|
||||
errorDeletingFiles: {{ _("files.js_error_deleting_files") | tojson }},
|
||||
selectFilesReprocess: {{ _("files.js_select_files_reprocess") | tojson }},
|
||||
confirmReprocessFiles: {{ _("files.js_confirm_reprocess_files") | tojson }},
|
||||
failedReprocessFiles: {{ _("files.js_failed_reprocess_files") | tojson }},
|
||||
errorReprocessingFiles: {{ _("files.js_error_reprocessing_files") | tojson }},
|
||||
selectFilesCloudOcr: {{ _("files.js_select_files_cloud_ocr") | tojson }},
|
||||
confirmCloudOcr: {{ _("files.js_confirm_cloud_ocr") | tojson }},
|
||||
failedQueueCloudOcr: {{ _("files.js_failed_queue_cloud_ocr") | tojson }},
|
||||
errorQueuingCloudOcr: {{ _("files.js_error_queuing_cloud_ocr") | tojson }},
|
||||
selectFilesDownload: {{ _("files.js_select_files_download") | tojson }},
|
||||
failedCreateZip: {{ _("files.js_failed_create_zip") | tojson }},
|
||||
errorDownloadingFiles: {{ _("files.js_error_downloading_files") | tojson }},
|
||||
searching: {{ _("files.js_searching") | tojson }},
|
||||
searchUnavailable: {{ _("files.js_search_unavailable") | tojson }},
|
||||
searchResultsCount: {{ _("files.js_search_results_count") | tojson }},
|
||||
noResults: {{ _("files.js_no_results") | tojson }},
|
||||
untitled: {{ _("files.js_untitled") | tojson }},
|
||||
viewFile: {{ _("files.js_view_file") | tojson }},
|
||||
prevPage: {{ _("files.js_prev_page") | tojson }},
|
||||
pageInfo: {{ _("files.js_page_info") | tojson }},
|
||||
nextPage: {{ _("files.js_next_page") | tojson }},
|
||||
zoomIn: {{ _("files.js_zoom_in") | tojson }},
|
||||
zoomOut: {{ _("files.js_zoom_out") | tojson }},
|
||||
resetZoom: {{ _("files.js_reset_zoom") | tojson }},
|
||||
};
|
||||
|
||||
// Modal functionality
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const cancelDelete = document.getElementById('cancelDelete');
|
||||
@@ -868,7 +917,7 @@
|
||||
const title = document.getElementById('previewTitle');
|
||||
const body = document.getElementById('previewBody');
|
||||
const footer = document.getElementById('previewFooter');
|
||||
title.textContent = filename || 'Preview';
|
||||
title.textContent = filename || __i18n.previewFallback;
|
||||
body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:300px;color:#9ca3af;"><i class="fas fa-spinner fa-spin" style="font-size:2rem;"></i></div>';
|
||||
footer.innerHTML = '';
|
||||
_pmPdf = { doc: null, page: 1, total: 0 };
|
||||
@@ -877,8 +926,8 @@
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
footer.innerHTML =
|
||||
`<a href="/files/${fileId}" aria-label="Open full view"><i class="fas fa-expand" aria-hidden="true"></i> Full view</a>` +
|
||||
`<a href="/api/files/${fileId}/download?version=processed" aria-label="Download file"><i class="fas fa-download" aria-hidden="true"></i> Download</a>`;
|
||||
`<a href="/files/${fileId}" aria-label="${__i18n.openFullViewAria}"><i class="fas fa-expand" aria-hidden="true"></i> ${__i18n.fullView}</a>` +
|
||||
`<a href="/api/files/${fileId}/download?version=processed" aria-label="${__i18n.downloadFileAria}"><i class="fas fa-download" aria-hidden="true"></i> ${__i18n.download}</a>`;
|
||||
|
||||
// Try "processed" first; fall back to "original" on 404
|
||||
_pmLoadPreview(body, fileId, mime, filename, 'processed');
|
||||
@@ -911,7 +960,7 @@
|
||||
body.innerHTML =
|
||||
'<div class="pm-pdf-controls">' +
|
||||
'<button onclick="_pmPdfPage(-1)" id="pm-prev" aria-label="Previous page"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>' +
|
||||
'<span id="pm-page-info" style="font-size:0.8rem;min-width:100px;text-align:center;">Loading…</span>' +
|
||||
'<span id="pm-page-info" style="font-size:0.8rem;min-width:100px;text-align:center;">' + __i18n.loading + '</span>' +
|
||||
'<button onclick="_pmPdfPage(1)" id="pm-next" aria-label="Next page"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>' +
|
||||
'</div>' +
|
||||
'<div class="pm-canvas-wrap" id="pm-canvas-wrap"><div style="text-align:center;padding:3rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin" style="font-size:2rem;"></i></div></div>';
|
||||
@@ -923,7 +972,7 @@
|
||||
_pmPdfRender();
|
||||
}).catch(function() {
|
||||
if (onError) { onError(); return; }
|
||||
document.getElementById('pm-canvas-wrap').innerHTML = '<div style="text-align:center;padding:2rem;color:#f87171;"><i class="fas fa-exclamation-triangle" style="font-size:2rem;margin-bottom:0.5rem;"></i><p>Failed to load PDF</p></div>';
|
||||
document.getElementById('pm-canvas-wrap').innerHTML = '<div style="text-align:center;padding:2rem;color:#f87171;"><i class="fas fa-exclamation-triangle" style="font-size:2rem;margin-bottom:0.5rem;"></i><p>' + __i18n.failedLoadPdf + '</p></div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -953,7 +1002,7 @@
|
||||
|
||||
function _pmPdfUpdateInfo() {
|
||||
var info = document.getElementById('pm-page-info');
|
||||
if (info) info.textContent = 'Page ' + _pmPdf.page + ' of ' + _pmPdf.total;
|
||||
if (info) info.textContent = __i18n.pdfPageInfo.replace('{page}', _pmPdf.page).replace('{total}', _pmPdf.total);
|
||||
var prev = document.getElementById('pm-prev');
|
||||
var next = document.getElementById('pm-next');
|
||||
if (prev) prev.disabled = _pmPdf.page <= 1;
|
||||
@@ -964,12 +1013,12 @@
|
||||
function _pmRenderImage(body, url, alt) {
|
||||
body.innerHTML =
|
||||
'<div class="pm-img-controls">' +
|
||||
'<button onclick="_pmImgZoom(1.25)" title="Zoom in" aria-label="Zoom in"><i class="fas fa-search-plus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgZoom(0.8)" title="Zoom out" aria-label="Zoom out"><i class="fas fa-search-minus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgReset()" title="Reset" aria-label="Reset zoom"><i class="fas fa-expand" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgZoom(1.25)" title="' + __i18n.zoomIn + '" aria-label="' + __i18n.zoomIn + '"><i class="fas fa-search-plus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgZoom(0.8)" title="' + __i18n.zoomOut + '" aria-label="' + __i18n.zoomOut + '"><i class="fas fa-search-minus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgReset()" title="' + __i18n.resetZoom + '" aria-label="' + __i18n.resetZoom + '"><i class="fas fa-expand" aria-hidden="true"></i></button>' +
|
||||
'<span id="pm-zoom-level" style="font-size:0.8rem;color:#6b7280;">100%</span>' +
|
||||
'</div>' +
|
||||
'<div class="pm-img-wrap" id="pm-img-wrap"><img id="pm-preview-img" src="' + url + '" alt="' + (alt || 'Preview') + '" draggable="false" style="max-width:100%;height:auto;"></div>';
|
||||
'<div class="pm-img-wrap" id="pm-img-wrap"><img id="pm-preview-img" src="' + url + '" alt="' + (alt || __i18n.previewFallback) + '" draggable="false" style="max-width:100%;height:auto;"></div>';
|
||||
// Drag-pan
|
||||
var wrap = document.getElementById('pm-img-wrap');
|
||||
if (wrap) {
|
||||
@@ -1000,7 +1049,7 @@
|
||||
|
||||
// Text rendering inside preview modal
|
||||
function _pmRenderText(body, url, onError) {
|
||||
body.innerHTML = '<div class="pm-text-content" id="pm-text-content"><div style="text-align:center;padding:2rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin"></i> Loading…</div></div>';
|
||||
body.innerHTML = '<div class="pm-text-content" id="pm-text-content"><div style="text-align:center;padding:2rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin"></i> ' + __i18n.loading + '</div></div>';
|
||||
fetch(url)
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
||||
.then(function(text) {
|
||||
@@ -1021,7 +1070,7 @@
|
||||
.catch(function() {
|
||||
if (onError) { onError(); return; }
|
||||
var c = document.getElementById('pm-text-content');
|
||||
if (c) c.innerHTML = '<div style="color:#f87171;padding:1rem;">Failed to load file</div>';
|
||||
if (c) c.innerHTML = '<div style="color:#f87171;padding:1rem;">' + __i18n.failedLoadFile + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1065,7 +1114,7 @@
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to delete file');
|
||||
throw new Error(err.detail || __i18n.failedDeleteFile);
|
||||
});
|
||||
} else {
|
||||
// Non-JSON response (likely HTML error page)
|
||||
@@ -1084,7 +1133,7 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error deleting file: ${error.message}`);
|
||||
alert(__i18n.errorDeletingFile.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1124,14 +1173,14 @@
|
||||
const container = document.getElementById('saved-searches-list');
|
||||
if (!container) return;
|
||||
if (!searches || searches.length === 0) {
|
||||
container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">No saved searches yet</span>';
|
||||
container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">' + __i18n.noSavedSearches + '</span>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = searches.map(s => {
|
||||
const params = new URLSearchParams(s.filters);
|
||||
return `<span class="saved-search-tag" style="display: inline-flex; align-items: center; gap: 0.25rem; background: var(--bg-tertiary, #e5e7eb); padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.8rem;">
|
||||
<a href="/files?${params.toString()}" style="text-decoration: none; color: inherit;">${s.name}</a>
|
||||
<button type="button" onclick="deleteSavedSearch(${s.id})" style="border: none; background: none; cursor: pointer; color: var(--text-muted); padding: 0; line-height: 1;" aria-label="Delete saved search ${s.name}">
|
||||
<button type="button" onclick="deleteSavedSearch(${s.id})" style="border: none; background: none; cursor: pointer; color: var(--text-muted); padding: 0; line-height: 1;" aria-label="${__i18n.deleteSavedSearchAria.replace('{name}', s.name)}">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</span>`;
|
||||
@@ -1139,7 +1188,7 @@
|
||||
})
|
||||
.catch(() => {
|
||||
const container = document.getElementById('saved-searches-list');
|
||||
if (container) container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">Could not load saved searches</span>';
|
||||
if (container) container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">' + __i18n.loadSavedSearchesError + '</span>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1152,10 +1201,10 @@
|
||||
if (val) filters[key] = val;
|
||||
});
|
||||
if (Object.keys(filters).length === 0) {
|
||||
alert('No filters to save. Please apply at least one filter (search, date, status, tags, etc.) before saving.');
|
||||
alert(__i18n.noFiltersToSave);
|
||||
return;
|
||||
}
|
||||
const name = prompt('Enter a name for this saved search:');
|
||||
const name = prompt(__i18n.enterSearchName);
|
||||
if (!name || !name.trim()) return;
|
||||
fetch('/api/saved-searches', {
|
||||
method: 'POST',
|
||||
@@ -1171,7 +1220,7 @@
|
||||
}
|
||||
|
||||
function deleteSavedSearch(id) {
|
||||
if (!confirm('Delete this saved search?')) return;
|
||||
if (!confirm(__i18n.deleteSavedSearchConfirm)) return;
|
||||
fetch(`/api/saved-searches/${id}`, { method: 'DELETE' })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to delete');
|
||||
@@ -1226,11 +1275,11 @@
|
||||
function bulkDelete() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to delete');
|
||||
alert(__i18n.selectFilesDelete);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${fileIds.length} file(s)?`)) {
|
||||
if (!confirm(__i18n.confirmDeleteFiles.replace('{count}', fileIds.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1244,7 +1293,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to delete files');
|
||||
throw new Error(err.detail || __i18n.failedDeleteFiles);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
@@ -1255,18 +1304,18 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error deleting files: ${error.message}`);
|
||||
alert(__i18n.errorDeletingFiles.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
function bulkReprocess() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to reprocess');
|
||||
alert(__i18n.selectFilesReprocess);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to reprocess ${fileIds.length} file(s)?`)) {
|
||||
if (!confirm(__i18n.confirmReprocessFiles.replace('{count}', fileIds.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1280,7 +1329,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to reprocess files');
|
||||
throw new Error(err.detail || __i18n.failedReprocessFiles);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
@@ -1297,18 +1346,18 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error reprocessing files: ${error.message}`);
|
||||
alert(__i18n.errorReprocessingFiles.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
function bulkReprocessCloudOcr() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to re-run Cloud OCR on');
|
||||
alert(__i18n.selectFilesCloudOcr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to re-run Cloud OCR on ${fileIds.length} file(s)?`)) {
|
||||
if (!confirm(__i18n.confirmCloudOcr.replace('{count}', fileIds.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1322,7 +1371,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to queue Cloud OCR');
|
||||
throw new Error(err.detail || __i18n.failedQueueCloudOcr);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
@@ -1339,14 +1388,14 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error queuing Cloud OCR: ${error.message}`);
|
||||
alert(__i18n.errorQueuingCloudOcr.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
function bulkDownload() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to download');
|
||||
alert(__i18n.selectFilesDownload);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1360,7 +1409,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to create ZIP download');
|
||||
throw new Error(err.detail || __i18n.failedCreateZip);
|
||||
});
|
||||
}
|
||||
return response.blob();
|
||||
@@ -1377,7 +1426,7 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error downloading files: ${error.message}`);
|
||||
alert(__i18n.errorDownloadingFiles.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1471,7 +1520,7 @@
|
||||
const summary = document.getElementById('search-results-summary');
|
||||
const pagination = document.getElementById('search-results-pagination');
|
||||
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;"><i class="fas fa-spinner fa-spin"></i> Searching…</div>';
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;"><i class="fas fa-spinner fa-spin"></i> ' + __i18n.searching + '</div>';
|
||||
summary.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
panel.style.display = 'block';
|
||||
@@ -1484,7 +1533,7 @@
|
||||
})
|
||||
.then(data => renderSearchResults(data, q))
|
||||
.catch(err => {
|
||||
list.innerHTML = `<div style="padding: 1rem; color: #dc2626; font-size: 0.875rem;"><i class="fas fa-exclamation-triangle"></i> Search unavailable: ${err.message}</div>`;
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #dc2626; font-size: 0.875rem;"><i class="fas fa-exclamation-triangle"></i> ' + __i18n.searchUnavailable.replace('{error}', err.message) + '</div>';
|
||||
summary.textContent = '';
|
||||
});
|
||||
}
|
||||
@@ -1496,17 +1545,17 @@
|
||||
const pagination = document.getElementById('search-results-pagination');
|
||||
|
||||
const { results, total, page, pages } = data;
|
||||
summary.textContent = `${total} result${total !== 1 ? 's' : ''} for "${q}"`;
|
||||
summary.textContent = __i18n.searchResultsCount.replace('{total}', total).replace('{query}', q);
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;">No results found.</div>';
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;">' + __i18n.noResults + '</div>';
|
||||
pagination.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = results.map(hit => {
|
||||
const fmt = hit._formatted || {};
|
||||
const title = fmt.document_title || hit.document_title || hit.original_filename || '(untitled)';
|
||||
const title = fmt.document_title || hit.document_title || hit.original_filename || __i18n.untitled;
|
||||
const filename = fmt.original_filename || hit.original_filename || '';
|
||||
const snippet = fmt.ocr_text || '';
|
||||
const tags = Array.isArray(hit.tags) ? hit.tags.join(', ') : (hit.tags || '');
|
||||
@@ -1524,7 +1573,7 @@
|
||||
${snippet ? `<div style="margin-top: 0.4rem; font-size: 0.8rem; color: #374151; white-space: pre-wrap; word-break: break-word;">…${snippet}…</div>` : ''}
|
||||
</div>
|
||||
<div style="flex-shrink: 0;">
|
||||
<a href="/files/${hit.file_id}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="View file">
|
||||
<a href="/files/${hit.file_id}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="${__i18n.viewFile}">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -1535,11 +1584,11 @@
|
||||
if (pages > 1) {
|
||||
const btns = [];
|
||||
if (page > 1) {
|
||||
btns.push(`<button onclick="runFullTextSearch(${page - 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">« Prev</button>`);
|
||||
btns.push(`<button onclick="runFullTextSearch(${page - 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">${__i18n.prevPage}</button>`);
|
||||
}
|
||||
btns.push(`<span style="padding: 0.25rem 0.6rem; font-size: 0.8rem; color: #6b7280;">Page ${page} / ${pages}</span>`);
|
||||
btns.push(`<span style="padding: 0.25rem 0.6rem; font-size: 0.8rem; color: #6b7280;">${__i18n.pageInfo.replace('{page}', page).replace('{pages}', pages)}</span>`);
|
||||
if (page < pages) {
|
||||
btns.push(`<button onclick="runFullTextSearch(${page + 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">Next »</button>`);
|
||||
btns.push(`<button onclick="runFullTextSearch(${page + 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">${__i18n.nextPage}</button>`);
|
||||
}
|
||||
pagination.innerHTML = btns.join('');
|
||||
} else {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"name": "Which cloud storage providers does DocuElevate support?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "DocuElevate integrates with Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, WebDAV, SFTP, FTP, and Paperless-ngx."
|
||||
"text": "DocuElevate integrates with Dropbox, Google Drive, OneDrive, SharePoint, Amazon S3, Nextcloud, WebDAV, SFTP, FTP, and Paperless-ngx."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -188,6 +188,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start bg-white shadow rounded-lg p-5">
|
||||
<i class="fab fa-microsoft text-purple-600 text-xl mr-3 mt-0.5" aria-hidden="true"></i>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 text-sm">SharePoint</h3>
|
||||
<p class="text-gray-500 text-xs">Upload to SharePoint Online document libraries via Graph API.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start bg-white shadow rounded-lg p-5">
|
||||
<i class="fab fa-aws text-yellow-600 text-xl mr-3 mt-0.5" aria-hidden="true"></i>
|
||||
<div>
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
>
|
||||
<option value="">{{ _("profile.language_auto_detect") }}</option>
|
||||
{% for lang in supported_languages %}
|
||||
<option value="{{ lang.code }}">{{ lang.flag }} {{ lang.native }} ({{ lang.name }})</option>
|
||||
<option value="{{ lang.code }}">{{ lang.native }} ({{ lang.name }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -231,6 +231,28 @@
|
||||
{{ _("profile.theme_hint") }}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Default Document Language -->
|
||||
<div>
|
||||
<label for="doc-lang-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-language text-gray-400 mr-1" aria-hidden="true"></i>{{ _("profile.default_document_language_label") }}
|
||||
</label>
|
||||
<select
|
||||
id="doc-lang-select"
|
||||
x-model="form.default_document_language"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<option value="">{{ _("profile.default_document_language_auto") }}</option>
|
||||
{% for lang in supported_languages %}
|
||||
<option value="{{ lang.code }}">{{ lang.native }} ({{ lang.name }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ _("profile.default_document_language_hint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -321,9 +343,203 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ── Security & Sessions card ──────────────────────────────────────── -->
|
||||
<section
|
||||
class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6"
|
||||
aria-labelledby="security-heading"
|
||||
x-data="sessionManager()"
|
||||
x-init="loadSessions()"
|
||||
>
|
||||
<h2 id="security-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-1">
|
||||
<i class="fas fa-shield-alt text-gray-400 mr-2" aria-hidden="true"></i>{{ _("sessions.security_heading") }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{{ _("sessions.security_subtitle") }}
|
||||
</p>
|
||||
|
||||
<!-- Session lifetime info -->
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mb-4" x-show="lifetimeDays > 0">
|
||||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>
|
||||
<span x-text="'{{ _("sessions.session_lifetime") }}'.replace('{days}', lifetimeDays)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Active sessions list -->
|
||||
<div class="space-y-3 mb-5">
|
||||
<template x-for="session in sessions" :key="session.id">
|
||||
<div
|
||||
class="flex items-center justify-between border border-gray-200 dark:border-gray-700 rounded-lg p-3"
|
||||
:class="session.is_current ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700' : ''"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<i
|
||||
:class="session.device_info && session.device_info.includes('iPhone') ? 'fas fa-mobile-alt' :
|
||||
session.device_info && session.device_info.includes('iPad') ? 'fas fa-tablet-alt' :
|
||||
session.device_info && session.device_info.includes('Android') ? 'fas fa-mobile-alt' :
|
||||
session.device_info && session.device_info.includes('App') ? 'fas fa-mobile-alt' :
|
||||
'fas fa-desktop'"
|
||||
class="text-gray-400 text-lg flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<span x-text="session.device_info || 'Unknown Device'"></span>
|
||||
<span
|
||||
x-show="session.is_current"
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>{{ _("sessions.current_session") }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-x-3">
|
||||
<span x-show="session.ip_address">
|
||||
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="session.ip_address"></span>
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>{{ _("sessions.last_active") }}
|
||||
<span x-text="timeAgo(session.last_active_at)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
x-show="!session.is_current"
|
||||
@click="revokeSession(session.id)"
|
||||
class="flex-shrink-0 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium px-3 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition"
|
||||
style="min-height:44px; min-width:44px;"
|
||||
:aria-label="'{{ _("sessions.revoke") }}'"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt mr-1" aria-hidden="true"></i>{{ _("sessions.revoke") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<p
|
||||
x-show="sessions.length <= 1"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 italic"
|
||||
>{{ _("sessions.no_other_sessions") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Log off everywhere + QR login row -->
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
@click="revokeAllSessions()"
|
||||
class="inline-flex items-center justify-center px-4 py-2 border border-red-300 dark:border-red-700 rounded-lg text-sm font-medium text-red-700 dark:text-red-300 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition"
|
||||
style="min-height:44px;"
|
||||
:disabled="revoking"
|
||||
>
|
||||
<i class="fas fa-power-off mr-2" aria-hidden="true"></i>
|
||||
<span x-text="revoking ? '{{ _("profile.saving") }}' : '{{ _("sessions.log_off_everywhere") }}'"></span>
|
||||
</button>
|
||||
<a
|
||||
href="/qr-login"
|
||||
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-qrcode mr-2" aria-hidden="true"></i>
|
||||
{{ _("sessions.qr_login_link") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Status banner for session actions -->
|
||||
<div
|
||||
x-show="sessionBanner.visible"
|
||||
x-transition
|
||||
class="mt-4 rounded-lg p-3 text-sm"
|
||||
:class="sessionBanner.error
|
||||
? 'bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-300 dark:border-red-700'
|
||||
: 'bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-300 dark:border-green-700'"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span x-text="sessionBanner.message"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /container -->
|
||||
|
||||
<script>
|
||||
/* ── Session Manager Alpine component ──────────────────────────────────── */
|
||||
function sessionManager() {
|
||||
return {
|
||||
sessions: [],
|
||||
lifetimeDays: 0,
|
||||
revoking: false,
|
||||
sessionBanner: { visible: false, error: false, message: '' },
|
||||
|
||||
_csrfToken() {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf_token='))
|
||||
?.split('=')[1];
|
||||
},
|
||||
|
||||
async loadSessions() {
|
||||
try {
|
||||
const res = await fetch('/api/sessions/');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.sessions = data.sessions || [];
|
||||
this.lifetimeDays = data.session_lifetime_days || 30;
|
||||
}
|
||||
} catch (_e) { /* silently ignore */ }
|
||||
},
|
||||
|
||||
async revokeSession(sessionId) {
|
||||
if (!confirm({{ _("sessions.confirm_revoke_one") | tojson }})) return;
|
||||
const csrf = this._csrfToken();
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
|
||||
});
|
||||
if (res.ok || res.status === 204) {
|
||||
this.sessions = this.sessions.filter(s => s.id !== sessionId);
|
||||
this._showSessionBanner({{ _("sessions.revoked_success") | tojson }}, false);
|
||||
}
|
||||
} catch (_e) {
|
||||
this._showSessionBanner('Network error — please try again.', true);
|
||||
}
|
||||
},
|
||||
|
||||
async revokeAllSessions() {
|
||||
if (!confirm({{ _("sessions.confirm_revoke_all") | tojson }})) return;
|
||||
this.revoking = true;
|
||||
const csrf = this._csrfToken();
|
||||
try {
|
||||
const res = await fetch('/api/sessions/revoke-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrf ? { 'X-CSRF-Token': csrf } : {}),
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
await this.loadSessions();
|
||||
this._showSessionBanner({{ _("sessions.revoked_all_success") | tojson }}, false);
|
||||
}
|
||||
} catch (_e) {
|
||||
this._showSessionBanner('Network error — please try again.', true);
|
||||
} finally {
|
||||
this.revoking = false;
|
||||
}
|
||||
},
|
||||
|
||||
timeAgo(dateStr) {
|
||||
if (!dateStr) return 'unknown';
|
||||
const now = new Date();
|
||||
const then = new Date(dateStr);
|
||||
const diff = Math.floor((now - then) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
},
|
||||
|
||||
_showSessionBanner(msg, err) {
|
||||
this.sessionBanner = { visible: true, error: err, message: msg };
|
||||
if (!err) setTimeout(() => { this.sessionBanner.visible = false; }, 4000);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Profile Settings Alpine component ─────────────────────────────────── */
|
||||
function profileSettings() {
|
||||
return {
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
@@ -340,6 +556,7 @@ function profileSettings() {
|
||||
contact_email: '',
|
||||
preferred_language: '',
|
||||
preferred_theme: 'system',
|
||||
default_document_language: '',
|
||||
},
|
||||
|
||||
pwForm: {
|
||||
@@ -363,6 +580,7 @@ function profileSettings() {
|
||||
this.form.contact_email = data.contact_email || '';
|
||||
this.form.preferred_language = data.preferred_language || '';
|
||||
this.form.preferred_theme = data.preferred_theme || 'system';
|
||||
this.form.default_document_language = data.default_document_language || '';
|
||||
this._initialLanguage = this.form.preferred_language;
|
||||
} catch (_e) {
|
||||
// Silently ignore — user might not be logged in (rare for this page)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _("qr_login.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
x-data="qrLoginPage()"
|
||||
x-init="generateChallenge()"
|
||||
class="container mx-auto px-4 py-8 max-w-xl"
|
||||
>
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<header class="mb-8 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center justify-center gap-2">
|
||||
<i class="fas fa-qrcode text-blue-500" aria-hidden="true"></i>
|
||||
{{ _("qr_login.heading") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ _("qr_login.subtitle") }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- ── QR Code Card ───────────────────────────────────────────────────── -->
|
||||
<section
|
||||
class="bg-white dark:bg-gray-800 shadow rounded-lg p-8 mb-6 text-center"
|
||||
aria-labelledby="qr-heading"
|
||||
>
|
||||
<!-- Pending state: show QR code -->
|
||||
<template x-if="status === 'pending'">
|
||||
<div>
|
||||
<div
|
||||
class="mx-auto mb-4 bg-white p-4 inline-block rounded-lg shadow-inner"
|
||||
id="qr-container"
|
||||
aria-label="{{ _('qr_login.description') }}"
|
||||
>
|
||||
<img
|
||||
:src="qrCodeSvg"
|
||||
width="256"
|
||||
height="256"
|
||||
alt="{{ _('qr_login.description') }}"
|
||||
id="qr-image"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{{ _("qr_login.description") }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
<i class="fas fa-hourglass-half animate-pulse" aria-hidden="true"></i>
|
||||
<span x-text="'{{ _("qr_login.time_remaining") }}'.replace('{seconds}', countdown)"></span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-blue-600 dark:text-blue-400">
|
||||
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
|
||||
{{ _("qr_login.pending_message") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Claimed state: success -->
|
||||
<template x-if="status === 'claimed'">
|
||||
<div class="py-8">
|
||||
<i class="fas fa-check-circle text-green-500 text-5xl mb-4" aria-hidden="true"></i>
|
||||
<p class="text-lg font-semibold text-green-700 dark:text-green-400 mb-2">
|
||||
{{ _("qr_login.claimed_message") }}
|
||||
</p>
|
||||
<p x-show="deviceName" class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="'{{ _("qr_login.claimed_device") }}'.replace('{device_name}', deviceName)">
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Expired state -->
|
||||
<template x-if="status === 'expired'">
|
||||
<div class="py-8">
|
||||
<i class="fas fa-clock text-yellow-500 text-5xl mb-4" aria-hidden="true"></i>
|
||||
<p class="text-base text-gray-700 dark:text-gray-300 mb-4">
|
||||
{{ _("qr_login.expired_message") }}
|
||||
</p>
|
||||
<button
|
||||
@click="generateChallenge()"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-redo mr-2" aria-hidden="true"></i>
|
||||
{{ _("qr_login.generate_new") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template x-if="status === 'error'">
|
||||
<div class="py-8">
|
||||
<i class="fas fa-exclamation-triangle text-red-500 text-5xl mb-4" aria-hidden="true"></i>
|
||||
<p class="text-base text-gray-700 dark:text-gray-300 mb-4" x-text="errorMsg"></p>
|
||||
<button
|
||||
@click="generateChallenge()"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-redo mr-2" aria-hidden="true"></i>
|
||||
{{ _("qr_login.generate_new") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── How it works ───────────────────────────────────────────────────── -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-info-circle text-gray-400 mr-2" aria-hidden="true"></i>
|
||||
{{ _("qr_login.how_it_works") }}
|
||||
</h2>
|
||||
<ol class="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li>{{ _("qr_login.step_1") }}</li>
|
||||
<li>{{ _("qr_login.step_2") }}</li>
|
||||
<li>{{ _("qr_login.step_3") }}</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- QR code is rendered server-side; no external QR library needed -->
|
||||
|
||||
<script>
|
||||
function qrLoginPage() {
|
||||
return {
|
||||
status: 'loading', // loading | pending | claimed | expired | error
|
||||
challengeId: null,
|
||||
challengeToken: '',
|
||||
qrPayload: '',
|
||||
qrCodeSvg: '',
|
||||
expiresAt: null,
|
||||
countdown: 0,
|
||||
deviceName: '',
|
||||
errorMsg: '',
|
||||
_pollTimer: null,
|
||||
_countdownTimer: null,
|
||||
_ttlSeconds: 0,
|
||||
_receivedAt: null,
|
||||
|
||||
_csrfToken() {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf_token='))
|
||||
?.split('=')[1];
|
||||
},
|
||||
|
||||
async generateChallenge() {
|
||||
this.status = 'loading';
|
||||
this._stopTimers();
|
||||
const csrf = this._csrfToken();
|
||||
try {
|
||||
const res = await fetch('/api/qr-auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrf ? { 'X-CSRF-Token': csrf } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.status = 'error';
|
||||
this.errorMsg = 'Failed to generate QR code. Please try again.';
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
this.challengeId = data.challenge_id;
|
||||
this.challengeToken = data.challenge_token;
|
||||
this.qrPayload = data.qr_payload;
|
||||
this.qrCodeSvg = data.qr_code_svg;
|
||||
this.expiresAt = new Date(data.expires_at);
|
||||
this._ttlSeconds = data.ttl_seconds || 120;
|
||||
this._receivedAt = Date.now();
|
||||
this.status = 'pending';
|
||||
this.deviceName = '';
|
||||
|
||||
// Start polling and countdown
|
||||
this._startPolling();
|
||||
this._startCountdown();
|
||||
} catch (_e) {
|
||||
this.status = 'error';
|
||||
this.errorMsg = 'Network error — please check your connection and try again.';
|
||||
}
|
||||
},
|
||||
|
||||
_startPolling() {
|
||||
this._pollTimer = setInterval(async () => {
|
||||
if (this.status !== 'pending') { this._stopTimers(); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/qr-auth/challenge/${this.challengeId}/status`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.status === 'claimed') {
|
||||
this.status = 'claimed';
|
||||
this.deviceName = data.device_name || '';
|
||||
this._stopTimers();
|
||||
} else if (data.status === 'expired') {
|
||||
this.status = 'expired';
|
||||
this._stopTimers();
|
||||
} else if (data.status === 'cancelled') {
|
||||
this.status = 'expired';
|
||||
this._stopTimers();
|
||||
}
|
||||
} catch (_e) { /* ignore transient errors */ }
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
_startCountdown() {
|
||||
this._updateCountdown();
|
||||
this._countdownTimer = setInterval(() => {
|
||||
this._updateCountdown();
|
||||
if (this.countdown <= 0 && this.status === 'pending') {
|
||||
this.status = 'expired';
|
||||
this._stopTimers();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
_updateCountdown() {
|
||||
if (!this._receivedAt) { this.countdown = 0; return; }
|
||||
const elapsed = (Date.now() - this._receivedAt) / 1000;
|
||||
const remaining = Math.max(0, Math.floor(this._ttlSeconds - elapsed));
|
||||
this.countdown = remaining;
|
||||
},
|
||||
|
||||
_stopTimers() {
|
||||
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
||||
if (this._countdownTimer) { clearInterval(this._countdownTimer); this._countdownTimer = null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,261 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _("system_reset.page_title") }} - DocuElevate{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.reset-card { transition: box-shadow 0.2s ease; }
|
||||
.reset-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.08); }
|
||||
.confirmation-input { font-family: 'Courier New', monospace; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8" x-data="systemResetApp()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-skull-crossbones mr-2 text-red-600" aria-hidden="true"></i>
|
||||
{{ _("system_reset.heading") }}
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ _("system_reset.subtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Factory Reset on Startup banner -->
|
||||
{% if factory_reset_on_startup %}
|
||||
<div class="mb-6 rounded-lg border border-yellow-300 bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-700 p-4" role="alert">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5" aria-hidden="true"></i>
|
||||
<div>
|
||||
<p class="font-semibold text-yellow-800 dark:text-yellow-200">{{ _("system_reset.startup_reset_active") }}</p>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-1">{{ _("system_reset.startup_reset_desc") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Warning banner -->
|
||||
<div class="mb-8 rounded-lg border-2 border-red-300 bg-red-50 dark:bg-red-900/20 dark:border-red-700 p-6" role="alert">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-radiation text-red-600 text-2xl mt-0.5" aria-hidden="true"></i>
|
||||
<div>
|
||||
<p class="font-bold text-red-800 dark:text-red-200 text-lg">{{ _("system_reset.danger_zone") }}</p>
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mt-1">{{ _("system_reset.danger_desc") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
<!-- Full Reset Card -->
|
||||
<div class="reset-card rounded-xl border-2 border-red-200 dark:border-red-800 bg-white dark:bg-gray-800 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="h-10 w-10 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center">
|
||||
<i class="fas fa-trash-alt text-red-600" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _("system_reset.full_reset_title") }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _("system_reset.full_reset_subtitle") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ _("system_reset.full_reset_desc") }}</p>
|
||||
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4 list-disc">
|
||||
<li>{{ _("system_reset.full_reset_item_db") }}</li>
|
||||
<li>{{ _("system_reset.full_reset_item_files") }}</li>
|
||||
<li>{{ _("system_reset.full_reset_item_cache") }}</li>
|
||||
<li>{{ _("system_reset.full_reset_item_settings_kept") }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label for="fullResetConfirm" class="block text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
|
||||
{{ _("system_reset.type_delete") }}
|
||||
</label>
|
||||
<input id="fullResetConfirm"
|
||||
type="text"
|
||||
x-model="fullResetInput"
|
||||
class="confirmation-input w-full px-3 py-2 border-2 border-red-300 dark:border-red-700 rounded-lg
|
||||
text-center text-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
placeholder="DELETE"
|
||||
autocomplete="off"
|
||||
aria-describedby="fullResetHelp" />
|
||||
<p id="fullResetHelp" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("system_reset.type_delete_help") }}</p>
|
||||
|
||||
<button @click="executeFullReset()"
|
||||
:disabled="fullResetInput !== 'DELETE' || loading"
|
||||
type="button"
|
||||
class="mt-4 w-full inline-flex items-center justify-center px-4 py-3 border border-transparent
|
||||
text-base font-bold rounded-lg text-white
|
||||
bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500
|
||||
disabled:opacity-40 disabled:cursor-not-allowed transition min-h-[44px]"
|
||||
aria-label="{{ _('system_reset.full_reset_button') }}">
|
||||
<template x-if="loading && activeAction === 'full'">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||
</template>
|
||||
<i x-show="!(loading && activeAction === 'full')" class="fas fa-trash-alt mr-2" aria-hidden="true"></i>
|
||||
{{ _("system_reset.full_reset_button") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset & Re-import Card -->
|
||||
<div class="reset-card rounded-xl border-2 border-orange-200 dark:border-orange-800 bg-white dark:bg-gray-800 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="h-10 w-10 rounded-full bg-orange-100 dark:bg-orange-900 flex items-center justify-center">
|
||||
<i class="fas fa-recycle text-orange-600" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _("system_reset.reimport_title") }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _("system_reset.reimport_subtitle") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ _("system_reset.reimport_desc") }}</p>
|
||||
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4 list-decimal">
|
||||
<li>{{ _("system_reset.reimport_step_1") }}</li>
|
||||
<li>{{ _("system_reset.reimport_step_2") }}</li>
|
||||
<li>{{ _("system_reset.reimport_step_3") }}</li>
|
||||
</ol>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 italic">{{ _("system_reset.reimport_note") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label for="reimportConfirm" class="block text-sm font-semibold text-orange-700 dark:text-orange-400 mb-2">
|
||||
{{ _("system_reset.type_reimport") }}
|
||||
</label>
|
||||
<input id="reimportConfirm"
|
||||
type="text"
|
||||
x-model="reimportInput"
|
||||
class="confirmation-input w-full px-3 py-2 border-2 border-orange-300 dark:border-orange-700 rounded-lg
|
||||
text-center text-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="REIMPORT"
|
||||
autocomplete="off"
|
||||
aria-describedby="reimportHelp" />
|
||||
<p id="reimportHelp" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("system_reset.type_reimport_help") }}</p>
|
||||
|
||||
<button @click="executeReimport()"
|
||||
:disabled="reimportInput !== 'REIMPORT' || loading"
|
||||
type="button"
|
||||
class="mt-4 w-full inline-flex items-center justify-center px-4 py-3 border border-transparent
|
||||
text-base font-bold rounded-lg text-white
|
||||
bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500
|
||||
disabled:opacity-40 disabled:cursor-not-allowed transition min-h-[44px]"
|
||||
aria-label="{{ _('system_reset.reimport_button') }}">
|
||||
<template x-if="loading && activeAction === 'reimport'">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||
</template>
|
||||
<i x-show="!(loading && activeAction === 'reimport')" class="fas fa-recycle mr-2" aria-hidden="true"></i>
|
||||
{{ _("system_reset.reimport_button") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result banner (shown after a reset completes) -->
|
||||
<div x-show="resultMessage" x-cloak
|
||||
class="mt-8 rounded-lg p-4"
|
||||
:class="resultSuccess ? 'bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-700' :
|
||||
'bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-700'"
|
||||
:role="resultSuccess ? 'status' : 'alert'" aria-live="polite">
|
||||
<div class="flex items-start gap-3">
|
||||
<i :class="resultSuccess ? 'fas fa-check-circle text-green-600' : 'fas fa-times-circle text-red-600'" aria-hidden="true"></i>
|
||||
<div>
|
||||
<p class="font-semibold" :class="resultSuccess ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'"
|
||||
x-text="resultMessage"></p>
|
||||
<pre x-show="resultDetail" x-text="resultDetail"
|
||||
class="mt-2 text-xs overflow-x-auto whitespace-pre-wrap"
|
||||
:class="resultSuccess ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function systemResetApp() {
|
||||
const __i18n = {
|
||||
successFull: {{ _("system_reset.js_success_full") | tojson }},
|
||||
successReimport: {{ _("system_reset.js_success_reimport") | tojson }},
|
||||
errorGeneric: {{ _("system_reset.js_error_generic") | tojson }},
|
||||
};
|
||||
|
||||
return {
|
||||
fullResetInput: '',
|
||||
reimportInput: '',
|
||||
loading: false,
|
||||
activeAction: null,
|
||||
resultMessage: null,
|
||||
resultDetail: null,
|
||||
resultSuccess: false,
|
||||
|
||||
async executeFullReset() {
|
||||
if (this.fullResetInput !== 'DELETE') return;
|
||||
this.loading = true;
|
||||
this.activeAction = 'full';
|
||||
this.resultMessage = null;
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const resp = await fetch('/api/admin/system-reset/full', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ confirmation: 'DELETE' }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
this.resultSuccess = true;
|
||||
this.resultMessage = __i18n.successFull;
|
||||
this.resultDetail = JSON.stringify(data.result, null, 2);
|
||||
} else {
|
||||
this.resultSuccess = false;
|
||||
this.resultMessage = data.detail || __i18n.errorGeneric;
|
||||
}
|
||||
} catch (err) {
|
||||
this.resultSuccess = false;
|
||||
this.resultMessage = __i18n.errorGeneric;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.fullResetInput = '';
|
||||
}
|
||||
},
|
||||
|
||||
async executeReimport() {
|
||||
if (this.reimportInput !== 'REIMPORT') return;
|
||||
this.loading = true;
|
||||
this.activeAction = 'reimport';
|
||||
this.resultMessage = null;
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const resp = await fetch('/api/admin/system-reset/reimport', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ confirmation: 'REIMPORT' }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
this.resultSuccess = true;
|
||||
this.resultMessage = __i18n.successReimport;
|
||||
this.resultDetail = JSON.stringify(data.result, null, 2);
|
||||
} else {
|
||||
this.resultSuccess = false;
|
||||
this.resultMessage = data.detail || __i18n.errorGeneric;
|
||||
}
|
||||
} catch (err) {
|
||||
this.resultSuccess = false;
|
||||
this.resultMessage = __i18n.errorGeneric;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.reimportInput = '';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -41,6 +41,280 @@
|
||||
"about.story_heading": "Ons Verhaal",
|
||||
"about.story_p1": "DocuElevate is geskep met een doel voor oë: om dokumentbestuur vir almal te vereenvoudig en te stroomlyn, of jy nou 'n klein startup of 'n groot onderneming is.",
|
||||
"about.story_p2": "Ons maak gebruik van die krag van inpropbare KI-verskaffers (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey, en meer) vir metadata-ekstraksie en teksverbetering, integreer naatloos met Dropbox, Nextcloud, en Paperless NGX vir stoor en indeksering, benut Azure Document Intelligence vir OCR, en gebruik selfs Gotenberg vir lêer-na-PDF-omskakelings.",
|
||||
"admin_files.admin_only_badge": "Slegs Admin",
|
||||
"admin_files.aria_breadcrumb": "Migas",
|
||||
"admin_files.badge_delta_detected": "Delta opgespoor",
|
||||
"admin_files.badge_duplicate": "dup",
|
||||
"admin_files.badge_in_db": "in DB",
|
||||
"admin_files.badge_on_disk": "op skyf",
|
||||
"admin_files.breadcrumb_workdir": "werkdir",
|
||||
"admin_files.btn_download": "Laai Af",
|
||||
"admin_files.col_actions": "Aksies",
|
||||
"admin_files.col_db": "DB",
|
||||
"admin_files.col_health": "Gesondheid",
|
||||
"admin_files.col_id": "ID",
|
||||
"admin_files.col_ingested": "Ingested",
|
||||
"admin_files.col_local_filename": "lokale_bestandsnaam",
|
||||
"admin_files.col_missing_paths": "Ontbrekende paaie",
|
||||
"admin_files.col_modified": "Gewijzigde",
|
||||
"admin_files.col_name": "Naam",
|
||||
"admin_files.col_original_file_path": "oorspronklike_bestand_pad",
|
||||
"admin_files.col_original_filename": "Oorspronklike Bestandsnaam",
|
||||
"admin_files.col_path_relative": "Pad (relatiewe werkverzeichnis)",
|
||||
"admin_files.col_processed_file_path": "verwerkte_bestand_pad",
|
||||
"admin_files.col_size": "Grootte",
|
||||
"admin_files.delta_detected_detail": "Gevind {orphan_count} wees bestand(e) op skyf sonder DB rekord, en {ghost_count} DB rekord(e) met ontbrekende lêers op skyf.",
|
||||
"admin_files.delta_detected_title": "Delta ontdek.",
|
||||
"admin_files.empty_database": "Geen bestand rekords in die databasis gevind.",
|
||||
"admin_files.empty_directory": "Hierdie gids is leeg.",
|
||||
"admin_files.ghost_records_desc": "(in DB, lêer(e) ontbreek op skyf)",
|
||||
"admin_files.ghost_records_heading": "Spook rekord",
|
||||
"admin_files.heading": "Bestuurder",
|
||||
"admin_files.health_missing": "Ontbreek",
|
||||
"admin_files.health_ok": "OK",
|
||||
"admin_files.legend_file_exists": "Lêer bestaan op skyf",
|
||||
"admin_files.legend_file_missing": "Lêer ontbreek van skyf",
|
||||
"admin_files.legend_found_in_db": "Gevind in DB",
|
||||
"admin_files.legend_not_in_db": "Nie in DB (wees)",
|
||||
"admin_files.legend_path_not_set": "Pad nie gestel nie",
|
||||
"admin_files.no_delta": "Geen delta gevind — lêerstelsel en databasis is in sink.",
|
||||
"admin_files.no_ghost_records": "Geen spook rekords gevind.",
|
||||
"admin_files.no_orphan_files": "Geen wees lêers gevind.",
|
||||
"admin_files.orphan_files_desc": "(op skyf, geen DB rekord)",
|
||||
"admin_files.orphan_files_heading": "Wees lêers",
|
||||
"admin_files.page_title": "Bestuurder – Admin",
|
||||
"admin_files.status_in_db": "In DB",
|
||||
"admin_files.status_orphan": "Wees",
|
||||
"admin_files.tab_database": "Databasis Rekords",
|
||||
"admin_files.tab_filesystem": "Lêerstelsel",
|
||||
"admin_files.tab_reconcile": "Herstel",
|
||||
"admin_plans.aria_delete_plan": "Verwyder {name}",
|
||||
"admin_plans.aria_edit_plan": "Wysig {name}",
|
||||
"admin_plans.aria_feature_n": "Kenmerk {n}",
|
||||
"admin_plans.aria_move_down": "Beweeg {name} af",
|
||||
"admin_plans.aria_move_up": "Beweeg {name} op",
|
||||
"admin_plans.aria_remove_feature_n": "Verwyder kenmerk {n}",
|
||||
"admin_plans.btn_add_feature": "Voeg Kenmerk By",
|
||||
"admin_plans.btn_add_plan": "Voeg Plan By",
|
||||
"admin_plans.btn_cancel": "Kanselleer",
|
||||
"admin_plans.btn_create": "Skep Plan",
|
||||
"admin_plans.btn_delete": "Verwyder",
|
||||
"admin_plans.btn_edit": "Wysig",
|
||||
"admin_plans.btn_restore_defaults": "Herstel Verstekinstellings",
|
||||
"admin_plans.btn_restore_defaults_title": "Herstel al vier verstekplanne (net as daar nog geen planne bestaan nie)",
|
||||
"admin_plans.btn_restoring": "Herstel\u00115",
|
||||
"admin_plans.btn_save_changes": "Stoor Wysigings",
|
||||
"admin_plans.btn_save_order": "Stoor Bestelling",
|
||||
"admin_plans.btn_saving": "Stoor\u00115",
|
||||
"admin_plans.btn_stripe_setup": "Stripe Instelling",
|
||||
"admin_plans.btn_stripe_setup_title": "Maak die Stripe Instellingswizard oop om API-sleutels te konfigureer en planne te sinkroniseer",
|
||||
"admin_plans.col_actions": "Aksies",
|
||||
"admin_plans.col_active": "Aktief",
|
||||
"admin_plans.col_monthly": "Maandeliks",
|
||||
"admin_plans.col_monthly_limit": "Maandelikse Grens",
|
||||
"admin_plans.col_order": "Bestelling",
|
||||
"admin_plans.col_overage_pct": "Overschot %",
|
||||
"admin_plans.col_plan": "Plan",
|
||||
"admin_plans.col_yearly": "Jaarliks",
|
||||
"admin_plans.coming_soon": "Binnekort beskikbaar",
|
||||
"admin_plans.featured_badge": "Beklemtoon",
|
||||
"admin_plans.field_active": "Aktief",
|
||||
"admin_plans.field_allow_overage": "Toestaan Overschot Fakturering",
|
||||
"admin_plans.field_api_access": "API Toegang",
|
||||
"admin_plans.field_badge_text": "Insigne Tekst",
|
||||
"admin_plans.field_buffer": "Bufferv:",
|
||||
"admin_plans.field_cta_text": "CTA Knoppteks",
|
||||
"admin_plans.field_docs_month": "Docs / Maand",
|
||||
"admin_plans.field_featured": "Beklemtoon / Uitgelig",
|
||||
"admin_plans.field_lifetime_docs": "Leeftyd Docs",
|
||||
"admin_plans.field_mailboxes": "E-pos Posbusse",
|
||||
"admin_plans.field_max_file_size": "Max Lêrgrootte (MB)",
|
||||
"admin_plans.field_name": "Naam",
|
||||
"admin_plans.field_ocr_pages": "OCR Bladsye / Maand",
|
||||
"admin_plans.field_overage_doc_price": "Overschrijdingsprys / dokument ($)",
|
||||
"admin_plans.field_overage_ocr_price": "Overschrijdingsprys / OCR-bladsy ($)",
|
||||
"admin_plans.field_plan_id": "Plan ID",
|
||||
"admin_plans.field_price_monthly": "Maandelikse Pryse ($)",
|
||||
"admin_plans.field_price_yearly": "Jaarlikse Pryse ($)",
|
||||
"admin_plans.field_sort_order": "Sorteringvolgorde",
|
||||
"admin_plans.field_storage_dests": "Stoorbestemmings",
|
||||
"admin_plans.field_stripe_monthly": "Strepie Prijs ID (maandeliks)",
|
||||
"admin_plans.field_stripe_yearly": "Strepie Prijs ID (jaarliks)",
|
||||
"admin_plans.field_tagline": "Slogan",
|
||||
"admin_plans.field_trial_days": "Proeweke",
|
||||
"admin_plans.free_label": "Gratis",
|
||||
"admin_plans.heading": "Plan Ontwerper",
|
||||
"admin_plans.hint_features": "Hierdie opsommingstekens verskyn op die prysbladkaart vir hierdie plan.",
|
||||
"admin_plans.hint_plan_id": "Kleinletterige slak, kan nie verander word nadat dit geskep is nie.",
|
||||
"admin_plans.hint_zero_unlimited": "Voer 0 in vir onbeperk.",
|
||||
"admin_plans.js_delete_confirm": "Verwyder plan \"{id}\"? Dit kan nie ongedaan gemaak word nie.",
|
||||
"admin_plans.js_delete_failed": "Verwydering het misluk",
|
||||
"admin_plans.js_failed_load": "Laai planne het misluk",
|
||||
"admin_plans.js_order_saved": "Bestelling gestoor!",
|
||||
"admin_plans.js_plan_created": "Plan geskep!",
|
||||
"admin_plans.js_plan_deleted": "Plan \"{id}\" verwyder.",
|
||||
"admin_plans.js_plan_updated": "Plan opdateer!",
|
||||
"admin_plans.js_reorder_failed": "Herbestelling het misluk",
|
||||
"admin_plans.js_save_failed": "Stoor het misluk",
|
||||
"admin_plans.js_seed_confirm": "Saai die vier standaard planne? Dit is 'n no-op as planne reeds bestaan.",
|
||||
"admin_plans.js_seed_failed": "Saai het misluk",
|
||||
"admin_plans.js_yearly_enter": "Voer jaarlikse prys in om besparings te wys",
|
||||
"admin_plans.js_yearly_save": "Bespaar {pct}% teenoor maandeliks",
|
||||
"admin_plans.loading": "Laai planne\u0002026",
|
||||
"admin_plans.modal_close_aria": "Sluit modaal",
|
||||
"admin_plans.modal_create_title": "Voeg Plan By",
|
||||
"admin_plans.modal_edit_title_prefix": "Wysig Plan: ",
|
||||
"admin_plans.no_plans_intro": "Nog geen planne nie. Klik",
|
||||
"admin_plans.no_plans_suffix": "om die vier ingeboude planne te saai.",
|
||||
"admin_plans.overage_0pct": "0% (presies)",
|
||||
"admin_plans.overage_100pct": "100%",
|
||||
"admin_plans.overage_50pct": "50%",
|
||||
"admin_plans.overage_announce": "\u00153 aankondig",
|
||||
"admin_plans.overage_buffer_body_prefix": "Die oorskotbuffer is",
|
||||
"admin_plans.overage_buffer_body_suffix": "Ons adverteer X docs/maand maar handhaaf slegs by",
|
||||
"admin_plans.overage_buffer_formula": "X \u0000d7 (1 + buffer%)",
|
||||
"admin_plans.overage_buffer_invisible": "on sigbaar vir gebruikers",
|
||||
"admin_plans.overage_buffer_tail": "docs. Byvoorbeeld, 'n 150-doc/maand plan met 'n 20% buffer handhaaf by 180 docs. Dit voorkom harde afsny tydens die presiese aangekondigde limiet, wat gebruikers 'n gemaklike sagte landing bied.",
|
||||
"admin_plans.overage_buffer_title": "Oor die Oorskotbuffer",
|
||||
"admin_plans.overage_docs": "docs,",
|
||||
"admin_plans.overage_docs_end": "docs",
|
||||
"admin_plans.overage_enforce_at": "handhaaf by",
|
||||
"admin_plans.page_title": "Plan Ontwerper \u0013 DocuElevate Admin",
|
||||
"admin_plans.section_basic_info": "Basiese Inligting",
|
||||
"admin_plans.section_display": "Vertoon",
|
||||
"admin_plans.section_features": "Kenmerklijst",
|
||||
"admin_plans.section_overage": "Oorskot Ontwerper",
|
||||
"admin_plans.section_pricing": "Prys",
|
||||
"admin_plans.section_stripe": "Stripe Integrasie",
|
||||
"admin_plans.section_volume": "Volume Grense",
|
||||
"admin_plans.status_active": "Aktief",
|
||||
"admin_plans.status_inactive": "Inaktief",
|
||||
"admin_plans.stripe_desc_after": "om hulle outomaties te skep. Gratis planne het nie Stripe Prys ID's nodig nie.",
|
||||
"admin_plans.stripe_desc_before": "Voer die Stripe Prys ID's in vir hierdie plan, of gebruik die",
|
||||
"admin_plans.stripe_wizard_aria": "Open Stripe Instelling Towenaar in 'n nuwe oord",
|
||||
"admin_plans.stripe_wizard_link": "Stripe Towenaar",
|
||||
"admin_plans.stripe_wizard_text": "Stripe Instelling Towenaar",
|
||||
"admin_plans.subheading": "Bestuur intekenplanne wat op die openbare prysbladsy getoon word.",
|
||||
"admin_plans.table_aria_label": "Intekenplanne",
|
||||
"admin_users.add_user_profile_btn": "Voeg gebruiker profiel by",
|
||||
"admin_users.admin_only_badge": "Slegs Admin",
|
||||
"admin_users.btn_password": "Wagwoord",
|
||||
"admin_users.btn_reset": "Herstel",
|
||||
"admin_users.col_display_name": "Vertoon Naam",
|
||||
"admin_users.col_documents": "Dokumente",
|
||||
"admin_users.col_email": "E-pos",
|
||||
"admin_users.col_last_upload": "Laaste Laai Op",
|
||||
"admin_users.col_plan": "Plan",
|
||||
"admin_users.col_role": "Rol",
|
||||
"admin_users.col_upload_limit": "Laai Beperking",
|
||||
"admin_users.col_user_id": "Gebruiker ID",
|
||||
"admin_users.col_username": "Gebruikersnaam",
|
||||
"admin_users.create_local_account_btn": "Skep Plaaslike Rekening",
|
||||
"admin_users.create_local_title": "Skep Plaaslike Rekening",
|
||||
"admin_users.delete_account_btn": "Verwyder Rekening",
|
||||
"admin_users.delete_local_confirm": "Weet jy seker jy wil die rekening vir",
|
||||
"admin_users.delete_local_title": "Verwyder Plaaslike Rekening",
|
||||
"admin_users.delete_local_warning": "Dit kan nie teruggedraai word nie. Dokumente wat aan hierdie gebruiker behoort, word nie verwyder nie.",
|
||||
"admin_users.delete_profile_btn": "Verwyder Profiel",
|
||||
"admin_users.delete_profile_confirm": "Weet jy seker jy wil die profiel vir",
|
||||
"admin_users.delete_profile_title": "Verwyder Gebruiker Profiel",
|
||||
"admin_users.delete_profile_warning": "Dit verwyder slegs die admin-beheerde profiel rekord. Dokumente wat aan hierdie gebruiker behoort, word nie verwyder nie.",
|
||||
"admin_users.deleting": "Verwyder\n",
|
||||
"admin_users.edit_local_title": "Bewerk Plaaslike Rekening",
|
||||
"admin_users.filter_placeholder": "Filtreer op gebruiker ID\n",
|
||||
"admin_users.global_default": "globale standaard",
|
||||
"admin_users.heading": "Gebruikerbestuur",
|
||||
"admin_users.js_account_created": "Rekening geskep",
|
||||
"admin_users.js_account_created_msg": "Plaaslike rekening vir \"{username}\" is suksesvol geskep.",
|
||||
"admin_users.js_account_deleted_msg": "Rekening vir \"{username}\" is verwyder.",
|
||||
"admin_users.js_account_updated": "Rekening is opgedateer.",
|
||||
"admin_users.js_delete_failed": "Verwydering het gefaal",
|
||||
"admin_users.js_deleted": "Verwyder",
|
||||
"admin_users.js_email_not_sent": "E-pos nie gestuur nie",
|
||||
"admin_users.js_email_sent": "E-pos gestuur",
|
||||
"admin_users.js_email_sent_msg": "Wagwoord herstel e-pos gestuur na \"{email}\".",
|
||||
"admin_users.js_failed": "Gefaald",
|
||||
"admin_users.js_failed_create": "Dit het gefaal om rekening te skep.",
|
||||
"admin_users.js_failed_load_local": "Dit het gefaal om plaaslike gebruikers te laai",
|
||||
"admin_users.js_failed_load_users": "Kon nie gebruikers laai nie",
|
||||
"admin_users.js_failed_set_password": "Kon nie wagwoord instel nie.",
|
||||
"admin_users.js_failed_update": "Kon nie rekening opdateer nie.",
|
||||
"admin_users.js_network_error": "Netwerkfout",
|
||||
"admin_users.js_password_set": "Wagwoord gestel",
|
||||
"admin_users.js_password_set_msg": "Wagwoord vir \"{username}\" is opdateer.",
|
||||
"admin_users.js_profile_deleted": "Profiel vir \"{id}\" is verwyder.",
|
||||
"admin_users.js_profile_saved": "Profiel vir \"{id}\" is gestoor.",
|
||||
"admin_users.js_save_failed": "Stoor het gefaal",
|
||||
"admin_users.js_saved": "Gestoor",
|
||||
"admin_users.js_smtp_not_configured": "SMTP is nie gekonfigureer nie.",
|
||||
"admin_users.js_updated": "Opgedateer",
|
||||
"admin_users.loading_users": "Laai gebruikers\b5",
|
||||
"admin_users.local_account_active": "Rekening aktief",
|
||||
"admin_users.local_accounts_heading": "Plaaslike Gebruikersrekeninge",
|
||||
"admin_users.local_accounts_subheading": "E-pos/wagwoord rekeninge wat direk op hierdie bediener geskep is.",
|
||||
"admin_users.local_admin_privileges": "Gee adminprivileges",
|
||||
"admin_users.local_admin_privileges_short": "Adminprivileges",
|
||||
"admin_users.local_create_btn": "Skep Rekening",
|
||||
"admin_users.local_create_one": "Skep een.",
|
||||
"admin_users.local_creating": "Besig om te skep\b5",
|
||||
"admin_users.local_display_name_optional": "(opsioneel)",
|
||||
"admin_users.local_loading": "Laai\b5",
|
||||
"admin_users.local_no_accounts": "Geen plaaslike rekeninge nog nie.",
|
||||
"admin_users.local_password_hint": "Minimum 8 karakters.",
|
||||
"admin_users.local_saving": "Stoor\b5",
|
||||
"admin_users.local_username_hint": "3\u001d64 karakters. Letters, nommers, koppeltjies en onderstreep slegs.",
|
||||
"admin_users.modal_add_title": "Voeg Gebruikersprofiel by",
|
||||
"admin_users.modal_billing_cycle_label": "Faktureringsiklus",
|
||||
"admin_users.modal_billing_monthly": "Maandeliks",
|
||||
"admin_users.modal_billing_yearly": "Jaarliks",
|
||||
"admin_users.modal_block_hint": "(verhinder nuwe dokumentoplae)",
|
||||
"admin_users.modal_block_label": "Blokkeer hierdie gebruiker",
|
||||
"admin_users.modal_close_aria": "Sluit dialoog",
|
||||
"admin_users.modal_complimentary_hint": "(gebruiker hou vlak voordele maar word nooit gefaktureer \b5 word outomaties vir adminrekeninge ingestel)",
|
||||
"admin_users.modal_complimentary_label": "Gratis plan",
|
||||
"admin_users.modal_daily_limit_hint": "(laat leeg om globale standaard te gebruik)",
|
||||
"admin_users.modal_daily_limit_label": "Daaglikse Oplaadlimiet",
|
||||
"admin_users.modal_daily_limit_placeholder": "bv. 50 (0 = onbeperk)",
|
||||
"admin_users.modal_display_name_label": "Vertoonnaam",
|
||||
"admin_users.modal_display_name_placeholder": "Alice Smith (opsioneel)",
|
||||
"admin_users.modal_edit_title": "Bewerk Gebruikersprofiel",
|
||||
"admin_users.modal_notes_label": "Admin Aantekeninge",
|
||||
"admin_users.modal_notes_placeholder": "Interne aantekeninge slegs sigbaar vir admins\n...",
|
||||
"admin_users.modal_period_start_hint": "Jaarlikse oordrag word vanaf hierdie datum bereken. Laat leeg vir maandlikse afdwinging.",
|
||||
"admin_users.modal_period_start_label": "Bonningsperiode Begin",
|
||||
"admin_users.modal_plan_business": "Besigheid \u001917.99/maand (300/maand, onbeperkte posbusse)",
|
||||
"admin_users.modal_plan_free": "Gratis \u0019125 lewenslank lêers",
|
||||
"admin_users.modal_plan_hint": "Stel die kwota beperkings vir hierdie gebruiker. Beperkings word afgedwing by opgelaai.",
|
||||
"admin_users.modal_plan_label": "Bonningsplan",
|
||||
"admin_users.modal_plan_professional": "Professioneel \u001915.99/maand (150/maand, 3 posbusse)",
|
||||
"admin_users.modal_plan_starter": "Beginner \u001912.99/maand (50/maand, 1 posbus)",
|
||||
"admin_users.modal_save_changes": "Berg Wijzigings",
|
||||
"admin_users.modal_saving": "Berging\n...",
|
||||
"admin_users.modal_user_id_hint": "Die stabiele identifiseerder wat ooreenstem met owner_id in dokumente.",
|
||||
"admin_users.modal_user_id_label": "Gebruiker ID",
|
||||
"admin_users.modal_user_id_placeholder": "user@example.com of OAuth sub",
|
||||
"admin_users.new_account_btn": "Nuwe Rekening",
|
||||
"admin_users.new_password_label": "Nuwe Wagwoord",
|
||||
"admin_users.no_users_add_hint": "Laai 'n paar dokumente op of voeg 'n profiel bo aan.",
|
||||
"admin_users.no_users_found": "Geen gebruikers gevind.",
|
||||
"admin_users.no_users_search_hint": "Probeer 'n ander soekterm.",
|
||||
"admin_users.page_title": "Gebruikerbestuur \u001913 Admin \u001913 DocuElevate",
|
||||
"admin_users.pagination_page_of": "van",
|
||||
"admin_users.per_day": "/ dag",
|
||||
"admin_users.role_admin": "Admin",
|
||||
"admin_users.role_user": "Gebruiker",
|
||||
"admin_users.search_users_label": "Soek gebruikers",
|
||||
"admin_users.set_password_btn": "Stel Wagwoord In",
|
||||
"admin_users.set_password_desc": "Die gebruiker moet hierdie wagwoord verander na aanmelding.",
|
||||
"admin_users.set_password_desc_pre": "Stel 'n nuwe wagwoord direk in vir",
|
||||
"admin_users.set_password_title": "Stel Tydelike Wagwoord In",
|
||||
"admin_users.setting": "Instelling\n...",
|
||||
"admin_users.status_blocked": "Gesluit",
|
||||
"admin_users.status_unverified": "Ongeverifieer",
|
||||
"admin_users.subheading": "Bemeester gebruikersprofiele, per-gebruiker oplaai beperkings, en dokumentbesit.",
|
||||
"admin_users.total_count_users": "{count} gebruikers",
|
||||
"admin_users.total_no_users": "Geen gebruikers",
|
||||
"admin_users.total_one_user": "1 gebruiker",
|
||||
"api_tokens.col_created": "Aangemaak",
|
||||
"api_tokens.col_last_ip": "Laaste IP",
|
||||
"api_tokens.col_last_used": "Laaste gebruik",
|
||||
@@ -73,6 +347,19 @@
|
||||
"api_tokens.usage_intro_pre": "Gebruik jou API-token in die",
|
||||
"api_tokens.your_tokens": "Jou tokens",
|
||||
"app.name": "DocuElevate",
|
||||
"attribution.heading": "Derdeparty-sagteware toeskrywings",
|
||||
"attribution.intro": "DocuElevate gebruik verskeie open source biblioteke en gereedskap. Ons is dankbaar vir die ontwikkelaars van hierdie projekte vir hul bydraes tot open source sagteware.",
|
||||
"attribution.page_title": "DocuElevate - Derdeparty toeskrywings",
|
||||
"attribution.paramiko_lgpl_note": "Let wel: Hierdie biblioteek is gelisensieer onder die GNU Lesser General Public License v2.1 (LGPL-2.1)",
|
||||
"attribution.section_docker": "Docker Beelde",
|
||||
"attribution.section_frontend": "Frontend Afhanklikhede",
|
||||
"attribution.section_python": "Python Afhanklikhede",
|
||||
"attribution.special_lgpl_link": "hier",
|
||||
"attribution.special_lgpl_post": ".",
|
||||
"attribution.special_lgpl_pre": "‘n Kopie van die LGPL lisensie kan gevind word",
|
||||
"attribution.special_source_post": ".",
|
||||
"attribution.special_source_pre": "Hierdie sagteware sluit Paramiko in, wat onder LGPL gelisensieer is. Die bronkode vir Paramiko is beskikbaar by",
|
||||
"attribution.special_title": "Spesiale Toeskrywing:",
|
||||
"audit.col_ip": "IP",
|
||||
"audit.col_resource": "Bron",
|
||||
"audit.col_timestamp": "Tydstempel",
|
||||
@@ -252,6 +539,41 @@
|
||||
"cookie.notice_label": "Koekiekennisgewing",
|
||||
"cookie.policy_link": "Koekiebeleid",
|
||||
"cookie.privacy_link": "Privaatheidskennisgewing",
|
||||
"cookie_policy.heading": "Koekiebeleid",
|
||||
"cookie_policy.last_updated": "Laas opgedateer:",
|
||||
"cookie_policy.page_title": "Koekiebeleid - DocuElevate",
|
||||
"cookie_policy.s1_heading": "Wat is Koekies",
|
||||
"cookie_policy.s1_p1": "Koekies is klein tekstlêers wat op jou rekenaar of mobiele toestel gestoor word wanneer jy 'n webwerf besoek. Hulle word algemeen gebruik om webwerwe meer doeltreffend te laat werk en inligting aan die webwerf-eienaars te verskaf.",
|
||||
"cookie_policy.s2_heading": "Hoe Ons Koekies Gebruik",
|
||||
"cookie_policy.s2_li1_body": "Om jou te identifiseer wanneer jy aanmeld en jou sessie te onderhou terwyl jy die toepassing gebruik.",
|
||||
"cookie_policy.s2_li1_label": "Outentisering & Sessiebestuur:",
|
||||
"cookie_policy.s2_p1_post": "vir die volgende doeleindes:",
|
||||
"cookie_policy.s2_p1_pre": "DocuElevate gebruik",
|
||||
"cookie_policy.s2_p1_strong": "slegs strikt noodsaaklike sessiekoekies",
|
||||
"cookie_policy.s2_p2": "Hierdie koekies is noodsaaklik vir die behoorlike werking van ons diens. Sonder hierdie koekies sal jy gedwing word om herhaaldelik in te teken tydens jou blaai-sessies.",
|
||||
"cookie_policy.s2_p3": "Aangesien hierdie koekies strikt noodsaaklik is vir die diens om te werk, is hulle vrygestel van vooraf-toestemming vereistes ingevolge die EU ePrivacy-richtlijn (Art. 5(3)) en gelykwaardige nasionale implementasies. Ons stel geen opsionele, analitiese, advertensies of opsporing koekies in nie.",
|
||||
"cookie_policy.s3_col_duration": "Duur",
|
||||
"cookie_policy.s3_col_name": "Naam",
|
||||
"cookie_policy.s3_col_purpose": "Doel",
|
||||
"cookie_policy.s3_col_type": "Tipe",
|
||||
"cookie_policy.s3_heading": "Koekiedetails",
|
||||
"cookie_policy.s3_row1_duration": "Sessies (verwyder op blaasklip of afmelding)",
|
||||
"cookie_policy.s3_row1_purpose": "Hou jou geverifieerde sessie in stand; benodig vir aanmeld om te werk.",
|
||||
"cookie_policy.s3_row1_type": "Strikt Noodsaaklik",
|
||||
"cookie_policy.s3_row2_duration": "Volhardend (blaaier lokalestoor)",
|
||||
"cookie_policy.s3_row2_purpose": "Bêre jou erkenning van die koekie kennisgewing sodat dit nie herhaaldelik vertoon word nie (bewaak in lokalestoor, nie 'n koekie nie).",
|
||||
"cookie_policy.s3_row2_type": "Strikt Noodsaaklik",
|
||||
"cookie_policy.s4_heading": "Geen Derdeparty Koekies",
|
||||
"cookie_policy.s4_p1": "DocuElevate gebruik geen derdeparty koekies, opsporing koekies, advertensies koekies, of analitiese koekies nie. Ons respekteer jou privaatheid en implementeer slegs die minimum koekies wat benodig word vir ons diens om te werk.",
|
||||
"cookie_policy.s4_p2_pre": "Vir meer inligting oor hoe ons jou data hanteer, sien asseblief ons",
|
||||
"cookie_policy.s4_privacy_link": "Privaatheidskennisgewing",
|
||||
"cookie_policy.s5_heading": "Bestuur Koekies",
|
||||
"cookie_policy.s5_p1": "Meeste webblaaiers laat jou toe om koekies te beheer deur hul instellings. Om egter ons sessiekoekies te blokkeer of te verwyder, sal impak hê op die werking van DocuElevate, aangesien gebruikersoutentisering op hierdie koekies steun.",
|
||||
"cookie_policy.s5_p2": "Jy kan ook die koekiekennisgewing erkenning wat in jou blaaier se lokalestoor gestoor is, te eniger tyd verwyder via jou blaaier se ontwikkelaarstools (Toepassing \u0000BB Lokalestoor).",
|
||||
"cookie_policy.s5_p3_and": "en",
|
||||
"cookie_policy.s5_p3_pre": "Hierdie Koekiebeleid is deel van en ingeskakel in ons",
|
||||
"cookie_policy.s5_privacy_link": "Privaatheidskennisgewing",
|
||||
"cookie_policy.s5_terms_link": "Diensvoorwaardes",
|
||||
"credentials.col_action": "Aksie",
|
||||
"credentials.col_credential": "Kredensiaal",
|
||||
"credentials.col_source": "Bron",
|
||||
@@ -517,6 +839,31 @@
|
||||
"help.workflows_step_5_create": "Stoor – nuwe dokumente sal outomaties deur hierdie pyplyn verwerk word.",
|
||||
"help.workflows_typical_steps": "Tipiese Stappe",
|
||||
"help.workflows_what_is": "Wat is 'n Pyplyn?",
|
||||
"imprint.business_registration_heading": "Besigheidsregistrasie",
|
||||
"imprint.business_registration_vat": "BTW-identifikasienommer ingevolge Artikel 27a van die Wet op Belasting op Waarde:",
|
||||
"imprint.contact_heading": "Kontakinligting",
|
||||
"imprint.dispute_heading": "Aanlyn Geskiloplossing",
|
||||
"imprint.dispute_p1": "Die Europese Kommissie bied 'n platform vir aanlyn geskiloplossing (OS):",
|
||||
"imprint.dispute_p2": "Ons is nie bereid of verplig om deel te neem aan geschiloplossingsprosesse voor 'n verbruikersarbitrasiebord nie.",
|
||||
"imprint.heading": "Afdruk",
|
||||
"imprint.legal_copyright": "Alle inhoud op hierdie webwerf is beskerm deur kopiereg. Enige gebruik buite die grense van die kopieregwet vereis die skriftelike toestemming van die onderskeie outeur of kreator.",
|
||||
"imprint.legal_heading": "Regsligtings",
|
||||
"imprint.legal_liability": "Ten spyte van sorgvuldige inhoudbeheer aanvaar ons geen aanspreeklikheid vir die inhoud van eksterne skakels nie. Die operateurs van die gekoppelde bladsye is bloot verantwoordelik vir hul inhoud.",
|
||||
"imprint.page_title": "Afdruk - DocuElevate",
|
||||
"imprint.policies_cookie_desc": "Inligting oor koekies wat ons gebruik",
|
||||
"imprint.policies_cookie_label": "Koekiebeleid",
|
||||
"imprint.policies_heading": "Verwante Beleide",
|
||||
"imprint.policies_intro": "Ons diens word gereguleer deur die volgende beleide:",
|
||||
"imprint.policies_license_desc": "Hoe ons sagteware gelisensieer is",
|
||||
"imprint.policies_license_label": "Lisensie-inligting",
|
||||
"imprint.policies_privacy_desc": "Hoe ons jou data hanteer",
|
||||
"imprint.policies_privacy_label": "Privaatheidsbeleid",
|
||||
"imprint.policies_terms_desc": "Reëls vir die gebruik van DocuElevate",
|
||||
"imprint.policies_terms_label": "Diensvoorwaardes",
|
||||
"imprint.provider_heading": "Diensverskaffer",
|
||||
"imprint.responsible_content_heading": "Verantwoordelik vir Inhoud",
|
||||
"imprint.responsible_content_rstv": "Volgens § 55 Abs. 2 RStV:",
|
||||
"imprint.subtitle": "Inligting volgens § 5 TMG (Duitse Telemediawet)",
|
||||
"index.badge_intelligent": "Intelligente Dokumentverwerking",
|
||||
"index.button_browse_files": "Blaai Lêers",
|
||||
"index.button_upload": "Laai Op",
|
||||
@@ -728,6 +1075,19 @@
|
||||
"language.tr": "Türkçe",
|
||||
"language.uk": "Ukrainian",
|
||||
"language.zh": "中文",
|
||||
"license.apache_description": "DocuElevate word versprei onder die Apache Lisensie 2.0, wat 'n permisiewe oopbron sagteware lisensie is wat jou toelaat om die projek te gebruik, te verander, te versprei en by te dra.",
|
||||
"license.apache_heading": "Apache Lisensie 2.0",
|
||||
"license.heading": "Lisensie-inligting",
|
||||
"license.page_title": "Lisensie-inligting - DocuElevate",
|
||||
"license.related_about_link": "Oor bladsy",
|
||||
"license.related_and": "en",
|
||||
"license.related_heading": "Verwante Inligting",
|
||||
"license.related_p1_post": "vir inligting oor die gebruik van die DocuElevate diens.",
|
||||
"license.related_p1_pre": "Terwyl hierdie lisensie die gebruik van ons sagteware reguleer, hersien ook asseblief ons",
|
||||
"license.related_p2_post": ".",
|
||||
"license.related_p2_pre": "Vir meer inligting oor DocuElevate, besoek asseblief die",
|
||||
"license.related_privacy_link": "Privaatheidsbeleid",
|
||||
"license.related_terms_link": "Diensvoorwaardes",
|
||||
"nav.about": "Oor",
|
||||
"nav.account_menu": "Rekeningmenu",
|
||||
"nav.account_menu_for": "Rekeningmenu vir {name}",
|
||||
@@ -828,6 +1188,185 @@
|
||||
"pipelines.system_label": "Stelsel",
|
||||
"pipelines.system_pipeline_label": "Stelselpyplyn (sigbaar vir alle gebruikers)",
|
||||
"pipelines.title": "Verwerkingspyplyne",
|
||||
"privacy.heading": "DocuElevate – Privaatheidskennisgewing",
|
||||
"privacy.last_updated": "Laas opgedateer:",
|
||||
"privacy.page_title": "Privaatheidskennisgewing - DocuElevate",
|
||||
"privacy.s10_access_body": "Jy kan 'n kopie van die persoonlike data wat ons oor jou besit, aanvra.",
|
||||
"privacy.s10_access_label": "Reg van Toegang (Art. 15):",
|
||||
"privacy.s10_complaint_body": "Jy het die reg om 'n klagte in te dien by jou nasionale Gegevensbeskermingsgesag (DPA). In Duitsland: Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI). In die VK: Inligtingkommissaris se Kantoor (ICO). In Switserland: Federale Gegevensbeskerming en Inligtingkommissaris (FDPIC).",
|
||||
"privacy.s10_complaint_label": "Reg om 'n Klagte in te Dien:",
|
||||
"privacy.s10_contact": "Om enige van die bogenoemde regte uit te oefen, kontak ons by",
|
||||
"privacy.s10_erasure_body": "Jy kan versoek dat jou persoonlike data verwyder word waar daar geen oorheersende regmatige rede is vir ons om dit te hou nie.",
|
||||
"privacy.s10_erasure_label": "Reg op Verwydering (Kunst. 17):",
|
||||
"privacy.s10_heading": "10. Jou Regte (EU / EEA / VK / Switserland)",
|
||||
"privacy.s10_object_body": "Jy mag teengaan teen verwerking op grond van regmatige belange op enige tyd.",
|
||||
"privacy.s10_object_label": "Reg om te Teengaan (Kunst. 21):",
|
||||
"privacy.s10_p1": "Onder GDPR (en die VK GDPR / Sweedse nFADP gelykwaardige), het jy die volgende regte:",
|
||||
"privacy.s10_portability_body": "Jy kan versoek dat jou data in 'n gestructureerde, algemeen gebruikte, masjienleesbare formaat verskaf word.",
|
||||
"privacy.s10_portability_label": "Reg op Data Drabaarheid (Kunst. 20):",
|
||||
"privacy.s10_rectification_body": "Jy kan versoek dat Ond准确 of onvolledige persoonlike data gereguleer word.",
|
||||
"privacy.s10_rectification_label": "Reg op Regstelling (Kunst. 16):",
|
||||
"privacy.s10_response": "Ons sal binne een kalendermaand antwoordgee (verlengbaar met twee verdere maande vir komplekse versoeke).",
|
||||
"privacy.s10_restriction_body": "Jy kan versoek dat ons tydelik die verwerking van jou data in sekere omstandighede stop.",
|
||||
"privacy.s10_restriction_label": "Reg op Beperking (Kunst. 18):",
|
||||
"privacy.s10_withdraw_body": "Waar verwerking gebaseer is op toestemming, kan jy daardie toestemming te eniger tyd intrek sonder om die wettigheid van vorige verwerking te beïnvloed.",
|
||||
"privacy.s10_withdraw_label": "Reg om Toestemming in te Trek:",
|
||||
"privacy.s11_categories_body": "Identifiseerders (naam, e-pos), rekeningverifikasie tokens, en dokument metadata wat jy kies om op te laai.",
|
||||
"privacy.s11_categories_label": "Kategoriale persoonlike inligting versamel:",
|
||||
"privacy.s11_contact": "Om 'n verifieerbare verbruikersversoek in te dien, kontak ons by",
|
||||
"privacy.s11_correct_body": "Jy kan versoek dat onakkurate persoonlike inligting gereguleer word.",
|
||||
"privacy.s11_correct_label": "Reg om te Reguleer:",
|
||||
"privacy.s11_delete_body": "Jy kan versoek dat persoonlike inligting wat ons versamel het, verwyder word, onderhewig aan sekere uitsonderings.",
|
||||
"privacy.s11_delete_label": "Reg om te Verwyder:",
|
||||
"privacy.s11_heading": "11. Addisionele Regte – Verenigde State (CCPA / CPRA)",
|
||||
"privacy.s11_know_body": "Jy kan versoek dat die kategorieë en spesifieke stukke persoonlike inligting wat ons oor jou versamel het, bekend gemaak word.",
|
||||
"privacy.s11_know_label": "Reg om te Weet:",
|
||||
"privacy.s11_limit_body": "Ons gebruik nie sensitiewe persoonlike inligting meer as wat nodig is om die diens te voorsien nie.",
|
||||
"privacy.s11_limit_label": "Reg om Gebruik van Sensitiewe Persoonlike Inligting te Beperk:",
|
||||
"privacy.s11_nondiscrim_body": "Ons sal nie teen jou diskrimineer vir die uitoefening van enige van hierdie regte nie.",
|
||||
"privacy.s11_nondiscrim_label": "Nie-Diskriminasie:",
|
||||
"privacy.s11_optout_body": "Ons verkoop of deel nie persoonlike inligting soos gedefinieer deur CCPA/CPRA nie. Geen kies-uit mechanisme is vereis nie; egter, jy kan ons kontak om dit te bevestig.",
|
||||
"privacy.s11_optout_label": "Reg om uit Verkoop / Delen te Opt:",
|
||||
"privacy.s11_p1": "As jy 'n inwoner van Kalifornië of 'n ander Amerikaanse staat met toepaslike privaatheidswetgewing is (insluitend Virginia VCDPA, Colorado CPA, Connecticut CTDPA, Utah UCPA), geld die volgende addisionele bekendmakings:",
|
||||
"privacy.s11_purpose_body": "Verskaffing, verbetering en beveiliging van die DocuElevate diens. Ons verkoop of deel nie persoonlike inligting vir kruis-konteks gedragadvertensies nie.",
|
||||
"privacy.s11_purpose_label": "Doel van versameling:",
|
||||
"privacy.s11_response": "Ons sal binne 45 dae antwoordgee (verlengbaar met 'n addisionele 45 dae wanneer redelikerwys nodig).",
|
||||
"privacy.s12_access_body": "Jy kan versoek om toegang tot jou persoonlike inligting en inligting oor hoe dit gebruik of bekendgemaak is.",
|
||||
"privacy.s12_access_label": "Reg van Toegang:",
|
||||
"privacy.s12_contact": "Direkte privaatheidsklagtes aan ons Privaatheidsbeampte by",
|
||||
"privacy.s12_contact_post": ", of aan die Kantoor van die Privaatheid Kommissaris van Kanada.",
|
||||
"privacy.s12_correction_body": "Jy kan die akkuraatheid of volledigheid van jou persoonlike inligting uitdaag en 'n korreksie versoek.",
|
||||
"privacy.s12_correction_label": "Reg op Korreksie:",
|
||||
"privacy.s12_heading": "12. Bykomende Regte – Kanada (PIPEDA / Québec Wet 25)",
|
||||
"privacy.s12_li1": "Ons versamel, gebruik en openbaar persoonlike inligting slegs met jou kennis en toestemming, of soos toegestaan deur die wet.",
|
||||
"privacy.s12_p1": "As jy in Kanada geleë is, geld die volgende onder die Wet op die Beskerming van Persoonlike Inligting en Elektroniese Dokumente (PIPEDA) en toepaslike provinsiale wetgewing (insluitend Québec Wet 25 / Bill 64):",
|
||||
"privacy.s12_quebec_body": "Onder Wet 25 het jy bykomende regte, insluitend die reg op datamobiliteit (doeltreffend September 2023) en die reg op de-indeksasie waar persoonlike inligting aanlyn versprei word.",
|
||||
"privacy.s12_quebec_label": "Québec inwoners:",
|
||||
"privacy.s12_withdraw_body": "Onderhewig aan wettige of kontraktuele beperkings, kan jy toestemming vir die versameling, gebruik of bekendmaking van jou persoonlike inligting met redelike kennisgewing terugtrek.",
|
||||
"privacy.s12_withdraw_label": "Reg om Toestemming Terug te Trek:",
|
||||
"privacy.s13_brazil_body": "As jy in Brasilië geleë is, het jy die volgende regte onder die LGPD:",
|
||||
"privacy.s13_brazil_label": "Brasilië (LGPD – Algemene Wet op Gegevensbeskerming, Wet 13.709/2018):",
|
||||
"privacy.s13_contact": "Kontak:",
|
||||
"privacy.s13_heading": "13. Bykomende Regte – Latyns-Amerika (LGPD & Ander)",
|
||||
"privacy.s13_li1": "Bevestiging van die bestaan van verwerking en toegang tot jou data.",
|
||||
"privacy.s13_li2": "Korreksie van onvolledige, onnauwkeurige of verouderde data.",
|
||||
"privacy.s13_li3": "Anonimisering, blokkerings, of verwydering van onnodige of oorvloedige data.",
|
||||
"privacy.s13_li4": "Mobiliteit van jou data na 'n ander diens- of produkverskaffer.",
|
||||
"privacy.s13_li5": "Verwydering van persoonlike data wat met jou toestemming verwerk is.",
|
||||
"privacy.s13_li6": "Inligting oor entiteite waaraan jou data gedeel is.",
|
||||
"privacy.s13_li7": "Inligting oor die moontlikheid om nie toestemming te gee nie en die gevolge van weiering.",
|
||||
"privacy.s13_li8": "Herroeping van toestemming.",
|
||||
"privacy.s13_other_body": "Ons erken ook toepaslike privaatheidswette in Argentinië (PDPA), Mexiko (LFPDPPP), Chili, Colombia (Wet 1581), en ander. gebruikers in hierdie jurisdiksies kan gelykwaardige regte uitoefen soos uiteengesit onder hul nasionale wet deur ons te kontak.",
|
||||
"privacy.s13_other_label": "Ander Latyns-Amerikaanse Länder:",
|
||||
"privacy.s14_apj_body": "Ons erken die databeskermingsregte wat aan inwoners van hierdie jurisdiksies onder hul respectiewe nasionale wette toegeken word. Kontak ons om jou regte uit te oefen.",
|
||||
"privacy.s14_apj_label": "Ander APJ markte (Singapore PDPA, Nieu-Seeland Privaatheidswet, Indië DPDP Wet):",
|
||||
"privacy.s14_australia_body": "Australiese inwoners kan toegang tot en korreksie van hul persoonlike inligting versoek. Ons sal op toegang versoeke binne 30 dae antwoordgee. Klagte kan by die Kantoor van die Australiese Inligtingkommissaris (OAIC) ingedien word.",
|
||||
"privacy.s14_australia_label": "Australië (Privaatheidswet 1988 en Australiese Privaatheidsbeginsels):",
|
||||
"privacy.s14_contact": "Kontak:",
|
||||
"privacy.s14_heading": "14. Bykomende Regte – Asië-Stille Oseaan & Japan",
|
||||
"privacy.s14_japan_body": "Japanse inwoners kan openbaarmaking, korreksie, toevoeging of verwydering, opschorting van gebruik, verwydering, of opschorting van derdepartyverskaffing van hul persoonlike inligting wat deur ons gehou word, versoek. Derdeparty openbaarmakings vereis jou vooraf toestemming behalwe waar dit deur die wet toegestaan is.",
|
||||
"privacy.s14_japan_label": "Japan (APPI – Wet op die Beskerming van Persoonlike Inligting):",
|
||||
"privacy.s14_korea_body": "Koreaanse inwoners kan toegang, korreksie, verwydering, en opschorting van verwerking versoek. Ons hanteer persoonlike inligting van Koreaanse inwoners in ooreenstemming met die PIPA.",
|
||||
"privacy.s14_korea_label": "Suid-Korea (PIPA – Wet op die Beskerming van Persoonlike Inligting):",
|
||||
"privacy.s15_contact": "Kontak:",
|
||||
"privacy.s15_heading": "15. Bykomende Regte – Oekraïne",
|
||||
"privacy.s15_p1": "Gebruikers wat in Oekraïne geleë is, word beskerm deur die Wet van Oekraïne \"Oor die Beskerming van Persoonlike Data\" (No. 2297-VI). Jou regte sluit toegang tot, korreksie, blokkering, en verwydering van jou persoonlike data in, sowel as die reg om teen verwerking te beswaar.",
|
||||
"privacy.s16_cookies_link": "Koekiebeleid",
|
||||
"privacy.s16_heading": "16. Opdaterings van hierdie Privaatheid Kennisgewing",
|
||||
"privacy.s16_license_link": "Lisensie-inligting",
|
||||
"privacy.s16_p1": "Ons kan hierdie kennisgewing van tyd tot tyd opdateer om veranderinge in ons praktyke of toepaslike wette te weerspieël. Die \"Laaste Opdatering\" datum aan die bokant van hierdie bladsy dui aan wanneer die kennisgewing laas hersien is. Waar veranderinge materieel is, sal ons gebruikers in kennis stel via in-toepassing kennisgewing of e-pos waar toepaslik.",
|
||||
"privacy.s16_p2_pre": "As jy enige vrae of bekommernisse het oor hierdie Privaatheidkening of jou persoonlike data, kontak ons asseblief by",
|
||||
"privacy.s16_p3_pre": "Kyk asseblief ook na ons",
|
||||
"privacy.s16_terms_link": "Voorwaardes van Diens",
|
||||
"privacy.s1_address": "Alter Steinweg 3, 20459 Hamburg, Duitsland",
|
||||
"privacy.s1_company": "Christian Louis IT Konsultasie",
|
||||
"privacy.s1_contact_label": "Kontak E-pos:",
|
||||
"privacy.s1_heading": "1. Data Beheerder",
|
||||
"privacy.s1_p1": "Die beheerder wat verantwoordelik is vir die verwerking van jou persoonlike data volgens die EU Algemene Data Beskerming Regulation (GDPR) en ekwivalente privaatheidswette wêreldwyd is:",
|
||||
"privacy.s1_p3": "Vir alle privaatheid-verwante versoeke (toegang, verwydering, regstelling, keuse om nie te deel nie, of klagtes), kontak ons asseblief by die e-pos adres hierbo. Ons sal binne 30 dae reageer (of die tydperk wat deur toepaslike wet voorgeskryf word).",
|
||||
"privacy.s2_heading": "2. Omvang van hierdie Privaatheidkening",
|
||||
"privacy.s2_p1_pre": "Hierdie kennisgewing is van toepassing op die DocuElevate webtoepassing, gehos as",
|
||||
"privacy.s2_p2": "Dit dek alle gebruikers wêreldwyd, insluitend dié in die Europese Unie (EU), Europese Ekonomiese gebied (EEA), Duitsland, Verenigde Koninkryk (VK), Switserland, Oekraine, Verenigde State (VS), Kanada, Latyns-Amerika (Latam), Asië-Stille Oseaan, en Japan. Spesifieke bekendmakings vir markte word in toegewyde afdelings hieronder verskaf.",
|
||||
"privacy.s3_audit_body": "Ons hou beperkte ouditlogs (aktietipe, tydstempel, gebruikersidentifiseerder) om die integriteit en sekuriteit van die diens te verseker. Hierdie logs sluit nie dokumentinhoud in nie.",
|
||||
"privacy.s3_audit_label": "Audit Logs:",
|
||||
"privacy.s3_auth_body": "Ons gebruik OAuth 2.0 (Google, Dropbox, Microsoft/OneDrive) en opsionele plaaslike autentikasie. Deur OAuth kan ons jou naam, e-posadres en profielfoto ontvang.",
|
||||
"privacy.s3_auth_label": "Gebruiker Autentikasie:",
|
||||
"privacy.s3_doc_body": "Dokumente wat jy oplaai word verwerk vir OCR (optiese tekenherkenning), metadata-ekstraksie, en stoor na jou gekose wolk verskaffer. Dokumentinhoud word slegs verwerk vir die doel wat jy inisieer en word nie gestoor verder as wat operasioneel noodsaaklik is nie.",
|
||||
"privacy.s3_doc_label": "Dokument Verwerking:",
|
||||
"privacy.s3_heading": "3. Data Versameling & Doele",
|
||||
"privacy.s3_legal_body": "Ons primêre regsbasis vir verwerking is:",
|
||||
"privacy.s3_legal_label": "Regsbasis (GDPR Art. 6):",
|
||||
"privacy.s3_li1": "(1)(b) Prestasie van 'n kontrak: om die DocuElevate diens wat jy versoek het, te verskaf.",
|
||||
"privacy.s3_li2": "(1)(c) Wettige verpligting: om te voldoen aan toepaslike wette en regulasies.",
|
||||
"privacy.s3_li3": "(1)(f) Regmatige belang: om die sekuriteit van die diens te verseker en om bedrog te voorkom.",
|
||||
"privacy.s4_heading": "4. Data Minimalisering & Doelbeperking",
|
||||
"privacy.s4_li1": "Ons versamel slegs die minimum persoonlike data wat benodig word om die diens te bedryf.",
|
||||
"privacy.s4_li2": "Dokumentinhoud word streng verwerk vir die doel wat jy inisieer (OCR, stoor, metadata-ekstraksie). Ons gebruik nie jou dokumente om KI-modelle op te lei of vir enige sekondêre doel nie.",
|
||||
"privacy.s4_li3": "Daar word geen advertering, gedragsporing, of profilering uitgevoer nie.",
|
||||
"privacy.s4_li4": "Daar word geen volgkoekies of analitiese skripte gelaai nie.",
|
||||
"privacy.s4_li5": "Derdeparty KI-dienste (bv. OpenAI, Azure Document Intelligence) word slegs ingevoer wanneer jy dokumentverwerking inisieer, en data word oorgedra onder data verwerkingsooreenkomste.",
|
||||
"privacy.s4_p1": "DocuElevate is ontwerp met data minimalisering as 'n kern beginsel (GDPR Art. 5(1)(c)):",
|
||||
"privacy.s5_cookie_link": "Koekiebeleid",
|
||||
"privacy.s5_heading": "5. Gebruik van Koekies & Soortgelyke Tegnologieë",
|
||||
"privacy.s5_p1_post": "om jou geverifieerde sessie te onderhou. Hierdie koekies is noodsaaklik vir die funksionering van die diens en is van vooraf toestemming vereis onder die EU ePrivaatheidsriglyn (Art. 5(3)) en ekwivalente nasionale wette.",
|
||||
"privacy.s5_p1_pre": "DocuElevate gebruik",
|
||||
"privacy.s5_p1_strong": "slegs strikt noodsaaklike sessie-koekies",
|
||||
"privacy.s5_p2_body": "analitiese koekies, advertensie koekies, volg pixels, of enige derdeparty koekies wat jou toestemming vereis.",
|
||||
"privacy.s5_p2_label": "Ons gebruik nie:",
|
||||
"privacy.s5_p3_pre": "Vir volledige besonderhede oor die koekies wat ons stel, hul name, duur, en doel, besoek asseblief ons",
|
||||
"privacy.s6_ai_body": "Wanneer jy OCR of KI-gebaseerde metadata-ekstraksie inisieer, word dokumentdata oorgedra aan die KI-dienste wat jy of jou administrateur geconfigureer het. Hierdie oordrag word gereguleer deur 'n dataverwerkingsooreenkoms met die betrokke verskaffer.",
|
||||
"privacy.s6_ai_label": "KI Verwerkingsdienste (OpenAI, Azure Document Intelligence, ander):",
|
||||
"privacy.s6_heading": "6. Derdeparty Dienste",
|
||||
"privacy.s6_no_sale_body": "Ons verkoop, verhuur of deel nie jou persoonlike data met derde partye vir advertensie, bemarking of enige doel wat nie verband hou met die lewering van die diens nie.",
|
||||
"privacy.s6_no_sale_label": "Geen Verkoop of Deel vir Advertensiedoeleindes:",
|
||||
"privacy.s6_oauth_body": "Wanneer jy kies om deur OAuth te autentiseer, verwerk die betrokke verskaffer jou inligting en mag beperkte profielinligting met ons deel. Hierdie verskaffers handhaaf hulle eie privaatheidsbeleide.",
|
||||
"privacy.s6_oauth_label": "OAuth Verskaffers (Google, Dropbox, Microsoft):",
|
||||
"privacy.s6_storage_body": "Dokumente word gestoor in die wolkverskaffer wat jy konfigureer. Jou gekonfigureerde inligting word versleuteld in die aansoekdatabasis gestoor en word slegs gebruik om die stooroperasies wat jy versoek, uit te voer.",
|
||||
"privacy.s6_storage_label": "Wolk Stoor Verskaffers (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):",
|
||||
"privacy.s7_adequacy_body": "waar die Europese Kommissie 'n ekwivalente vlak van beskerming erken het (bv. Verenigde Koninkryk, Switserland, Kanada (kommersiële organisasies), Japan, Suid-Korea).",
|
||||
"privacy.s7_adequacy_label": "Adekwaatheid Besluite",
|
||||
"privacy.s7_contact": "Jy kan 'n kopie van die relevante beskermingsmaatreëls aanvra deur ons te kontak by",
|
||||
"privacy.s7_heading": "7. Internasionale Dataverskuiwing",
|
||||
"privacy.s7_idta_body": "vir oordragte vanaf die VK na Brexit.",
|
||||
"privacy.s7_idta_label": "VK Internasionale Dataverskuiwingsooreenkomste (IDTA's)",
|
||||
"privacy.s7_p1": "DocuElevate is standaard in die Europese Unie / EEA gehuisves. Waar persoonlike data buite die EEA oorgedra word (byvoorbeeld na VSA-gebaseerde KI-diensverskaffers soos OpenAI), steun ons op toepaslike beskermingsmaatreëls, insluitend:",
|
||||
"privacy.s7_scc_body": "aangeneem deur die Europese Kommissie (2021/914/EU) vir oordragte na verwerkers en controllers in derde lande.",
|
||||
"privacy.s7_scc_label": "Standaard Kontraktuele Klousules (SCC's)",
|
||||
"privacy.s8_audit_body": "Gehou vir tot 90 dae vir sekuriteits- en nakomingsdoeleindes.",
|
||||
"privacy.s8_audit_label": "Ouditlogboek:",
|
||||
"privacy.s8_contact": "Om verwydering van jou rekening en alle geassosieerde persoonlike data aan te vra, kontak ons asseblief by",
|
||||
"privacy.s8_files_body": "Gehou vir die duur van jou gebruik van die diens. Jy kan individuele lêers te enige tyd deur die aansoek verwyder.",
|
||||
"privacy.s8_files_label": "Lêerrekords en metadata:",
|
||||
"privacy.s8_heading": "8. Data Bewaring",
|
||||
"privacy.s8_oauth_body": "Gestoor in versleutelde vorm en herroepbaar te enige tyd deur jou OAuth-verskaffer.",
|
||||
"privacy.s8_oauth_label": "OAuth tokens:",
|
||||
"privacy.s8_p1": "Ons hou persoonlike data slegs so lank as wat dit dringend nodig is om die DocuElevate-diens te lewer of om aan wetlike verpligtinge te voldoen:",
|
||||
"privacy.s8_session_body": "Verwyder wanneer jy uitteken of na sessietydsverloop.",
|
||||
"privacy.s8_session_label": "Sessie data:",
|
||||
"privacy.s9_heading": "9. Data Sekuriteit",
|
||||
"privacy.s9_li1": "Versleuteling van inligting en sensitiewe konfigurasie in rus.",
|
||||
"privacy.s9_li2": "Transportlaag Sekuriteit (TLS/HTTPS) vir alle kommunikasies.",
|
||||
"privacy.s9_li3": "Rolgebaseerde toegangbeheer wat toegang tot persoonlike data beperk.",
|
||||
"privacy.s9_li4": "Regelmatige sekuriteitsouditering en afhanklikheid kwesbaarheid skandering.",
|
||||
"privacy.s9_li5": "CSRF beskerming op alle toestandveranderende versoeke.",
|
||||
"privacy.s9_p1": "Ons implementeer toepaslike tegniese en organisatoriese maatreëls (TOMs) om jou persoonlike data te beskerm, insluitend:",
|
||||
"privacy.toc_1": "Data Beheerder",
|
||||
"privacy.toc_10": "Jou Regte (EU / EEA / VK / Switserland)",
|
||||
"privacy.toc_11": "Addisionele Regte – Verenigde State (CCPA/CPRA)",
|
||||
"privacy.toc_12": "Addisionele Regte – Kanada (PIPEDA / Wet 25)",
|
||||
"privacy.toc_13": "Addisionele Regte – Latyns-Amerika (LGPD & ander)",
|
||||
"privacy.toc_14": "Addisionele Regte – Asië-Stille Oseaan & Japan",
|
||||
"privacy.toc_15": "Addisionele Regte – Oekraine",
|
||||
"privacy.toc_16": "Opdaterings aan hierdie Privaatheidskennisgewing",
|
||||
"privacy.toc_2": "Toepassing van hierdie Privaatheidskennisgewing",
|
||||
"privacy.toc_3": "Gegevensinsameling & Doelwitte",
|
||||
"privacy.toc_4": "Gegevensminimalisering & Doellewing",
|
||||
"privacy.toc_5": "Gebruik van Koekies & Soortgelyke Tegnieke",
|
||||
"privacy.toc_6": "Derdeparty Dienste",
|
||||
"privacy.toc_7": "Internasionale Gegevensoordragte",
|
||||
"privacy.toc_8": "Gegevensbewaring",
|
||||
"privacy.toc_9": "Gegevenssekuriteit",
|
||||
"privacy.toc_heading": "Inhoud",
|
||||
"profile.avatar_alt": "Jou profielprent",
|
||||
"profile.avatar_heading": "Profielprent",
|
||||
"profile.avatar_remove": "Verwyder persoonlike avatar",
|
||||
@@ -838,6 +1377,9 @@
|
||||
"profile.contact_email_label": "Kontak / Kennisgewing E-pos",
|
||||
"profile.contact_email_placeholder": "jy@example.com",
|
||||
"profile.current_password": "Huidige Wagwoord",
|
||||
"profile.default_document_language_auto": "Gebruik stelselsleutels",
|
||||
"profile.default_document_language_hint": "Dokumente in ander tale word outomaties in hierdie taal vertaal. Laat leeg om die stelselsleutel (Engels) te gebruik.",
|
||||
"profile.default_document_language_label": "Standaard Dokument Taal",
|
||||
"profile.dismiss": "Verwerp",
|
||||
"profile.display_name_hint": "Laat leë om jou rekening gebruikersnaam of e-pos te gebruik.",
|
||||
"profile.display_name_label": "Aanskouenaam",
|
||||
@@ -1040,14 +1582,66 @@
|
||||
"similarity.subtitle": "Pare van dokumente met hoë semantiese gelykheid, gegranteer op punt.",
|
||||
"similarity.trigger_aria": "Aktiveer inbeding berekening vir alle lêers sonder inbedings",
|
||||
"similarity.trigger_now": "aktiveer dit nou",
|
||||
"status.active": "Aktief",
|
||||
"status.ai_empty_response": "(leer)",
|
||||
"status.ai_extraction_desc": "Plak die eenvoudige teksinhoud van 'n dokument hieronder en run dit deur die geconfigureerde AI-verskaffer om die ruwe antwoord, onttrekte JSON en etikette te ondersoek.",
|
||||
"status.ai_extraction_failed": "AI Onttrekking Misluk",
|
||||
"status.ai_extraction_label": "Dokument Tegst",
|
||||
"status.ai_extraction_placeholder": "Plak die eenvoudige teksinhoud van jou dokument hier\u001e...",
|
||||
"status.ai_extraction_title": "AI Onttrekking toets",
|
||||
"status.app_version": "App Weergawe",
|
||||
"status.as_account": "as",
|
||||
"status.auth_required": "Outentisering Benodig",
|
||||
"status.build_date": "Bou Datum",
|
||||
"status.config_settings": "Konfigurasie Instellings",
|
||||
"status.config_settings_desc": "Vir meer gedetailleerde konfigurasie-instellings en omgewingsveranderlikes, kyk na die instellingsblad.",
|
||||
"status.configure_now": "Configureer Nou",
|
||||
"status.configured": "Geconfigureer",
|
||||
"status.connection_error": "Verbinding Fout",
|
||||
"status.connection_test_failed": "Verbindingstoets het gefaal",
|
||||
"status.connection_test_successful": "Verbindingstoets suksesvol",
|
||||
"status.container_id": "Container ID",
|
||||
"status.container_started": "Container Gestart",
|
||||
"status.dashboard_subtitle": "Hierdie dashboard wys die status van alle geconfigureerde integrasies en teikens.",
|
||||
"status.debug_mode": "Foutopsporing Modus",
|
||||
"status.error_running_extraction": "Fout tydens onttrekking: ",
|
||||
"status.error_testing_connection": "Fout tydens toets van verbinding: ",
|
||||
"status.error_testing_notifications": "Fout tydens toets van kennisgewings: ",
|
||||
"status.extracted_tags": "Onthaalde Etikette",
|
||||
"status.git_commit": "Git Toewyding",
|
||||
"status.inactive": "Inaktief",
|
||||
"status.json_parse_issue": "JSON parse probleem: ",
|
||||
"status.last_check": "Laaste Kontrole",
|
||||
"status.manage": "Bestuur",
|
||||
"status.modal_default_message": "Operasie suksesvol voltooi.",
|
||||
"status.modal_default_title": "Sukses",
|
||||
"status.no_details": "Geen besonderhede beskikbaar",
|
||||
"status.not_configured": "Nie Geconfigureer nie",
|
||||
"status.notification_config_missing": "Kennisgewing Konfigurasie Ontbreek",
|
||||
"status.open": "Maak",
|
||||
"status.page_title": "Sisteem Status",
|
||||
"status.parsed_json_label": "Geparsde JSON",
|
||||
"status.provider_config_details": "{name} Konfigurasie Besonderhede",
|
||||
"status.provider_details": "Verskaffer Besonderhede",
|
||||
"status.raw_llm_response": "Onverwerkte LLM Antwoord",
|
||||
"status.run_extraction": "Voer Onttrekking Uit",
|
||||
"status.running": "Aan die gang\u00135",
|
||||
"status.sending": "Stuur...",
|
||||
"status.setting_label": "Instelling",
|
||||
"status.test_connection": "Toets Verbinding",
|
||||
"status.test_extraction": "Toets Onttrekking",
|
||||
"status.test_failed": "Toets Misluk",
|
||||
"status.test_notification_failed": "Toets Kennisgewing Misluk",
|
||||
"status.test_notification_sent": "Toets Kennisgewing Gestuur",
|
||||
"status.test_notifications": "Toets Kennisgewings",
|
||||
"status.test_provider": "Toets {name}",
|
||||
"status.test_successful": "Toets Suksesvol",
|
||||
"status.testing": "Toets...",
|
||||
"status.token_expired": "U token het verloop of is ongeldig. Herkonfigureer asseblief hierdie verbinding.",
|
||||
"status.token_valid_for": "Token geldig vir:",
|
||||
"status.value_label": "Waarde",
|
||||
"status.view_config": "Beskou Gedetailleerde Konfigurasie",
|
||||
"status.view_details": "Beskou Besonderhede",
|
||||
"subscription.available_plans_heading": "Beschikbare planne",
|
||||
"subscription.back_to_dashboard": "Terug na Dashboard",
|
||||
"subscription.cancel_pending": "Annuleer veranderings",
|
||||
@@ -1083,6 +1677,51 @@
|
||||
"subscription.upgrade_info": "Opgraderings tree onmiddellik in werking. Afgraderings is geskeduleer vir die einde van jou huidige faktureringsperiode.",
|
||||
"subscription.upgrade_to_prefix": "Opgradeer na",
|
||||
"subscription.usage_heading": "Gebruik",
|
||||
"terms.cookie_link": "Koekiebeleid",
|
||||
"terms.heading": "Voorwaardes van Diens",
|
||||
"terms.last_updated": "Laas opgedateer:",
|
||||
"terms.license_link": "Lisensie-inligting",
|
||||
"terms.page_title": "Voorwaardes van Diens - DocuElevate",
|
||||
"terms.privacy_link": "Privaatheidsbeleid",
|
||||
"terms.s1_heading": "1. Aanvaarding van Voorwaardes",
|
||||
"terms.s1_p1": "Deur toegang te verkry of DocuElevate te gebruik, stem u in om aan hierdie Voorwaardes van Diens gebonde te wees. As u nie saamstem met hierdie voorwaardes nie, gebruik asseblief hierdie diens nie.",
|
||||
"terms.s2_heading": "2. Beskrywing van Diens",
|
||||
"terms.s2_p1": "DocuElevate verskaf dokumentverwerking, OCR, metadata-uitkapping, en bergingsdienste. Ons behou die reg voor om enige aspek van die diens te wysig of te beëindig te eniger tyd.",
|
||||
"terms.s3_heading": "3. Gebruikerse verantwoordelikhede",
|
||||
"terms.s3_li1": "Alle inhoud wat u na DocuElevate oplaai",
|
||||
"terms.s3_li2": "Verseker dat u die regte het om dokumente op te laai en te verwerk",
|
||||
"terms.s3_li3": "Die vertroulikheid van u rekeningbesonderhede te handhaaf",
|
||||
"terms.s3_li4": "Enige aktiwiteit wat onder u rekening plaasvind",
|
||||
"terms.s3_p1": "U is verantwoordelik vir:",
|
||||
"terms.s3_p2_and": "en",
|
||||
"terms.s3_p2_post": ".",
|
||||
"terms.s3_p2_pre": "Deur ons diens te gebruik, stem u ook in tot ons",
|
||||
"terms.s4_heading": "4. Intellektuele Eiendomsregte",
|
||||
"terms.s4_p1": "DocuElevate respekteer intellektuele eiendomsregte. Gebruikers mag nie inhoud oplaai wat ander se intellektuele eiendomsregte oortree nie.",
|
||||
"terms.s5_heading": "5. Beperking van Aanspreeklikheid",
|
||||
"terms.s5_p1": "DocuElevate verskaf die diens \"soos dit is\" sonder enige waarborge van enige aard. Ons sal nie aanspreeklik wees vir enige direkte, indirekte, toevallige, spesiale, gevolglike, of strafmaatregte wat voortspruit uit u gebruik van of onvermoë om die diens te gebruik nie.",
|
||||
"terms.s6_heading": "6. Regsgeleentheid",
|
||||
"terms.s6_p1": "Hierdie Voorwaardes sal regeer word deur die wette van Duitsland, sonder om rekening te hou met sy konflik van regsbepalings.",
|
||||
"terms.s6_p2_post": ".",
|
||||
"terms.s6_p2_pre": "As u enige vrae het oor hierdie Voorwaardes, kontak ons asseblief by",
|
||||
"terms.s6_p3_mid": ". Vir lisensie-inligting, verwys asseblief na ons",
|
||||
"terms.s6_p3_post": ".",
|
||||
"terms.s6_p3_pre": "Vir inligting oor hoe ons koekies gebruik, sien asseblief ons",
|
||||
"translation.copied": "Gekopieer!",
|
||||
"translation.copy": "Kopieer",
|
||||
"translation.default_language_version": "Standaard Taal Versie",
|
||||
"translation.detected_language": "Gedigte taal",
|
||||
"translation.hide_text": "Verberg teks",
|
||||
"translation.load_translation": "Laai vertaling",
|
||||
"translation.no_translation": "Geen vertaling beskikbaar nie \u0011d dit mag steeds verwerk.",
|
||||
"translation.select_language": "Kies taal\u001a",
|
||||
"translation.select_target": "Graag kies 'n teikentaal.",
|
||||
"translation.show_text": "Wys teks",
|
||||
"translation.translate_btn": "Vertaal",
|
||||
"translation.translate_to": "Vertaal na 'n Ander Taal",
|
||||
"translation.translated_to": "Vertaal na",
|
||||
"translation.translating": "Vertaal\u001a",
|
||||
"translation.translation_failed": "Vertaling het gefaal",
|
||||
"upload.browse_button": "Bladsy Lêers",
|
||||
"upload.button_processing": "Verwerk...",
|
||||
"upload.camera_button": "Neem Foto / Skandeer Dokument",
|
||||
|
||||
@@ -41,6 +41,280 @@
|
||||
"about.story_heading": "የእኛ ታሪክ",
|
||||
"about.story_p1": "DocuElevate አንድ ዓላማ ወይዘር እንደ ማዕከል ተፈጥሯል፡፡ ለዚህ ሁሉም ሰው ገንዘብ ተመክሮ ምርት እና ስራዎች ይተይበይ እና ይህ አለመጠቀም ይሰራ ከነ አስተዳደር መዋቅር ይቅርቻለኝ፡፡",
|
||||
"about.story_p2": "እኛ በፍቱ እንደ ኦፕት ባለ ጌዜ ከኦፕይ አንጻ የሚገኙትን ብሄን ይዛል፡፡ መሰንያ ማስፈላለይነት እና የጽሁፍ ድርጊት ለማስፈጸም ፣ እንዲሁም ወዘንድ ከDropbox ፣ ከNextcloud እና ከPaperless NGX ከእንቀቅል ጋር ይጠቀሙ እና ከዱህብነ ድጋ መሐፀ ቅዱስ ለኦርክ ጥዋቲ እና ክወታደ ወይዘር ለጉይን ወይዘር በግብይም መረጃ እንዳይቀርበኝ፡፡",
|
||||
"admin_files.admin_only_badge": "አድሚን ብቻ",
|
||||
"admin_files.aria_breadcrumb": "ቅጥ",
|
||||
"admin_files.badge_delta_detected": "የዳሰላሰ ቆመ",
|
||||
"admin_files.badge_duplicate": "አይደገው",
|
||||
"admin_files.badge_in_db": "በዳታ",
|
||||
"admin_files.badge_on_disk": "በዳይሬክቶር",
|
||||
"admin_files.breadcrumb_workdir": "የሥራ መስመር",
|
||||
"admin_files.btn_download": "ይያዙ",
|
||||
"admin_files.col_actions": "እንክብካቤዎች",
|
||||
"admin_files.col_db": "ህብረታ",
|
||||
"admin_files.col_health": "ጤና",
|
||||
"admin_files.col_id": "መለያ",
|
||||
"admin_files.col_ingested": "የተቀበለ",
|
||||
"admin_files.col_local_filename": "ከእንቅስቃሴ_ፋይል_የወቅታዊ_ስም",
|
||||
"admin_files.col_missing_paths": "የሚገኙት መንገዶች",
|
||||
"admin_files.col_modified": "የተለዋወጠ",
|
||||
"admin_files.col_name": "ስም",
|
||||
"admin_files.col_original_file_path": "የመነሻ_ፋይል_መንገድ",
|
||||
"admin_files.col_original_filename": "የመነሻ ፋይል የወቅታዊ ስም",
|
||||
"admin_files.col_path_relative": "መንገድ (እንደ የሥራ_ዳታ)",
|
||||
"admin_files.col_processed_file_path": "የወሰነ_ፋይል_መንገድ",
|
||||
"admin_files.col_size": "መጠን",
|
||||
"admin_files.delta_detected_detail": "በዲስክ ላይ የሚገኙ የማንኛውም እድር ፋይል(ዎች) ይገኙ ስለሌለ፣ ላይ በወጣቶች ዝርዝሮች የወይዘ።",
|
||||
"admin_files.delta_detected_title": "ዴልታ ተገኝቷል።",
|
||||
"admin_files.empty_database": "በዳታበዝ ውስጥ ምንም ፋይል መዝግቦች አልተገኙም።",
|
||||
"admin_files.empty_directory": "ይህ ዳይሬክተር ዝቅተኛ አለው።",
|
||||
"admin_files.ghost_records_desc": "(በዲቢ ውስጥ ፋይል(ዎች) በዲስክ ላይ የለም)",
|
||||
"admin_files.ghost_records_heading": "ጎስት መዝግቦች",
|
||||
"admin_files.heading": "ፋይል አስተዳደር",
|
||||
"admin_files.health_missing": "የለም",
|
||||
"admin_files.health_ok": "እሺ",
|
||||
"admin_files.legend_file_exists": "ፋይል በዲስክ ላይ እንደሚገኝ አለ",
|
||||
"admin_files.legend_file_missing": "ፋይል ከዲስክ ውስጥ የለም",
|
||||
"admin_files.legend_found_in_db": "በዲቢ ውስጥ ይገኛል",
|
||||
"admin_files.legend_not_in_db": "በዲቢ ውስጥ አልነበረም (እድር)",
|
||||
"admin_files.legend_path_not_set": "መንገዱ አልተመዘገበም",
|
||||
"admin_files.no_delta": "የለም ዴልታ ይቅር፣ የፋይል ሥርዓት እና ዳታበዝ እንደተመሳሳይ።",
|
||||
"admin_files.no_ghost_records": "የጎስት መዝግቦች የለም።",
|
||||
"admin_files.no_orphan_files": "የእድር ፋይሎች የለም።",
|
||||
"admin_files.orphan_files_desc": "(በዲስክ ላይ የለም የዲቢ መዝግብ)",
|
||||
"admin_files.orphan_files_heading": "የእድር ፋይሎች",
|
||||
"admin_files.page_title": "ፋይል አስተዳደር – አድሚን",
|
||||
"admin_files.status_in_db": "በዲቢ ውስጥ",
|
||||
"admin_files.status_orphan": "እድር",
|
||||
"admin_files.tab_database": "ዳታበዝ መዝግቦች",
|
||||
"admin_files.tab_filesystem": "ፋይል ሥርዓት",
|
||||
"admin_files.tab_reconcile": "ግንኙነት",
|
||||
"admin_plans.aria_delete_plan": "{name} ማጥፊያ",
|
||||
"admin_plans.aria_edit_plan": "{name} ማሻሻያ",
|
||||
"admin_plans.aria_feature_n": "ባህርይ {n}",
|
||||
"admin_plans.aria_move_down": "{name} ወደ ታች እንዲንቀሳቀስ",
|
||||
"admin_plans.aria_move_up": "{name} ወደ ላይ እንዲንቀሳቀስ",
|
||||
"admin_plans.aria_remove_feature_n": "ባህርይ {n} ማውጣት",
|
||||
"admin_plans.btn_add_feature": "ባህርይ አክል",
|
||||
"admin_plans.btn_add_plan": "እቅፍ አክል",
|
||||
"admin_plans.btn_cancel": "ሰውነት",
|
||||
"admin_plans.btn_create": "እቅፍ ፍጠር",
|
||||
"admin_plans.btn_delete": "ማጥፊያ",
|
||||
"admin_plans.btn_edit": "ማሻሻያ",
|
||||
"admin_plans.btn_restore_defaults": "ዳግመ ውሂብ ይመለስ",
|
||||
"admin_plans.btn_restore_defaults_title": "ከታች ዳግመ የጥሪ ዕቅፍ አራት ይመለስ (እስከ ወቅት ድረስ ዕቅፍ የለ ነው)",
|
||||
"admin_plans.btn_restoring": "እንደገና ማሳደግ\n...",
|
||||
"admin_plans.btn_save_changes": "ለውጦችን ይህን",
|
||||
"admin_plans.btn_save_order": "ቅድመ ትዕዛዝን ይህን",
|
||||
"admin_plans.btn_saving": "እንደገና እንዲወደው\n...",
|
||||
"admin_plans.btn_stripe_setup": "የተከፈት ምዕባለ ይዕባል",
|
||||
"admin_plans.btn_stripe_setup_title": "የሚቀመጥ የኤፒአይ ቁሪትነትን እና ዕቅፍ ይወደው እንዲወን ይቃጡ",
|
||||
"admin_plans.col_actions": "እንቅስቃሴዎች",
|
||||
"admin_plans.col_active": "ተንቁ",
|
||||
"admin_plans.col_monthly": "ወርሃዊ",
|
||||
"admin_plans.col_monthly_limit": "ወርሃዊ ወርውር",
|
||||
"admin_plans.col_order": "ትዕዛዝ",
|
||||
"admin_plans.col_overage_pct": "መታዋቂ ስምንት %",
|
||||
"admin_plans.col_plan": "እቅፍ",
|
||||
"admin_plans.col_yearly": "አመታዊ",
|
||||
"admin_plans.coming_soon": "በምንጭ ይቀር",
|
||||
"admin_plans.featured_badge": "ታዋቂ",
|
||||
"admin_plans.field_active": "ተንቁ",
|
||||
"admin_plans.field_allow_overage": "ወደ ያን ይህን",
|
||||
"admin_plans.field_api_access": "የኤፒአይ ጌጣጌጥ",
|
||||
"admin_plans.field_badge_text": "የዋይይ ጽሑፍ",
|
||||
"admin_plans.field_buffer": "አቅም:",
|
||||
"admin_plans.field_cta_text": "ዲሬክተ የመላእክት ጽሑፍ",
|
||||
"admin_plans.field_docs_month": "ዶክስ / ወር",
|
||||
"admin_plans.field_featured": "ታዋቂ / አስቀርበ",
|
||||
"admin_plans.field_lifetime_docs": "የህይወት ዶክስ",
|
||||
"admin_plans.field_mailboxes": "ኢሜል የትንቢት",
|
||||
"admin_plans.field_max_file_size": "የአስቀምጣ ፋይል መጠን (MB)",
|
||||
"admin_plans.field_name": "ስም",
|
||||
"admin_plans.field_ocr_pages": "OCR ገጽዎች / ወር",
|
||||
"admin_plans.field_overage_doc_price": "በበለጠ ዋጋ / ሰነድ ($)",
|
||||
"admin_plans.field_overage_ocr_price": "በበለጠ ዋጋ / OCR ገጽ ($)",
|
||||
"admin_plans.field_plan_id": "አይዲ ፕላን",
|
||||
"admin_plans.field_price_monthly": "ወርሃዊ ዋጋ ($)",
|
||||
"admin_plans.field_price_yearly": "አመታዊ ዋጋ ($)",
|
||||
"admin_plans.field_sort_order": "የቅደም ተቀይር",
|
||||
"admin_plans.field_storage_dests": "የእንቅስቃሴ ቦታዎች",
|
||||
"admin_plans.field_stripe_monthly": "የስትራይፕ ዋጋ አይዲ (ወርሃዊ)",
|
||||
"admin_plans.field_stripe_yearly": "የስትራይፕ ዋጋ አይዲ (አመታዊ)",
|
||||
"admin_plans.field_tagline": "የታግላይን",
|
||||
"admin_plans.field_trial_days": "እንቅስቃሴ ቀናት",
|
||||
"admin_plans.free_label": "ግዴታ",
|
||||
"admin_plans.heading": "የፕላን አረፍተ ገጽ",
|
||||
"admin_plans.hint_features": "ይህን የመለኪያ ነገር በዚህ የፕላን ዋጋ ገጽ እንደሚታየው ይቃጥሉ።",
|
||||
"admin_plans.hint_plan_id": "በታች የተወሰነ ስም፣ ከፍለው በሚቀይሩበት ልዩ ነው።",
|
||||
"admin_plans.hint_zero_unlimited": "ገንዘብ ለዚህ 0 ይቅርታ።",
|
||||
"admin_plans.js_delete_confirm": "ፕላን \"{id}\" ይሰርዝ? ይህ አይደለም።",
|
||||
"admin_plans.js_delete_failed": "ማጥፊያ አልቻለም",
|
||||
"admin_plans.js_failed_load": "ፕላን ማስገንዘብ አልቻለም",
|
||||
"admin_plans.js_order_saved": "ትዕዛዝ ተይዞ ሆኗል!",
|
||||
"admin_plans.js_plan_created": "ፕላን ተፈጥሯል!",
|
||||
"admin_plans.js_plan_deleted": "ፕላን \"{id}\" ወይዘ ነው።",
|
||||
"admin_plans.js_plan_updated": "ፕላን ተዘውርል!",
|
||||
"admin_plans.js_reorder_failed": "ይዘው ለመደርሳት አልቻለም",
|
||||
"admin_plans.js_save_failed": "ይዘው ማድረግ አልቻለም",
|
||||
"admin_plans.js_seed_confirm": "አራት ዋቢ ፕላኖች ይገነባ? ይህ ፕላኖች የተገነቡባቸውን በማንኛውም ይሆናል።",
|
||||
"admin_plans.js_seed_failed": "ይገነባ አልቻለም",
|
||||
"admin_plans.js_yearly_enter": "ዕድል ዋጋ ይግቡ ቀጥታ ይሄው",
|
||||
"admin_plans.js_yearly_save": "ይቅርታ {pct}% ወይዘ ትክክለኛ",
|
||||
"admin_plans.loading": "ፕላን በማግኘት\u0019...",
|
||||
"admin_plans.modal_close_aria": "ሞዳል ይዘዉ",
|
||||
"admin_plans.modal_create_title": "ፕላን ይጨምሩ",
|
||||
"admin_plans.modal_edit_title_prefix": "ፕላንን እትም: ",
|
||||
"admin_plans.no_plans_intro": "አሁን የለም። ጠቅ ይቀርባሉ",
|
||||
"admin_plans.no_plans_suffix": "አራት ዋቢ ፕላኖች ይግነባሉ።",
|
||||
"admin_plans.overage_0pct": "0% (ትክክለኛ)",
|
||||
"admin_plans.overage_100pct": "100%",
|
||||
"admin_plans.overage_50pct": "50%",
|
||||
"admin_plans.overage_announce": "\u0017B ይገልጹ",
|
||||
"admin_plans.overage_buffer_body_prefix": "የተጨማሪ በፍርማ ነው",
|
||||
"admin_plans.overage_buffer_body_suffix": "እኛ በወር የአንድ ዓይነት እንደ X ዳንዴ እናስቀርባለን ነገር ግን በ",
|
||||
"admin_plans.overage_buffer_formula": "X (1 + በፍርማ%)",
|
||||
"admin_plans.overage_buffer_invisible": "ተጠቃሚዎች ይታወቃል",
|
||||
"admin_plans.overage_buffer_tail": "ዳንዴዎች። ምሳሌ፣ የ150 ዳንዴ/ወር እቅድ የ20% በፍርማ ነው በ180 ዳንዴ ይታወቃል። ይህ በተገለጹት ድርጅት በእስከ ወቅት ዳንዴዎች እንደዚህ ይወዳድ መውጣትውን ይከለክላቸው።",
|
||||
"admin_plans.overage_buffer_title": "የተጨማሪ በፍርማ ስለ",
|
||||
"admin_plans.overage_docs": "ዳንዴዎች።",
|
||||
"admin_plans.overage_docs_end": "ዳንዴዎች",
|
||||
"admin_plans.overage_enforce_at": "በታሰቡ",
|
||||
"admin_plans.page_title": "የእቅድ እንደዚህ ከተወሰነ የማይታወቀው ግን ዳንዴ",
|
||||
"admin_plans.section_basic_info": "መረጃ መሰብሰብ",
|
||||
"admin_plans.section_display": "ይዘት",
|
||||
"admin_plans.section_features": "የይዘት ዝርዝር።",
|
||||
"admin_plans.section_overage": "የተጨማሪ የወጪ እንደዚህ",
|
||||
"admin_plans.section_pricing": "ዋጋ",
|
||||
"admin_plans.section_stripe": "የጭምር መዋቅር",
|
||||
"admin_plans.section_volume": "የድምር ገደብ",
|
||||
"admin_plans.status_active": "አሁን ነው",
|
||||
"admin_plans.status_inactive": "ያነሱ",
|
||||
"admin_plans.stripe_desc_after": "በሚመነክቱ። ነፃ እቅዶች አይወድቅም ለመጥፉ ይዘን.",
|
||||
"admin_plans.stripe_desc_before": "ለይነት መመቸት ከላይ ይህ በጭምር ይገንዘብ ይታወቅዎችን ይጻፉ፣",
|
||||
"admin_plans.stripe_wizard_aria": "ጭምር መዋቅርን ወዲያ ይላኩ",
|
||||
"admin_plans.stripe_wizard_link": "የጭምር አይደገው",
|
||||
"admin_plans.stripe_wizard_text": "ጭምር መዋቅር",
|
||||
"admin_plans.subheading": "በህይወት ወይንም ይዘት ፕላን ይደርሳሉ።",
|
||||
"admin_plans.table_aria_label": "የመግቢያ ውድድር",
|
||||
"admin_users.add_user_profile_btn": "የተጠቃሚ ፕሮፋይል ወይዘር አክስዖት",
|
||||
"admin_users.admin_only_badge": "ወቅታዊ ብሩህ",
|
||||
"admin_users.btn_password": "ፕስዎርድ",
|
||||
"admin_users.btn_reset": "ዳግመ ይሪዩ",
|
||||
"admin_users.col_display_name": "የማሳያ ስም",
|
||||
"admin_users.col_documents": "እትምያዊ",
|
||||
"admin_users.col_email": "ኢሜይል",
|
||||
"admin_users.col_last_upload": "መጨረሻ ይሉለው",
|
||||
"admin_users.col_plan": "ዕቅፍ",
|
||||
"admin_users.col_role": "አካል",
|
||||
"admin_users.col_upload_limit": "የማውረድ ገደብ",
|
||||
"admin_users.col_user_id": "የተጠቃሚ መለያ",
|
||||
"admin_users.col_username": "የተጠቃሚ ስም",
|
||||
"admin_users.create_local_account_btn": "የአካውንት ቤት ይፍጠሩ",
|
||||
"admin_users.create_local_title": "የአካውንት ቤት ይፍጠሩ",
|
||||
"admin_users.delete_account_btn": "አካውንት ይጠፉ",
|
||||
"admin_users.delete_local_confirm": "ለማርገዝ ለምን እባክዎት?",
|
||||
"admin_users.delete_local_title": "የአካውንት ቤት ይሠርይ",
|
||||
"admin_users.delete_local_warning": "ይህ አይታወስም። የዚህ ግን የተጠቃሚ ዕትምያዊ ነው)",
|
||||
"admin_users.delete_profile_btn": "መለያ ይጠፉ",
|
||||
"admin_users.delete_profile_confirm": "ለማርገዝ ለምን እባክዎት?",
|
||||
"admin_users.delete_profile_title": "የተጠቃሚ ፕሮፋይል ይሠርይ",
|
||||
"admin_users.delete_profile_warning": "ይህ በአድሚን የተከታታይ ዕትምያዊ መረጃ አይታወስም። የዚህ ግን የተጠቃሚ ዕትምያዊ ነው)",
|
||||
"admin_users.deleting": "ይቀጥለው\u0000...",
|
||||
"admin_users.edit_local_title": "የአካውንት ቤት አሻሽት",
|
||||
"admin_users.filter_placeholder": "ወቅታዊው ይገንዝ አድርገው...",
|
||||
"admin_users.global_default": "ዋነኛ ዝንፈል",
|
||||
"admin_users.heading": "የተጠቃሚ አስተዳደር",
|
||||
"admin_users.js_account_created": "አካውንት ተፈጠርቷል",
|
||||
"admin_users.js_account_created_msg": "የ\"{username}\" ወቅታዊ አካውንት በሀገር ስሙ ተመነ",
|
||||
"admin_users.js_account_deleted_msg": "አካውንት ወይዘር ለ\"{username}\" ይሕጋይ",
|
||||
"admin_users.js_account_updated": "አካውንት ተወስዷል",
|
||||
"admin_users.js_delete_failed": "ውሉት ይቀመጣል",
|
||||
"admin_users.js_deleted": "ይቀጠል",
|
||||
"admin_users.js_email_not_sent": "ኢሜይል ይላክ አይደለም",
|
||||
"admin_users.js_email_sent": "ኢሜይል ተከበርቷል",
|
||||
"admin_users.js_email_sent_msg": "የፕስዎርድ ቡና ወይዘር በ\"{email}\" ይጐረኜው",
|
||||
"admin_users.js_failed": "ይቅርታ",
|
||||
"admin_users.js_failed_create": "አካውንት ማሳውቱ አልቻልኩም",
|
||||
"admin_users.js_failed_load_local": "ወቅታዊ ይሁንን አልቻልኬኝ",
|
||||
"admin_users.js_failed_load_users": "ተጠቃሚዎችን ማስገነባት የለም",
|
||||
"admin_users.js_failed_set_password": "የይለፍ ቃል ማዘጋጀት የለም.",
|
||||
"admin_users.js_failed_update": "የአካውንት ዘመን የለም.",
|
||||
"admin_users.js_network_error": "የኔትወርክ ችግኝ",
|
||||
"admin_users.js_password_set": "የይለፍ ቃል ተመልክቷል",
|
||||
"admin_users.js_password_set_msg": "\"{username}\" ለይለፍ ቃል ታደርጋለህ.",
|
||||
"admin_users.js_profile_deleted": "\"{id}\" የገጻችን መግነት ተሰበር.",
|
||||
"admin_users.js_profile_saved": "\"{id}\" የገጻችን መግነት ተመርጧል.",
|
||||
"admin_users.js_save_failed": "ማስቀመጥ ወይዘም የለም",
|
||||
"admin_users.js_saved": "ተመርጧል",
|
||||
"admin_users.js_smtp_not_configured": "SMTP አስተካክለዋል.",
|
||||
"admin_users.js_updated": "ዝርዝር ማድረግ",
|
||||
"admin_users.loading_users": "ተጠቃሚዎችን እየታይ ነኝ\n",
|
||||
"admin_users.local_account_active": "አካውንት ተግባር",
|
||||
"admin_users.local_accounts_heading": "አካውንት ተጠቃሚዎች",
|
||||
"admin_users.local_accounts_subheading": "በዚህ አገልግሎት በቀጥታ የተፈጠሩ ኢሜይል/የይለፍ ቃል አካውንቶች.",
|
||||
"admin_users.local_admin_privileges": "የአስተዳደር መረጃዎችን ማሰጣጠር",
|
||||
"admin_users.local_admin_privileges_short": "የአስተዳደር መረጃዎች",
|
||||
"admin_users.local_create_btn": "አካውንት ፈጥር",
|
||||
"admin_users.local_create_one": "አንዱን ፈጥር.",
|
||||
"admin_users.local_creating": "እንደምን እንቁም\n",
|
||||
"admin_users.local_display_name_optional": "(የሚፈለግ)",
|
||||
"admin_users.local_loading": "እንደምን እንቁም\n",
|
||||
"admin_users.local_no_accounts": "ምንም የአካውንት የለም.",
|
||||
"admin_users.local_password_hint": "አነስተኛ 8 ቁምፊዎች.",
|
||||
"admin_users.local_saving": "እንደምን እንቁም\n",
|
||||
"admin_users.local_username_hint": "3\u00120 ቁምፊዎች. ፊደላት፣ ቁጥሮች፣ ማዕድስቅ እና አድርቦ ብቸኛ.",
|
||||
"admin_users.modal_add_title": "የተጠቃሚ መገኛ ያክል",
|
||||
"admin_users.modal_billing_cycle_label": "የክፍያ ሂደት",
|
||||
"admin_users.modal_billing_monthly": "ወርሃዊ",
|
||||
"admin_users.modal_billing_yearly": "አመታዊ",
|
||||
"admin_users.modal_block_hint": "(አዲስ ወረቀቶች ማስገባት የለብም)",
|
||||
"admin_users.modal_block_label": "ይህን ተጠቃሚ ይከልክል",
|
||||
"admin_users.modal_close_aria": "ውይይት ዝግጅት ይዘን",
|
||||
"admin_users.modal_complimentary_hint": "(ተጠቃሚው ክፍል አስተዋጽኦ ነው ነገር ይታወቃል እንዴት ይቀበል\n ወይም ይተቀመጥ)",
|
||||
"admin_users.modal_complimentary_label": "ነፃ እቃ",
|
||||
"admin_users.modal_daily_limit_hint": "(አስቀምጥ ለአለመው አጠቃላይ ይሓድም)",
|
||||
"admin_users.modal_daily_limit_label": "ዕለታዊ የማስገባት ዕድል",
|
||||
"admin_users.modal_daily_limit_placeholder": "ምሳሌ 50 (0 = ያለ ወንቀት)",
|
||||
"admin_users.modal_display_name_label": "የማስታወቂያ ይርዝ",
|
||||
"admin_users.modal_display_name_placeholder": "አሊስ ስሚጥ (አማራጭ)",
|
||||
"admin_users.modal_edit_title": "የተጠቃሚ ፕሮፋይል ይሻሽሉ",
|
||||
"admin_users.modal_notes_label": "የአድሚን ማስታወሻዎች",
|
||||
"admin_users.modal_notes_placeholder": "ወቅታዊ ማስታወሻዎች በአድሚን ብቻ ይታይ\u0000\u0000",
|
||||
"admin_users.modal_period_start_hint": "የተመለከተ ወቅት ይህ ቀን እንደ ደረሰ ቆዳ ይቆጠር። ለወታደር ማስተከተል ማይትረ ይወደዱ።",
|
||||
"admin_users.modal_period_start_label": "የአቅርቦት ወቅት መጀመሪያ",
|
||||
"admin_users.modal_plan_business": "ንግድ — $7.99/ወር (300/ወር፣ የማይንገባ መሳት)",
|
||||
"admin_users.modal_plan_free": "ነፃ — 25 እንደነገር ፋይሎች",
|
||||
"admin_users.modal_plan_hint": "ይህን የተጠቃሚ የኬባ ዕድል ይኖረው። ዕድል ወደ ታዊት እንቀቅቅ ይፈጽሞል።",
|
||||
"admin_users.modal_plan_label": "የአቅርቦት እቅድ",
|
||||
"admin_users.modal_plan_professional": "ባለሞያ — $5.99/ወር (150/ወር፣ 3 መሳት)",
|
||||
"admin_users.modal_plan_starter": "መጀመሪያ — $2.99/ወር (50/ወር፣ 1 መሳት)",
|
||||
"admin_users.modal_save_changes": "ለውጦቹን አስቀምጥ",
|
||||
"admin_users.modal_saving": "በማስቀመጥ\u00000",
|
||||
"admin_users.modal_user_id_hint": "የሚስጧው መለያ ይገኝ በዳይሬክት ይመነጭባቸው።",
|
||||
"admin_users.modal_user_id_label": "የተጠቃሚ መለያ",
|
||||
"admin_users.modal_user_id_placeholder": "user@example.com ወይም OAuth sub",
|
||||
"admin_users.new_account_btn": "አዲስ account",
|
||||
"admin_users.new_password_label": "አዲስ ፓውርድ",
|
||||
"admin_users.no_users_add_hint": "አንዳንዴ ዳይሬክት ወይም ከአንዱ ፕሮፋይል ከስር ይውሉ።",
|
||||
"admin_users.no_users_found": "የተጠቃሚ የለም።",
|
||||
"admin_users.no_users_search_hint": "ሌላ የፈላጊ ነገር ይውሉ።",
|
||||
"admin_users.page_title": "የተጠቃሚ አስተዳደር – አድሚን – DocuElevate",
|
||||
"admin_users.pagination_page_of": "ከ",
|
||||
"admin_users.per_day": "/ ቀን",
|
||||
"admin_users.role_admin": "አድሚን",
|
||||
"admin_users.role_user": "ተጠቃሚ",
|
||||
"admin_users.search_users_label": "የተጠቃሚዎችን እይ",
|
||||
"admin_users.set_password_btn": "ፓውርድ ለይ",
|
||||
"admin_users.set_password_desc": "ተጠቃሚው ወደዚህ ይወድዱ እንዲለውት",
|
||||
"admin_users.set_password_desc_pre": "ቀላል የአዲስ ፓውርድ ይወድዱ",
|
||||
"admin_users.set_password_title": "የጊዜ ፓውርድ ይወርድ",
|
||||
"admin_users.setting": "ቅንጅት\u00000",
|
||||
"admin_users.status_blocked": "እንደተከሰተ",
|
||||
"admin_users.status_unverified": "ወታደር",
|
||||
"admin_users.subheading": "የተጠቃሚ ፕሮፋይሎችን ይቀመጣሉ፣ በተጠቃሚ ወሪ ለእንድ ማስተባባል ወይም የሰነዶቹን ይቅደድ።",
|
||||
"admin_users.total_count_users": "{count} ተጠቃሚዎች",
|
||||
"admin_users.total_no_users": "የተጠቃሚዎች ወደቀረ",
|
||||
"admin_users.total_one_user": "1 ተጠቃሚ",
|
||||
"api_tokens.col_created": "ተፈጥሯል",
|
||||
"api_tokens.col_last_ip": "መጨረሻ አይፒ",
|
||||
"api_tokens.col_last_used": "መጨረሻ ይጠቀሙ",
|
||||
@@ -73,6 +347,19 @@
|
||||
"api_tokens.usage_intro_pre": "የእቅፍ እንቁላል",
|
||||
"api_tokens.your_tokens": "የእቅፍ ወንድም፤",
|
||||
"app.name": "DocuElevate",
|
||||
"attribution.heading": "ሶፍትዌር የሶስት ጋር ተያያዥ እንቅስቃሴዎች",
|
||||
"attribution.intro": "DocuElevate ብዙ ኦፕን ምንጭ ላይበርሊያዎችና ዕቃዎችን አጠቃላይ ይጠቀማል። ይህ የሂደት ወደ ኦፕን ምንጭ ሶፍትዌር ለውጤታቸው እናመሰግናለን።",
|
||||
"attribution.page_title": "DocuElevate - ሶፍትዌር የሶስት ጋር ተያይዞች",
|
||||
"attribution.paramiko_lgpl_note": "ማስታወሻ፡ ይህ ዕቃ በGNU ዝቅተኛ አገልግሎታዊ ፈቃድ v2.1 (LGPL-2.1) ታይቷል።",
|
||||
"attribution.section_docker": "ዶከር የምርት ምስሎች",
|
||||
"attribution.section_frontend": "ፍርማ እንደገና",
|
||||
"attribution.section_python": "ፓይተን ገንቢያዎች",
|
||||
"attribution.special_lgpl_link": "እዚህ",
|
||||
"attribution.special_lgpl_post": ".",
|
||||
"attribution.special_lgpl_pre": "የLGPL ፈቃድ ቅፅ ሊገኝ ይችላል",
|
||||
"attribution.special_source_post": ".",
|
||||
"attribution.special_source_pre": "ይህ የሶፍትዌር ፓራሚኮ ይካተታል፣ የተወደደበ በLGPL ነው። ለፓራሚኮ የምርት ኮድ በዚህ ይገኛል",
|
||||
"attribution.special_title": "ወደ ላይ እንደተገኘ ዕይታ:",
|
||||
"audit.col_ip": "አይፒ",
|
||||
"audit.col_resource": "ወገን",
|
||||
"audit.col_timestamp": "የተቐበለ ጊዜ",
|
||||
@@ -252,6 +539,41 @@
|
||||
"cookie.notice_label": "ኮኪ ማስታወቂያ",
|
||||
"cookie.policy_link": "የኮኪ ፖሊሲ",
|
||||
"cookie.privacy_link": "የግለሰቦች መረጃ ማስታወቂያ",
|
||||
"cookie_policy.heading": "ኮኪ ፖሊሲ",
|
||||
"cookie_policy.last_updated": "ከእንደዚህ በፊት የተከናወነ:",
|
||||
"cookie_policy.page_title": "ኮኪ ፖሊሲ - DocuElevate",
|
||||
"cookie_policy.s1_heading": "ኮኪ ምንድነው",
|
||||
"cookie_policy.s1_p1": "ኮኪዎች ወቅታዊ የጽሁፍ ፋይሎች ናቸው እና ስለዚያ ወቅታዊ ገፅን ስታጎበኝ በኮምፒውተርዎ ወይም በሞባይል መሳሪያዎ ላይ ይቀመጣሉ። እነሱ ወቅታዊ ገፆች በመተግበሪያ ወይም ለድር ባለቤቶች መረጃ ለመስጠት በተለምዶ ይጠቀማሉ።",
|
||||
"cookie_policy.s2_heading": "ኮኪዎችን እንዴት እንጠቀማለን",
|
||||
"cookie_policy.s2_li1_body": "ስለዚያ ወደ ውስጥ ወይም ኬይ ላይ ሲገባ እና የመመርጃ ጊዜያችሁን እነው ይኖሩ ይወዳደርን።",
|
||||
"cookie_policy.s2_li1_label": "አይደ ግብይት እና ኬይ አስተዳደር:",
|
||||
"cookie_policy.s2_p1_post": "ለዚህ አስተዳደር ፕርቲፕየን:",
|
||||
"cookie_policy.s2_p1_pre": "DocuElevate ይጠቀማል",
|
||||
"cookie_policy.s2_p1_strong": "በግልፅ ይጠቀማል የቀድሞ የኮኪዎች ብቁ ወተገብይያን",
|
||||
"cookie_policy.s2_p2": "ይህ ኮኪዎች ወንበር እንዲገኙ ለአገልግሎታችን አስተዳደር አይገባም። እንዴት እንዳይቀያይሉ ስለዚህ አገልግሎታችንን ወግየን ይቀፍላሉ።",
|
||||
"cookie_policy.s2_p3": "ይህ ስለዚህ ይህ መሳሪያ አስተዳደር ከተመለከተ ከስር ነው ወታት የምንቀጣጠር እና ወይም በማንኛውም ዘንቀዳይ ኮኪዎች ይኖሩበት።",
|
||||
"cookie_policy.s3_col_duration": "ወቅት",
|
||||
"cookie_policy.s3_col_name": "ስም",
|
||||
"cookie_policy.s3_col_purpose": "ዓላማ",
|
||||
"cookie_policy.s3_col_type": "ዓይነት",
|
||||
"cookie_policy.s3_heading": "የኮኪ ዝርዝር",
|
||||
"cookie_policy.s3_row1_duration": "ቦታ (በድምበሪ ዝውረት ወይም ለመውጣት ይሰረዝ)",
|
||||
"cookie_policy.s3_row1_purpose": "እቅፍዎን ይያዙዋል፤ ወበለእይዝዕ ይግዙ።",
|
||||
"cookie_policy.s3_row1_type": "በግልፅ ይሁን",
|
||||
"cookie_policy.s3_row2_duration": "በግዴ ክልል (ወቅታዊ እንዴት)",
|
||||
"cookie_policy.s3_row2_purpose": "ኮኪው እንድንዉነዋቸው ይነሱ ዌብ ባለቤቶችን ለኤር ይግዙ።",
|
||||
"cookie_policy.s3_row2_type": "በግልፅ ይሁን",
|
||||
"cookie_policy.s4_heading": "ሶስት-ወቀድ ኮኪዎች የለም",
|
||||
"cookie_policy.s4_p1": "DocuElevate ሶስት-ወቀድ ኮኪዎችን፣ ይሳይው ወይም ወዜማ ማውራብ ኮኪዎች ወይም የአለኛዎች ኮኪዎች አይጠቀሙም። የእርስዎን ግለሰብ ወይም ወና አይደሏም፣ ወዚያ እንዳይሆን ወገን ከመዜና ክንዱ ተቀርባል።",
|
||||
"cookie_policy.s4_p2_pre": "የተንበው ወዶ ኢየነዋቸው",
|
||||
"cookie_policy.s4_privacy_link": "የግለሰብ መዝገብ ማህበሩን",
|
||||
"cookie_policy.s5_heading": "ኮኪዎችን ወይም አስተዳደር",
|
||||
"cookie_policy.s5_p1": "ብዙ የድር አከባቢዎች ኮኪዎችን ከተነሱ በሂደቱ ምንም እንንዌን ወደሚጎነወትን ይወዳዳር። ይህን ወተዉ ኮኪዮችን ሞተእ ይህ በኮንር ምናምነት ይቀርባ ይቀፍላል።",
|
||||
"cookie_policy.s5_p2": "ይህን ወቀጥ ክንዱ እንዴት ዄรድው አርክመው ይወዳዳር።",
|
||||
"cookie_policy.s5_p3_and": "እና",
|
||||
"cookie_policy.s5_p3_pre": "ይህ ኮኪ ፖሊሲ እንደ ወይንወ ይኖሩ ወይንወ እንድን።",
|
||||
"cookie_policy.s5_privacy_link": "የግለሰብ መዝገብ ማህበሩን",
|
||||
"cookie_policy.s5_terms_link": "የአገልግሎት ውል",
|
||||
"credentials.col_action": "እርምጃ",
|
||||
"credentials.col_credential": "መረጃ",
|
||||
"credentials.col_source": "ዋነኛ",
|
||||
@@ -517,6 +839,31 @@
|
||||
"help.workflows_step_5_create": "ይያዙ – አዳዲስ ስነ-ጽሁፎች በዚህ ፍርድ በራስ-ሂደት ይከናወናሉ.",
|
||||
"help.workflows_typical_steps": "አማካይ ደረጃዎች",
|
||||
"help.workflows_what_is": "ፍርድ ምንድነው?",
|
||||
"imprint.business_registration_heading": "የድርጅት ቀፅ ውርድ",
|
||||
"imprint.business_registration_vat": "የእርሜሪት የተመለከተ ቁጥር እንደ ማህበሩ ነው እንደ ቀድሞ ስህተት:",
|
||||
"imprint.contact_heading": "ያገኙ መረጃ",
|
||||
"imprint.dispute_heading": "ኦንላይን መታምበር",
|
||||
"imprint.dispute_p1": "የውርድ ኮሚሽን አይደለዎት ለኦንላይን መታምበር (OS):",
|
||||
"imprint.dispute_p2": "እኛ በዚህ የተወሰነ የእድል እንደአመቺነት ይለመኑ ወይም ማቋረጥ አልነንም።",
|
||||
"imprint.heading": "ምስል",
|
||||
"imprint.legal_copyright": "በዚህ ድረ-ገፅ ላይ ያለው ይዘት በመብት የተደገፈ ነው። የመብት ሕግ ውስጥ ያለው ገደም ማለክ መደበኛ እንደ ፈቃድ የሆነ ጽሑፍ ይወዳድር።",
|
||||
"imprint.legal_heading": "የሕግ ማስታወቂያዎች",
|
||||
"imprint.legal_liability": "የይዘት ቁጥጥር በተወጥ ቢሆንም የውስጣዊ እቃዎችን ላይ ምንም ተግባር አልገነዘብም። ይህ የተያያዘ ገጽታ ለይተቶ እንደአለመንጩ እንደ ይዘት ይዘው ኢስተኮንቻም ይሆን።",
|
||||
"imprint.page_title": "ምስል - DocuElevate",
|
||||
"imprint.policies_cookie_desc": "ይጠቀሙ የተደርጓል ዝርዝር መረጃ",
|
||||
"imprint.policies_cookie_label": "የኩኪ ደረጃ",
|
||||
"imprint.policies_heading": "የተዳራሽ ደረጃዎች",
|
||||
"imprint.policies_intro": "እኛን እንዳለን የተያያዘ የተዳራሽ የደረጃ:",
|
||||
"imprint.policies_license_desc": "የምንጠቀም የሶፍትዌር ትዕዛዝ",
|
||||
"imprint.policies_license_label": "የትዕዛዝ መረጃ",
|
||||
"imprint.policies_privacy_desc": "የእንድን ውል ገፅታዎች",
|
||||
"imprint.policies_privacy_label": "የግለ ማስታወቂያ",
|
||||
"imprint.policies_terms_desc": "የDocuElevate ምርት",
|
||||
"imprint.policies_terms_label": "የአገልግሎት ደረጃ",
|
||||
"imprint.provider_heading": "የአገልግሎት እቅፍ",
|
||||
"imprint.responsible_content_heading": "ወረርሽ ዝርዝር ገጻቹ",
|
||||
"imprint.responsible_content_rstv": "በ§ 55 Abs. 2 RStV:",
|
||||
"imprint.subtitle": "መረጃ በ§ 5 TMG (የጀርመን የማህበረሰባት ሕግ)",
|
||||
"index.badge_intelligent": "የጭነት ዳሰሳ",
|
||||
"index.button_browse_files": "ፋይሎችን ይከብዩ",
|
||||
"index.button_upload": "አስገባ",
|
||||
@@ -728,6 +1075,19 @@
|
||||
"language.tr": "ቱርክኛ",
|
||||
"language.uk": "ዩክሬንኛ",
|
||||
"language.zh": "መዋክብ",
|
||||
"license.apache_description": "DocuElevate በApache License 2.0 የተመለከተ የሚሞላ በየተወዳጁን የዕደም ድርጅት ሲነክከን መለኪያ ይወክልዎታይ ይዋልዝን በእንደ ሕግ የሚገኝ እና ወርዳ ነው።",
|
||||
"license.apache_heading": "Apache ዕቃ 2.0",
|
||||
"license.heading": "የትዕዛዝ መረጃ",
|
||||
"license.page_title": "የትዕዛዝ መረጃ - DocuElevate",
|
||||
"license.related_about_link": "እንደ ገጽ",
|
||||
"license.related_and": "እና",
|
||||
"license.related_heading": "የተያያዙ መረጃዎች",
|
||||
"license.related_p1_post": "እንደተያያዙ ንድፉን እንደ በሚጠቀሙ ይወደይችበት ይወዳድረዎት።",
|
||||
"license.related_p1_pre": "ይህ የትዕዛዝ ይዘግይ ይሁን እንደዚህ ይህ እትሙንኩ እንዲህ በውስጥ እንዲሁም መመርዘቅ",
|
||||
"license.related_p2_post": ".",
|
||||
"license.related_p2_pre": "ነገር ግን በDocuElevate ላይ በዚህ ላይ እንዲገናኝ ነገር ወዳጅ።",
|
||||
"license.related_privacy_link": "የግለ መረጃ ንድፉ",
|
||||
"license.related_terms_link": "የአገልግሎት ደረጃ",
|
||||
"nav.about": "ገና",
|
||||
"nav.account_menu": "መለያ ምናሌ",
|
||||
"nav.account_menu_for": "{name} የመለያ ምናሌ",
|
||||
@@ -828,6 +1188,185 @@
|
||||
"pipelines.system_label": "አውታር",
|
||||
"pipelines.system_pipeline_label": "የስርዓት ፓይፕላይን (ለሁሉም ተጠቃሚዎች የታይ)",
|
||||
"pipelines.title": "ይቀጣጣ ፓይፕላይን",
|
||||
"privacy.heading": "DocuElevate – የግለ መረጃ መንገድ",
|
||||
"privacy.last_updated": "የመጨረሻ ዝሬ:",
|
||||
"privacy.page_title": "የግለ መረጃ - DocuElevate",
|
||||
"privacy.s10_access_body": "እንደውለዳው በሚያስቡት ይፈልጋሉ።",
|
||||
"privacy.s10_access_label": "ወትስ ይፈልግ (አንቀጽ 15):",
|
||||
"privacy.s10_complaint_body": "ወይዘኑ ይበቱ፣ ከሚነሲያው የውስጣዊ ታሕተ ሥልኩን ተደርጓል። በጀርመን: Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI). በተወዳዳሪው: የታህበሩ ጥቅር የመረጃ (ICO). በስዊዘርላንድ: የዩነው ዘገባናም ዩነ ዘገባ (FDPIC).",
|
||||
"privacy.s10_complaint_label": "ወይዘና ማስታወቂያ:",
|
||||
"privacy.s10_contact": "ላይ ወይም ጋር እንገናኝ ለማድረግ ወይም ወንጌል ተከታታይ በመልክ መገኘት ትችላለህ።",
|
||||
"privacy.s10_erasure_body": "እንዲሁም የግለው ውል ከመጠኑ ባለፈ ቀን አስተዋይ የሆነ እንደ ወንድ እለን.",
|
||||
"privacy.s10_erasure_label": "ማስወግድ መብት (ሕገ-ወቅት 17):",
|
||||
"privacy.s10_heading": "10. የእርስዎ መብቶች (EU / EEA / UK / ስዊዘርላንድ)",
|
||||
"privacy.s10_object_body": "በእውነተኛ ዕለት ዘርእዩ፣ በገንዘብ አተረዋይው ዖንቅ፤ መምከር እንደ ንቀተ እንደሚያጠ አንቀሳቀስ።",
|
||||
"privacy.s10_object_label": "ማስተዋልዕድ መብት (ሕገ-ወቅት 21):",
|
||||
"privacy.s10_p1": "በGDPR (እና የ UK GDPR / የስዊዘርላንድ nFADP ዝም ገባ አማርኛ ሁኔታ ለሚወጡ የተመለከቱ መብቶች አሉል፣",
|
||||
"privacy.s10_portability_body": "ውህዳው በአገልግሎቱ(ማስታወቅያ) ላይ ትምርሽ የሌለው ማንን ወንዙ ወገብ ዝና አይነት ይጠይቃሉዋን ፍላጐቶች ይቋወርኩ።",
|
||||
"privacy.s10_portability_label": "የውይይት መብት (ሕገ-ወቅት 20):",
|
||||
"privacy.s10_rectification_body": "በውጭ አስቸጋሪነው መላኪያዎች እንደሚስተኖር ይደርሱ ይችላሉ።",
|
||||
"privacy.s10_rectification_label": "የማስታወቅያ መብት (ሕገ-ወቅት 16):",
|
||||
"privacy.s10_response": "ገነዕ ደብዳቤ እንክሞኑ ወር ወቀጣቂ እንዳቀዳ በሙሉ ይነሱ።",
|
||||
"privacy.s10_restriction_body": "በይም ሓላፍነት በመሬታት ውላስ ይገነዘቡት ቸለ የእርስዎ ማህበረሰብዎች ይጠይቁ።",
|
||||
"privacy.s10_restriction_label": "መብት ያገእን ወይይት (ሕገ-ወቅት 18):",
|
||||
"privacy.s10_withdraw_body": "በመነሣ አብሰቦች ተጠቃልሎች ይጠቃሉ አለፋ እንደአኙል ",
|
||||
"privacy.s10_withdraw_label": "የማርኬ መብት ማስታወቅያ:",
|
||||
"privacy.s11_categories_body": "መለያያዎች (ስም፣ ኢሜይል፣) ደም እቃ ወይ ፈለገው ",
|
||||
"privacy.s11_categories_label": "የተሰበሰባዊ መገናኛ ቀዌዛይዎችዶች:",
|
||||
"privacy.s11_contact": "የይግ ማህበረሰብ ለማረጋገጥ ይጠይቁ:",
|
||||
"privacy.s11_correct_body": "የምላስለ ይምርእምዋስለ",
|
||||
"privacy.s11_correct_label": "ክንዝና ይችላሉ ():",
|
||||
"privacy.s11_delete_body": "ከጭው በኩሉተዌሌ ይለዉ ተምሩ መዳለፊዬ ወይ ታስለ ,ካቖ.",
|
||||
"privacy.s11_delete_label": "መለያ መስቀሬ (መብት):",
|
||||
"privacy.s11_heading": "11. እንደ ማን ይደበስኩ ጌር-አሜሪቫ (CCPA / የመደቀቆች)",
|
||||
"privacy.s11_know_body": "በግለው ይረዓት ይገናኛው ለመጨረሻው የሚቲር ነገር የንፈት ክስተት:",
|
||||
"privacy.s11_know_label": "መለያ መደረገ (ሕገ-ወቅት):",
|
||||
"privacy.s11_limit_body": "አይቀበሉን ከግልነት ወግራችሀው ይቷወ ወይ ወለወይ ይወዲኑን:",
|
||||
"privacy.s11_limit_label": "ቀዓል ከእኳነት ጭድ",
|
||||
"privacy.s11_nondiscrim_body": "አይቀዋየዋ ይገናኛሉ ይምርልዎቹ ይቀጀሉ;",
|
||||
"privacy.s11_nondiscrim_label": "ይገለጹኝ -ዕ (ዕ):",
|
||||
"privacy.s11_optout_body": "ከህይወት ይበር ከቀጡ ይቦርክ ፀኖ ወደ ዘዋከኝ ቀለለው ወይም ይገናኛሉ:",
|
||||
"privacy.s11_optout_label": "የሱ ገንዘብ (ምንጫይ):",
|
||||
"privacy.s11_p1": "ስለ እንደነበሩ ገንዘቡ በዋክ አየክ ምንጩ ይባሉ:",
|
||||
"privacy.s11_purpose_body": "ውዝ ካሙ የምድ እንደሚስ ይጠይቃḋሉมั่น;",
|
||||
"privacy.s11_purpose_label": " የቅይዝ ገንዘቡ:",
|
||||
"privacy.s11_response": "ጸረብሁ ይባሉ ስነ አዓቢት(strict):",
|
||||
"privacy.s12_access_body": "ላይ ይገናኛሉ መወወ ውዳጅ (ይናቝቱ)!",
|
||||
"privacy.s12_access_label": "መሠረ የማረ ዑስፍፈ",
|
||||
"privacy.s12_contact": "መመሪያ ወወይሉ ይገናኛሉ:",
|
||||
"privacy.s12_contact_post": " . . እንደምናቂ (ኮን ወወይቀባ Sparse) ፈረው",
|
||||
"privacy.s12_correction_body": "እባክዎን የግለሰቦች መረጃዎች እንደተሳሳተ ወይም ሙሉ በሙሉ አልነበርም ይቅርታ ይቀበሩልን።",
|
||||
"privacy.s12_correction_label": "የማስተካከያ حق:",
|
||||
"privacy.s12_heading": "12. ተጨማሪ መ����ያዎች - ካናዳ (PIPEDA / ከቤክ ሕግ 25)",
|
||||
"privacy.s12_li1": "እኛ የግለሰቦች መረጃዎችን በእንቅስቃሴ እና ይዘት ማቀናበር እንወጣ።",
|
||||
"privacy.s12_p1": "የሚገኙ በካናዳ እንዲህ የተመዘገቡ ነው በተመዘገቡ የግለሰቦች መረጃ የኤሌክትሮኒክ ወግን ነው (PIPEDA) ወይም ወግን ይከተሉ።",
|
||||
"privacy.s12_quebec_body": "በሕግ 25 መሰረት ተጨማሪ መለኪያዎች እና በስም መረጃዎችን ዕውጽ ማዛውም የሚችል ናቸው እና የክንውን ዳባ ለቀጥነት እና በአስፈር እንደካናዳ ተጠቃሚ ይዋንክ መመዝገቡ።",
|
||||
"privacy.s12_quebec_label": "የከቤክ አርባ፣",
|
||||
"privacy.s12_withdraw_body": "ለእንደ-አበል ወይም ሉዕላች ይሁን ባለውን እንደገኝ ይጠይቃል ይገባይ أضف እፈጥሩ! በድጋሚ የነበረ ተገበት ሁሉ በማን ይቀበሉ ጠይቃ ይገባይ።",
|
||||
"privacy.s12_withdraw_label": "የማይሞሉ ዕዋይዩ!:",
|
||||
"privacy.s13_brazil_body": "የሚገኙ በብራይል ለታከናኛ ነው። የግለሰቦች መረጃውን መኰረት ይችላሉ።",
|
||||
"privacy.s13_brazil_label": "ብራይል (LGPD - የመወዳደሪያ መረጃ-ዕቃ, ንስሐ 13.709/2018):",
|
||||
"privacy.s13_contact": "ጠይቅ:",
|
||||
"privacy.s13_heading": "13. ተጨማሪ መዋዛዎች - የአውሮፓ ሕገመት (LGPD & አንዳንድ የሌሎች)",
|
||||
"privacy.s13_li1": "ማስተናገድ እንደ ተከናውነቱ ይርዓሼንን ጠይቂ!",
|
||||
"privacy.s13_li2": "የአንዳንድ ወይዘህ ወይም ዥብም!",
|
||||
"privacy.s13_li3": "አርነት, ንቅዓት, ወይስ ወዋዋይ ንኳ!",
|
||||
"privacy.s13_li4": "የመረጃዎች ውድቀት ወይም ቅድመ ዝየሐቀት!",
|
||||
"privacy.s13_li5": "ወታ ይልክ ወደ መጠየቅ!",
|
||||
"privacy.s13_li6": "ወይዘ ይርዑት ወደ መመጠረ ዱር!",
|
||||
"privacy.s13_li7": "ይጎወዝ ንዱስ ወወቲሁ!",
|
||||
"privacy.s13_li8": "ዳው ወትንሙ!",
|
||||
"privacy.s13_other_body": "ይውደይ ወእንደ ሎቻያዉ ባለስልጣን ወድ የዕቅቡ ዊን ኘmeans ይውደይ! ተወልደይ የግለሰቦች እንዳ ቆሚ ይብሉዘኡ።",
|
||||
"privacy.s13_other_label": "የሌላ በጋቾች ሀገራት:",
|
||||
"privacy.s14_apj_body": "ፈታ ሕግ እንደ ግለሰቦች ሕቅዖቶች ይቅርታ ወወደ ሌሎች ዕቅ ይዋንኒ ወውን ዋት የሥዋይዜ ቀይ ያለን!",
|
||||
"privacy.s14_apj_label": "ሌላ የኤፒ ግለሰቦች በግለሱባች (ሲንግስ ወ ወደ New Zealand ሕገ ሥቃይት ወይም ሕገ ግለሰቦች ዋን):",
|
||||
"privacy.s14_australia_body": "የአውስትራሊያ አመለስተ ነው። የና እንደ ርዕሱ ይቨዝናቸው ይህነው አመልእ አቂው ፕላን ፍቀ ነፅን ወውሌ ደትውዕይ ርቋይ እና ወይዘይ!",
|
||||
"privacy.s14_australia_label": "አውስትራሊያ (ዋንስጥ ኤቱ እይ ሳባ ንዳ አላሊስ ወነደይ)!",
|
||||
"privacy.s14_contact": "ጠይቅ:",
|
||||
"privacy.s14_heading": "14. ተጨማሪ መዶሟ - የአሜሪካ ዤል",
|
||||
"privacy.s14_japan_body": "የጃፓን አገሩ ከታክስ ይምንወዎን ኣኡይስህ ወምቅሉ ቀይኀ ይምንወይግው ንጥሉቅ!",
|
||||
"privacy.s14_japan_label": "ጃፓን (APPI - ወጤታን ውይዘይ ግለሰቦች):",
|
||||
"privacy.s14_korea_body": "የደቂብከ ዴንነይ ገጀገርእዉ ወውይዳ ዲህዪነቆቅ ወተሳስበ። ይኖ በማዳና ግዋወሳ ወኦቤዋይ ይይበቡ!",
|
||||
"privacy.s14_korea_label": "ደቢዓይት ወደ ደብረ ዴማይ ዲህላት:",
|
||||
"privacy.s15_contact": "ጠይቅ:",
|
||||
"privacy.s15_heading": "15. ተጨማሪ መደም - የውቅር",
|
||||
"privacy.s15_p1": "የመዋዛዎቃዮ የኔ መቀበሪ! ከፍልክ ድልዝ ይፍቀ የየፍቀከዱ የንዳቁም ይስጠቢደቴኢም! ውልቁ ወይ፣ የሴው ወውይዕይ ዝዝዙ ይወደዋ",
|
||||
"privacy.s16_cookies_link": "የኮኪ ፖሊሲ",
|
||||
"privacy.s16_heading": "16. ወመነዮች ምስራታዎ",
|
||||
"privacy.s16_license_link": "የThis ወኟ!",
|
||||
"privacy.s16_p1": "ምዕቀ ውርዞ አድርብና እገዀብናል; ወይት! ክቃ ወይ ኢበዩን!",
|
||||
"privacy.s16_p2_pre": "ይህ ፕራይቨሲ መግለጫ ወይም ስለ የግል ውሂብዎ የምንካሄድ ወቅት ካላችሁ ጥያቄዎች ወይም የቅርብ ዝርዝር መረጃ ካላችሁ እባኮትን ወይዘላችሁ ይገናኝ.",
|
||||
"privacy.s16_p3_pre": "እባኮትን ምንጭ ይመልክት ይህን ይመለክት:",
|
||||
"privacy.s16_terms_link": "እንደ አማርኛ ምዝገባ ይህ ነገር ነው",
|
||||
"privacy.s1_address": "አልተር ስታይንወግ 3, 20459 ሃምበርግ, ግርማንይ",
|
||||
"privacy.s1_company": "ክርስቲን ሉዊስ አይቲ ቡዝአርጋ",
|
||||
"privacy.s1_contact_label": "የቅርብ ኢሜል:",
|
||||
"privacy.s1_heading": "1. የውሂብ አስተዳደር",
|
||||
"privacy.s1_p1": "በአውሮፓ ኅብረት በመሣረት የሚሰራ የውሂብ አመራር ነው፣ እንደ አይነት ይልቁን ክፍዝ መዝገባ ይቀየር እናቸው በዓለም ላይ እንደ ይለወዅ ይቀይርበት..",
|
||||
"privacy.s1_p3": "የተወሰኑ ገንዘብ ይምዝገባን ንደሚለው በመዝገብ ውስብስብኝ ሲከ ለጊት በ30 ቀናት ውስድ ይወይርበት (ወይስ የድርጅቱ ገንዘብ ምላሽን በፍርድ አማኞች).",
|
||||
"privacy.s2_heading": "2. የይቅርታ ንጅ እና ፕራይቨሲ ጥናት",
|
||||
"privacy.s2_p1_pre": "ይህ መመሪያ እንደሚኖር፣ ዳንዊይ ፋይል ላይ እንዲሆን.",
|
||||
"privacy.s2_p2": "ይህ ወዴር ብዙ ተጠቃሚዎችን ይከፍፍ የውበሌማ አይኞች",
|
||||
"privacy.s3_audit_body": "እንበሳዋን ዱልው ግብር እትህን አስቀዳውን ላይ.",
|
||||
"privacy.s3_audit_label": "የውሂብ በኩል:",
|
||||
"privacy.s3_auth_body": "እንግዲኛ ከአንዱ ወቅት ላይ ስለ ንብረት የሚፈርስ መረጃ ከውስብስብ",
|
||||
"privacy.s3_auth_label": "ተጠቃሚ እንደቀርብ እንደወይ.",
|
||||
"privacy.s3_doc_body": "ምንጭ እንዳይኖር ወንድ ይዲሴ",
|
||||
"privacy.s3_doc_label": "ዝም ምዕሬባ.",
|
||||
"privacy.s3_heading": "3. ውሂብ መረጃ & ፍላጎቶች",
|
||||
"privacy.s3_legal_body": "ይህ ወይዞ መሠረታል",
|
||||
"privacy.s3_legal_label": "የሕግ መደብ (GDPR ማዕከል 6):",
|
||||
"privacy.s3_li1": "(1)(b) ውርደ ውሂብ: ዝም ነው ዚመነቡ.",
|
||||
"privacy.s3_li2": "(1)(c) የሕግ ወግ : የውሇይ የአዋጅ ፀሐፈ.",
|
||||
"privacy.s3_li3": "(1)(f) አንዱ ዝገይ ዐይነት: ዝም ውንድ",
|
||||
"privacy.s4_heading": "4. የውሂብ መጠን & ዕድሎች ወይ:",
|
||||
"privacy.s4_li1": "የመክቢደት ወውሂብ እንዳችን ብቱ አለ",
|
||||
"privacy.s4_li2": "ደበቦች ዕድል ይዲሴ ይሆናል (OCR, የምንፃጸም ዜርዕ) ንገናኝ እንዳይዘን",
|
||||
"privacy.s4_li3": "የምእክብ በኋላ ይገኙ.",
|
||||
"privacy.s4_li4": "የማይታወቅ ገንዘብ በኩል ይዘዋ.",
|
||||
"privacy.s4_li5": "የወይለኔ ዓይነት/ጝልዓት ዕድል ድብዝ፡",
|
||||
"privacy.s4_p1": "ዳንዊወ ዛሉ መሰጠት. ዳንዊወ ዛሉ ይገኙ ወይዘላ",
|
||||
"privacy.s5_cookie_link": "የኩኪ",
|
||||
"privacy.s5_heading": "5. የኩኪ ወ ወንርቒ",
|
||||
"privacy.s5_p1_post": "እዚያ ይሆናሉ. ቶ ይል ተገኙ ብት ታይይይ릯 ይገኛል. ከተለጠፋ ይህ ይሆናል በጂም",
|
||||
"privacy.s5_p1_pre": "ዳንዊወ ዛሉ፣ ዝወንፈር 아래",
|
||||
"privacy.s5_p1_strong": "ዳንዊወ ተገኝት ኬ",
|
||||
"privacy.s5_p2_body": "تحاليل لعند ንብ",
|
||||
"privacy.s5_p2_label": "እኛ እንደ ውሊይ",
|
||||
"privacy.s5_p3_pre": "እባኮትን ወበዛቃ ይሠት አንዱ",
|
||||
"privacy.s6_ai_body": "ወደ ውስብስብህ ንቁይ",
|
||||
"privacy.s6_ai_label": "AI አስተዳደር 服務 (OpenAI, Azure የአንባሳ ተመንበር, እሱታስ):",
|
||||
"privacy.s6_heading": "6. የሶስት ፈጠራ አገልግሎቶች",
|
||||
"privacy.s6_no_sale_body": "እኛ የግለ መረጃዎችን ከሶስት ፈጠራዎች ጋር ለምርት እና ምርጫ ወይም የአገልግሎትን አቀማጥ አንገንዝ አንናብስ።",
|
||||
"privacy.s6_no_sale_label": "ለምርት ማስታወቂያ መልካም አለመለካም:",
|
||||
"privacy.s6_oauth_body": "እንዲሁም ለOAuth ማረጋገጫ ሲመረጥ በተነ ተመንበር መረጃዎችን ይሠራበታል እና ማንኛውም የአቅሞ መረጃ እንዲያወርዳሉ። እነዚህ ተመንበሮች የራሳቸውን የግለ እድለት ይይዥበታል።",
|
||||
"privacy.s6_oauth_label": "OAuth ተመንበሮች (Google, Dropbox, Microsoft):",
|
||||
"privacy.s6_storage_body": "ሰነዶች በየተመንበሮች ውስጥ የተቀባይ ክፍል አውዋኝ ይይዥባሉ ይሆናሉ። የተመንበርዎ ክፍል ይተቀምታል ይሊተይት እና ይገባበሳል።",
|
||||
"privacy.s6_storage_label": "የሰረገላ የተመንበር እና እነዚሕንነት (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):",
|
||||
"privacy.s7_adequacy_body": "የአውሮፓ ኮሚሽን ቀድሞ የተቀበል ደረጃን እንዳይገነዝ (በምሳሌ፣ የአንደኛ ዩኔስ ዕድለት፣ ስዊዘርላንድ እና ካናዳ (የንግዶች፣ ጃፓን፣ ደቡብ ኮርያ).",
|
||||
"privacy.s7_adequacy_label": "ደረጃ ውሳኔዎች",
|
||||
"privacy.s7_contact": "እባኮትን፣ እኛን ግንኙነት ይህ የሚኖር ወይም ያደረገ መግለጫ ይይዥባል።",
|
||||
"privacy.s7_heading": "7. የአለም ዳታ ባለቤት",
|
||||
"privacy.s7_idta_body": "ለባለመንት ውስብስች በተመንበር ይይዥባል።",
|
||||
"privacy.s7_idta_label": "የተነሱ በደመና ዳታ የኃላፊ ውሳኔዎች በእንግሊዝ `IDTAs",
|
||||
"privacy.s7_p1": "DocuElevate በአውሮፓ አውሮፓ / EEA በዋና ቀጥፍ ይጠቀማል። የሰነድ መረጃ በEEA ውስጥ ሲወውው ለየነ እና ምሳሌት ወይም የትውልድ ኦበቤ በኩር ኦን ዳታ ገጽታ ለማንኜውምፈገም።",
|
||||
"privacy.s7_scc_body": "አቅርቦቻቸው በአውሮፓ ኮሚሽን የተገለጸ (2021/914/EU) ውስጥ ለንፀንሽና ዋሚስማቭ ቆሬ።",
|
||||
"privacy.s7_scc_label": "የመዋቅር መሣሪያዎች (SCCs)",
|
||||
"privacy.s8_audit_body": "ለእለቱ ለ90 ቀን ይሗዳል።",
|
||||
"privacy.s8_audit_label": "የእንግዛዎች መታወቅ:",
|
||||
"privacy.s8_contact": "የእርን መልዕውነት ወይም ሁሉንም የሚያብቁ ወደምንቀጥዋ ይልኮት።",
|
||||
"privacy.s8_files_body": "ይህ ገጽታን ወስታ እንድስሣ ይካደ ሁሉንም ይገምሙ። የአፕሊካር ኢክስ ለአትነት ማንነት ማለትን እስከመለደቩ😥",
|
||||
"privacy.s8_files_label": "የክፉፍን ክፍሎችና መረጃዎች:",
|
||||
"privacy.s8_heading": "8. የዳታ ዚማ",
|
||||
"privacy.s8_oauth_body": "ወይም ባትናቸው እንዲሞክር ይቀደስ።",
|
||||
"privacy.s8_oauth_label": "OAuth ግለሰቦች:",
|
||||
"privacy.s8_p1": "በገንቡ ゙ ይኖር።",
|
||||
"privacy.s8_session_body": "እንዳግኝ ወይም በዚስነን ጊዜ ይቀይረ።",
|
||||
"privacy.s8_session_label": "የተንቀዉ ቍጥር:",
|
||||
"privacy.s9_heading": "9. የዳታ ደህንነት",
|
||||
"privacy.s9_li1": "ወርቅ ከኢንንነችን እንኖር.",
|
||||
"privacy.s9_li2": "ወፈቲ ማሻሻጣ ወር ሙር",
|
||||
"privacy.s9_li3": "የመወያዘ ወል ኢንግዊዕን/ውቅን ዘአዩ ወንጓዌ ወር.",
|
||||
"privacy.s9_li4": "ዋካ ኤርግህ ዋይኚ/ዋይ የእአጣበ",
|
||||
"privacy.s9_li5": "የአዉጩ መቀዙ እንዳብሚ ወመተክተው ይወናገዝ.",
|
||||
"privacy.s9_p1": "የተመሬ ውቅን ይዝይልይ ።",
|
||||
"privacy.toc_1": "የዳታ ክትትል",
|
||||
"privacy.toc_10": "የእርስዎ መቅር (ዩኒየን / EEA / ዩኬን ይዌይ)",
|
||||
"privacy.toc_11": "የተጨማሪ መቅር - ዩናይትድ ስቴት( CCPA/CPRA)",
|
||||
"privacy.toc_12": "የተጨማሪ መቅር - ካናዳ (PIPEDA / ዳሩ 25)",
|
||||
"privacy.toc_13": "የተጨማሪ መቅር - ላቲን አሜሪካ (LGPD & ሌላዎች)",
|
||||
"privacy.toc_14": "เพิ่มเติมสิทธิ์ – เอเชียแปซิฟิก & ญี่ปุ่น",
|
||||
"privacy.toc_15": "เพิ่มเติมสิทธิ์ – ยูเครน",
|
||||
"privacy.toc_16": "การอัปเดตประกาศความเป็นส่วนตัวนี้",
|
||||
"privacy.toc_2": "ขอบเขตของประกาศความเป็นส่วนตัวนี้",
|
||||
"privacy.toc_3": "การเก็บข้อมูล & วัตถุประสงค์",
|
||||
"privacy.toc_4": "การลดปริมาณข้อมูล & การจำกัดวัตถุประสงค์",
|
||||
"privacy.toc_5": "การใช้คุกกี้ & เทคโนโลยีที่คล้ายคลึงกัน",
|
||||
"privacy.toc_6": "บริการของบุคคลที่สาม",
|
||||
"privacy.toc_7": "การโอนข้อมูลระหว่างประเทศ",
|
||||
"privacy.toc_8": "การเก็บรักษาข้อมูล",
|
||||
"privacy.toc_9": "ความปลอดภัยของข้อมูล",
|
||||
"privacy.toc_heading": "เนื้อหา",
|
||||
"profile.avatar_alt": "የእርስዎ የመገኘት ምስል",
|
||||
"profile.avatar_heading": "የመገኘት ምስል",
|
||||
"profile.avatar_remove": "ባለሙያ ምስል አስወግድ",
|
||||
@@ -838,6 +1377,9 @@
|
||||
"profile.contact_email_label": "ግንኙነት / ማስገንዘብ ኢሜይል",
|
||||
"profile.contact_email_placeholder": "you@example.com",
|
||||
"profile.current_password": "ወቅታዊ የይለፍ ቃል",
|
||||
"profile.default_document_language_auto": "የስርዓት ዳፍን ይጠቀሙ",
|
||||
"profile.default_document_language_hint": "በሌላ ቋንቋ ውስጥ እባክዎ ይታወቅ እንዲመለከታም ይታወቅ ይሆን ይታወቅ. ወይም ዝነዋ በስርዓት ዳፍን ይጠቀሙ (እንግሊዝኛ) ይቻላል።",
|
||||
"profile.default_document_language_label": "የመደበኛ የጽሁፍ ቋንቋ",
|
||||
"profile.dismiss": "ከታች አውጣ",
|
||||
"profile.display_name_hint": "ወደ ግል ይቀጥሉ ወይም እቃ ወይም ዊእንታ ይግበሩ።",
|
||||
"profile.display_name_label": "የማሳያ ስም",
|
||||
@@ -1040,14 +1582,66 @@
|
||||
"similarity.subtitle": "የበለጠ ሴምንቲክ ተመሳሳይነት ያለው ሰነዶች ጥንዶች በነጥብ ዝርዝር.",
|
||||
"similarity.trigger_aria": "ለሁሉም እባኮች የተገኙ ኢምበዲንግ ሂደት እንዲጀምሩ ገደም ይሁን",
|
||||
"similarity.trigger_now": "ይገድም አሁን",
|
||||
"status.active": "አንቀጽ",
|
||||
"status.ai_empty_response": "(ዝግጅት)",
|
||||
"status.ai_extraction_desc": "ምንጭ ይታወቅ፡ የአንድ ማህበራዊ ጽሁፍ ቀጥታ አይከታተል። በቅንብር የተዘጋጅቦ ከሚንቀሳቀሱ የAI እንደምን ይጎዲያ ይምረጡ። የይልም ሰዎች ኮድ፣ ኢይ ዩነው ቁጥር እና ታግኖች:",
|
||||
"status.ai_extraction_failed": "AI የመግነዝ አለመኖር",
|
||||
"status.ai_extraction_label": "የማህበረሰብ ጽሁፍ",
|
||||
"status.ai_extraction_placeholder": "የምንጭ ውይይት ይገኙ ይስጡ።",
|
||||
"status.ai_extraction_title": "AI መግነዝ ሙከራ",
|
||||
"status.app_version": "የመተግበሪያ እትም",
|
||||
"status.as_account": "እንደ",
|
||||
"status.auth_required": "መታወቂያ ያስፈልጋል",
|
||||
"status.build_date": "የምርት ቀን",
|
||||
"status.config_settings": "የቅንብር ቅንብር እና የአንዱ ኑሮ",
|
||||
"status.config_settings_desc": "የቅንብር መገለጫዎች እና አንዱ የተጣይ ይመልከቱ።",
|
||||
"status.configure_now": "አሁን ይቅንብር",
|
||||
"status.configured": "ተቅንብረ",
|
||||
"status.connection_error": "መገናኛ ውስጥ አለመኖር",
|
||||
"status.connection_test_failed": "መገናኛ ሙከራ በይምነት",
|
||||
"status.connection_test_successful": "መገናኛ ሙከራ በደንብ",
|
||||
"status.container_id": "የኮንቴነር መለያ",
|
||||
"status.container_started": "ኮንቴይንር ተመልሷል",
|
||||
"status.dashboard_subtitle": "ይህ ዳሽቦርድ የተቅንብረ ቅዒት ዒይታ አመባር ክፍል ምልክቱን ይወስዳል።",
|
||||
"status.debug_mode": "የመታወቂያ እቅድ",
|
||||
"status.error_running_extraction": "ሂደቱ ላይ የለን= ",
|
||||
"status.error_testing_connection": "መገናኛው ኡንቢ በይምነት",
|
||||
"status.error_testing_notifications": "ሐይች ላይ መሽለን ሉወቀ",
|
||||
"status.extracted_tags": "ውስጥ የተወጣ ምልክቶች",
|
||||
"status.git_commit": "ጊት ኮሚት",
|
||||
"status.inactive": "ወይንም",
|
||||
"status.json_parse_issue": "የJSON አውታረ ወደ ተመልከቱን በይምነት",
|
||||
"status.last_check": "የመጨረሻ ተመልክት",
|
||||
"status.manage": "እንዲሠርቅ",
|
||||
"status.modal_default_message": "እንደተጠንቅል እንቅስቃሴው ተገንቡ።",
|
||||
"status.modal_default_title": "አስተናጋጅ",
|
||||
"status.no_details": "ዝርዝር የለም",
|
||||
"status.not_configured": "አልተለዋወጠም",
|
||||
"status.notification_config_missing": "የማስታወቂያ ኮንፊግሬሽን የለም",
|
||||
"status.open": "ክፈት",
|
||||
"status.page_title": "የስርዓት እንቅስቃሴ",
|
||||
"status.parsed_json_label": "የተቀረጸ _JSON_",
|
||||
"status.provider_config_details": "{name} ኮንፊግሬሽን ዝርዝር",
|
||||
"status.provider_details": "የአቅራቢ ዝርዝር",
|
||||
"status.raw_llm_response": "ዐቀፍ LLM መልስ",
|
||||
"status.run_extraction": "እንቅስቃሴ ይሂዱ",
|
||||
"status.running": "ሂደት\u0000...",
|
||||
"status.sending": "ላክ...",
|
||||
"status.setting_label": "ማስተካከያ",
|
||||
"status.test_connection": "ግንኙነት ይወቁ",
|
||||
"status.test_extraction": "እንቅስቃሴ ይነቃነቁ",
|
||||
"status.test_failed": "ፈተና ዐዲስ",
|
||||
"status.test_notification_failed": "የማስታወቂያ ፈተና ዐዲስ",
|
||||
"status.test_notification_sent": "የማስታወቂያ ፈተና ገፅታለው",
|
||||
"status.test_notifications": "እርከን ፈተናዎች",
|
||||
"status.test_provider": "ፈተና {name}",
|
||||
"status.test_successful": "ፈተና ማሳሰብ አለ",
|
||||
"status.testing": "ፈተና ይኖር...",
|
||||
"status.token_expired": "የእርስዎ መለያ ተጠንቃለች ወይም ብዙ ነው። እባኮትን ይዘይዝ ይህን ግንኙነት ይወይቃየዱ።",
|
||||
"status.token_valid_for": "መለያ ትንበዦች የለነው:",
|
||||
"status.value_label": "ዋጋ",
|
||||
"status.view_config": "ዝርዝር ኮንፊግሬሽን ይመልክቱ",
|
||||
"status.view_details": "ዝርዝር ይመልክቱ",
|
||||
"subscription.available_plans_heading": "የአስተያየት እቅዶች",
|
||||
"subscription.back_to_dashboard": "ወደ ዳሽቦርድ ይዘው ተመለሱ",
|
||||
"subscription.cancel_pending": "ለዚህ ለማቋረጥ",
|
||||
@@ -1083,6 +1677,51 @@
|
||||
"subscription.upgrade_info": "ስርዓት እምበሩ ይቀየር። ታች ይቀየር።",
|
||||
"subscription.upgrade_to_prefix": "ወደ እውነት ይሂዱ",
|
||||
"subscription.usage_heading": "ጥቅም",
|
||||
"terms.cookie_link": "ኮኪ ፖሊሲ",
|
||||
"terms.heading": "የአገልግሎት ይዘት",
|
||||
"terms.last_updated": "መጨረሻ የታደሰው:",
|
||||
"terms.license_link": "የእንቅስቃሴ መረጃ",
|
||||
"terms.page_title": "የአገልግሎት ይዘት - DocuElevate",
|
||||
"terms.privacy_link": "የግለሰቦች ፖሊሲ",
|
||||
"terms.s1_heading": "1. የይዘት ተቀበል",
|
||||
"terms.s1_p1": "ወደ DocuElevate በመግባት ወይም በመጠቀም ይዘት ይተገበሩ ይገባል። ይህን ይዘት አትቀበል ከሆነ፣ እባክህ ይህን አገልግሎት አትጠቀም።",
|
||||
"terms.s2_heading": "2. የአገልግሎት መግለጫ",
|
||||
"terms.s2_p1": "DocuElevate የሰነድ ሂደት፣ OCR፣ የመረጃ እንቅስቃሴ፣ እና የእቃ ቅድሚያ አገልግሎቶችን ይሰጣል። በአንደኛ ጊዜ የአገልግሎት አንዱን ወይም ተዋሕዶ ይለውጡ ይችላሉ።",
|
||||
"terms.s3_heading": "3. የተጠቃሚ ኃላፊዎች",
|
||||
"terms.s3_li1": "እንዳሉት ወደ DocuElevate ይላኩ",
|
||||
"terms.s3_li2": "ሰነዶችን ወይም መግባት ለማድረግ የግል መብቶችን መያዝ",
|
||||
"terms.s3_li3": "ይተወው ያለውን ማስታወቂያ መጠበቅ",
|
||||
"terms.s3_li4": "እየነገር የሚኖርዎ ማንኛውም ድርጅት",
|
||||
"terms.s3_p1": "እርስዎ መረጃ ነው:",
|
||||
"terms.s3_p2_and": "እና",
|
||||
"terms.s3_p2_post": ".",
|
||||
"terms.s3_p2_pre": "የአገልግሎታችን አገልግሎትን በመጠቀም ይህ ይወዳድር ይገባል።",
|
||||
"terms.s4_heading": "4. የኢንተርኔት ንብረት መብቶች",
|
||||
"terms.s4_p1": "DocuElevate የኢንተርኔት ንብረት መብቶችን ይነጻል። ተጠቃሚዎች የሌሎችን የኢንተርኔት ንብረት መብቶችን የሚያስታውሰው ይላኩ.",
|
||||
"terms.s5_heading": "5. የወደ ክስ መያዣ",
|
||||
"terms.s5_p1": "DocuElevate አገልግሎትን \"እንደ ዋጋ\" እንደምትቀመጥ ይሰጣል። ይህ አገልግሎት መጠቀም ወይም እንዳይወዳድር የታመነ ጉዳዮች ወይም የፍላጐቱን ጉዳዮችን አይወቅም።",
|
||||
"terms.s6_heading": "6. የተመራባን ሕግ",
|
||||
"terms.s6_p1": "ይህ ይዘት በጀርመን የሕግ ደንብ ይወዳደር ይችላል፣ ያን ባለቤታቸው ወንጌል አለ።",
|
||||
"terms.s6_p2_post": ".",
|
||||
"terms.s6_p2_pre": "ይህ ይዘት ላይ እንደማቀበል ወይም ይወዳድር ይነጃሉ እንዲሁም ወይም",
|
||||
"terms.s6_p3_mid": ". ወደ ሥርዓት መወዳደር ወይም እንደሚገኜ ወደ ገንዘብ መንፈሳዊው ገንዘብ ይማርቁ።",
|
||||
"terms.s6_p3_post": ".",
|
||||
"terms.s6_p3_pre": "ኮኪ በምን እንደሚገኝ ለመረጃ እባክዎን ይመልከቱ",
|
||||
"translation.copied": "ተቅድመ!",
|
||||
"translation.copy": "ቅድሚያ",
|
||||
"translation.default_language_version": "የመደበኛ ቋንቋ ምርዝ",
|
||||
"translation.detected_language": "የተገኘ ቋንቋ",
|
||||
"translation.hide_text": "ጽሑፍ ሠይት",
|
||||
"translation.load_translation": "ትርጉም አስገባ",
|
||||
"translation.no_translation": "ትርጉም የለም በሚገኝ ጊዜ ላይ - በማቀየር ላይ ሊኖር ይችላል",
|
||||
"translation.select_language": "ቋንቋ انتخب...",
|
||||
"translation.select_target": "እባኮትን የተመረጠ ቋንቋ ይምረጡ።",
|
||||
"translation.show_text": "ጽሑፍ አሳይ",
|
||||
"translation.translate_btn": "ትርጉም",
|
||||
"translation.translate_to": "ወደ ሌላ ቋንቋ ትርጉም አድርግ",
|
||||
"translation.translated_to": "ወደ ተተርጉሞ",
|
||||
"translation.translating": "በማቀየር ላይ...",
|
||||
"translation.translation_failed": "ትርጉም ወደ ኋላ ተወው",
|
||||
"upload.browse_button": "ፋይሎችን ይምረጡ",
|
||||
"upload.button_processing": "በሂደት...",
|
||||
"upload.camera_button": "ፎቶ እንደፈለግ/ሰነድ ይቀመጡ",
|
||||
|
||||
@@ -41,6 +41,280 @@
|
||||
"about.story_heading": "قصتنا",
|
||||
"about.story_p1": "تم إنشاء DocuElevate بهدف واحد: تبسيط وإدارة الوثائق للجميع، سواء كنت شركة ناشئة صغيرة أو مؤسسة كبيرة.",
|
||||
"about.story_p2": "نحن نستفيد من قوة مزودي الذكاء الاصطناعي القابلين للتوصيل (OpenAI، Anthropic Claude، Google Gemini، Ollama، OpenRouter، Portkey، والمزيد) لاستخراج البيانات الوصفية وتنقيح النصوص، وندمج بسلاسة مع Dropbox وNextcloud وPaperless NGX للتخزين والفهرسة، ونستفيد من Azure Document Intelligence لتمييز النصوص (OCR)، وحتى نستخدم Gotenberg لتحويل الملفات إلى PDF.",
|
||||
"admin_files.admin_only_badge": "للإداريين فقط",
|
||||
"admin_files.aria_breadcrumb": "مسار",
|
||||
"admin_files.badge_delta_detected": "تم اكتشاف دلتا",
|
||||
"admin_files.badge_duplicate": "مكرر",
|
||||
"admin_files.badge_in_db": "في قاعدة البيانات",
|
||||
"admin_files.badge_on_disk": "على القرص",
|
||||
"admin_files.breadcrumb_workdir": "مجلد العمل",
|
||||
"admin_files.btn_download": "تحميل",
|
||||
"admin_files.col_actions": "الإجراءات",
|
||||
"admin_files.col_db": "قاعدة البيانات",
|
||||
"admin_files.col_health": "الصحة",
|
||||
"admin_files.col_id": "المعرف",
|
||||
"admin_files.col_ingested": "تم الاستيعاب",
|
||||
"admin_files.col_local_filename": "اسم الملف المحلي",
|
||||
"admin_files.col_missing_paths": "المسارات المفقودة",
|
||||
"admin_files.col_modified": "تم التعديل",
|
||||
"admin_files.col_name": "الاسم",
|
||||
"admin_files.col_original_file_path": "مسار الملف الأصلي",
|
||||
"admin_files.col_original_filename": "اسم الملف الأصلي",
|
||||
"admin_files.col_path_relative": "المسار (نسبي إلى دليل العمل)",
|
||||
"admin_files.col_processed_file_path": "مسار الملف المعالج",
|
||||
"admin_files.col_size": "الحجم",
|
||||
"admin_files.delta_detected_detail": "تم العثور على {orphan_count} ملف يتيم(ة) على القرص بدون سجل في قاعدة البيانات، و {ghost_count} سجل في قاعدة البيانات مع ملفات مفقودة على القرص.",
|
||||
"admin_files.delta_detected_title": "تم اكتشاف دلتا.",
|
||||
"admin_files.empty_database": "لم يتم العثور على سجلات ملفات في قاعدة البيانات.",
|
||||
"admin_files.empty_directory": "هذا الدليل فارغ.",
|
||||
"admin_files.ghost_records_desc": "(في قاعدة البيانات، ملفات مفقودة على القرص)",
|
||||
"admin_files.ghost_records_heading": "سجلات شبحية",
|
||||
"admin_files.heading": "مدير الملفات",
|
||||
"admin_files.health_missing": "مفقود",
|
||||
"admin_files.health_ok": "حسناً",
|
||||
"admin_files.legend_file_exists": "الملف موجود على القرص",
|
||||
"admin_files.legend_file_missing": "الملف مفقود من القرص",
|
||||
"admin_files.legend_found_in_db": "تم العثور عليه في قاعدة البيانات",
|
||||
"admin_files.legend_not_in_db": "غير موجود في قاعدة البيانات (يتيم)",
|
||||
"admin_files.legend_path_not_set": "لم يتم تعيين المسار",
|
||||
"admin_files.no_delta": "لم يتم العثور على دلتا — نظام الملفات وقاعدة البيانات متزامنان.",
|
||||
"admin_files.no_ghost_records": "لم يتم العثور على سجلات شبحية.",
|
||||
"admin_files.no_orphan_files": "لم يتم العثور على ملفات يتيمة.",
|
||||
"admin_files.orphan_files_desc": "(على القرص، لا يوجد سجل في قاعدة البيانات)",
|
||||
"admin_files.orphan_files_heading": "ملفات يتيمة",
|
||||
"admin_files.page_title": "مدير الملفات – المسؤول",
|
||||
"admin_files.status_in_db": "في قاعدة البيانات",
|
||||
"admin_files.status_orphan": "يتيم",
|
||||
"admin_files.tab_database": "سجلات قاعدة البيانات",
|
||||
"admin_files.tab_filesystem": "نظام الملفات",
|
||||
"admin_files.tab_reconcile": "التوفيق",
|
||||
"admin_plans.aria_delete_plan": "حذف {name}",
|
||||
"admin_plans.aria_edit_plan": "تعديل {name}",
|
||||
"admin_plans.aria_feature_n": "ميزة {n}",
|
||||
"admin_plans.aria_move_down": "نقل {name} إلى الأسفل",
|
||||
"admin_plans.aria_move_up": "نقل {name} إلى الأعلى",
|
||||
"admin_plans.aria_remove_feature_n": "إزالة الميزة {n}",
|
||||
"admin_plans.btn_add_feature": "إضافة ميزة",
|
||||
"admin_plans.btn_add_plan": "إضافة خطة",
|
||||
"admin_plans.btn_cancel": "إلغاء",
|
||||
"admin_plans.btn_create": "إنشاء خطة",
|
||||
"admin_plans.btn_delete": "حذف",
|
||||
"admin_plans.btn_edit": "تعديل",
|
||||
"admin_plans.btn_restore_defaults": "استعادة الإعدادات الافتراضية",
|
||||
"admin_plans.btn_restore_defaults_title": "استعادة جميع الخطط الافتراضية الأربعة (فقط إذا كانت لا توجد خطط بعد)",
|
||||
"admin_plans.btn_restoring": "يتم الاستعادة\u0002026",
|
||||
"admin_plans.btn_save_changes": "حفظ التغييرات",
|
||||
"admin_plans.btn_save_order": "حفظ الترتيب",
|
||||
"admin_plans.btn_saving": "يتم الحفظ\u0002026",
|
||||
"admin_plans.btn_stripe_setup": "إعداد سترايب",
|
||||
"admin_plans.btn_stripe_setup_title": "فتح معالج إعداد سترايب لتكوين مفاتيح API ومزامنة الخطط",
|
||||
"admin_plans.col_actions": "الإجراءات",
|
||||
"admin_plans.col_active": "نشط",
|
||||
"admin_plans.col_monthly": "شهري",
|
||||
"admin_plans.col_monthly_limit": "الحد الشهري",
|
||||
"admin_plans.col_order": "الترتيب",
|
||||
"admin_plans.col_overage_pct": "نسبة تجاوز الاستخدام %",
|
||||
"admin_plans.col_plan": "خطة",
|
||||
"admin_plans.col_yearly": "سنوي",
|
||||
"admin_plans.coming_soon": "سيأتي قريبًا",
|
||||
"admin_plans.featured_badge": "مميز",
|
||||
"admin_plans.field_active": "نشط",
|
||||
"admin_plans.field_allow_overage": "السماح بفواتير تجاوز الاستخدام",
|
||||
"admin_plans.field_api_access": "وصول API",
|
||||
"admin_plans.field_badge_text": "نص الشارة",
|
||||
"admin_plans.field_buffer": "الوسادة:",
|
||||
"admin_plans.field_cta_text": "نص زر CTA",
|
||||
"admin_plans.field_docs_month": "الوثائق / شهر",
|
||||
"admin_plans.field_featured": "مميز / بارز",
|
||||
"admin_plans.field_lifetime_docs": "الوثائق مدى الحياة",
|
||||
"admin_plans.field_mailboxes": "صناديق البريد الإلكتروني",
|
||||
"admin_plans.field_max_file_size": "أقصى حجم ملف (ميغابايت)",
|
||||
"admin_plans.field_name": "الاسم",
|
||||
"admin_plans.field_ocr_pages": "صفحات OCR / شهر",
|
||||
"admin_plans.field_overage_doc_price": "سعر الزيادة / مستند ($)",
|
||||
"admin_plans.field_overage_ocr_price": "سعر الزيادة / صفحة OCR ($)",
|
||||
"admin_plans.field_plan_id": "معرف الخطة",
|
||||
"admin_plans.field_price_monthly": "السعر الشهري ($)",
|
||||
"admin_plans.field_price_yearly": "السعر السنوي ($)",
|
||||
"admin_plans.field_sort_order": "ترتيب الفرز",
|
||||
"admin_plans.field_storage_dests": "وجهات التخزين",
|
||||
"admin_plans.field_stripe_monthly": "معرف سعر Stripe (شهري)",
|
||||
"admin_plans.field_stripe_yearly": "معرف سعر Stripe (سنوي)",
|
||||
"admin_plans.field_tagline": "الشعار",
|
||||
"admin_plans.field_trial_days": "أيام التجربة",
|
||||
"admin_plans.free_label": "مجاني",
|
||||
"admin_plans.heading": "مصمم الخطة",
|
||||
"admin_plans.hint_features": "نقاط الرصاص هذه تظهر في بطاقة صفحة التسعير لهذه الخطة.",
|
||||
"admin_plans.hint_plan_id": "سلاسل صغيرة، لا يمكن تغييرها بعد الإنشاء.",
|
||||
"admin_plans.hint_zero_unlimited": "أدخل 0 لغير المحدود.",
|
||||
"admin_plans.js_delete_confirm": "هل تريد حذف الخطة \"{id}\"؟ لا يمكن التراجع عن ذلك.",
|
||||
"admin_plans.js_delete_failed": "فشل الحذف",
|
||||
"admin_plans.js_failed_load": "فشل في تحميل الخطط",
|
||||
"admin_plans.js_order_saved": "تم حفظ الطلب!",
|
||||
"admin_plans.js_plan_created": "تم إنشاء الخطة!",
|
||||
"admin_plans.js_plan_deleted": "تم حذف الخطة \"{id}\".",
|
||||
"admin_plans.js_plan_updated": "تم تحديث الخطة!",
|
||||
"admin_plans.js_reorder_failed": "فشل إعادة الطلب",
|
||||
"admin_plans.js_save_failed": "فشل الحفظ",
|
||||
"admin_plans.js_seed_confirm": "هل تريد إدخال الخطط الأربعة الافتراضية؟ هذا لن يؤثر إذا كانت الخطط موجودة بالفعل.",
|
||||
"admin_plans.js_seed_failed": "فشل الإدخال",
|
||||
"admin_plans.js_yearly_enter": "أدخل السعر السنوي لإظهار التوفير",
|
||||
"admin_plans.js_yearly_save": "توفير {pct}% مقابل الشهري",
|
||||
"admin_plans.loading": "جارٍ تحميل الخطط\n",
|
||||
"admin_plans.modal_close_aria": "إغلاق النافذة",
|
||||
"admin_plans.modal_create_title": "إضافة خطة",
|
||||
"admin_plans.modal_edit_title_prefix": "تعديل الخطة: ",
|
||||
"admin_plans.no_plans_intro": "لا توجد خطط حتى الآن. انقر",
|
||||
"admin_plans.no_plans_suffix": "لإدخال الخطط الأربعة المدمجة.",
|
||||
"admin_plans.overage_0pct": "0% (بالضبط)",
|
||||
"admin_plans.overage_100pct": "100%",
|
||||
"admin_plans.overage_50pct": "50%",
|
||||
"admin_plans.overage_announce": "\u0006f \u0016b\u00107",
|
||||
"admin_plans.overage_buffer_body_prefix": "حدود الاستخدام الزائد هو",
|
||||
"admin_plans.overage_buffer_body_suffix": "نعلن عن X مستندات/شهر ولكن نطبق فقط في",
|
||||
"admin_plans.overage_buffer_formula": "X \u0000d7 (1 + buffer%)",
|
||||
"admin_plans.overage_buffer_invisible": "غير مرئي للمستخدمين",
|
||||
"admin_plans.overage_buffer_tail": "مستندات. على سبيل المثال، خطة 150 مستند/شهر مع 20% من الحدود الزائدة تُطبق عند 180 مستند. هذا يمنع الانقطاع المفاجئ عند الحد المعلن بالضبط، مما يمنح المستخدمين هبوطًا سلسًا.",
|
||||
"admin_plans.overage_buffer_title": "حول حدود الاستخدام الزائد",
|
||||
"admin_plans.overage_docs": "مستندات،",
|
||||
"admin_plans.overage_docs_end": "مستندات",
|
||||
"admin_plans.overage_enforce_at": "تطبيق عند",
|
||||
"admin_plans.page_title": "مصمم الخطة — إدارة DocuElevate",
|
||||
"admin_plans.section_basic_info": "معلومات أساسية",
|
||||
"admin_plans.section_display": "عرض",
|
||||
"admin_plans.section_features": "قائمة الميزات",
|
||||
"admin_plans.section_overage": "مصمم الاستخدام الزائد",
|
||||
"admin_plans.section_pricing": "التسعير",
|
||||
"admin_plans.section_stripe": "دمج Stripe",
|
||||
"admin_plans.section_volume": "حدود الحجم",
|
||||
"admin_plans.status_active": "نشط",
|
||||
"admin_plans.status_inactive": "غير نشط",
|
||||
"admin_plans.stripe_desc_after": "لإنشائها تلقائيًا. الخطط المجانية لا تحتاج إلى معرفات أسعار Stripe.",
|
||||
"admin_plans.stripe_desc_before": "أدخل معرفات أسعار Stripe لهذه الخطة، أو استخدم",
|
||||
"admin_plans.stripe_wizard_aria": "فتح معالج إعداد Stripe في علامة تبويب جديدة",
|
||||
"admin_plans.stripe_wizard_link": "معالج Stripe",
|
||||
"admin_plans.stripe_wizard_text": "معالج إعداد Stripe",
|
||||
"admin_plans.subheading": "إدارة خطط الاشتراك المعروضة على صفحة التسعير العامة.",
|
||||
"admin_plans.table_aria_label": "خطط الاشتراك",
|
||||
"admin_users.add_user_profile_btn": "إضافة ملف تعريف مستخدم",
|
||||
"admin_users.admin_only_badge": "للمسؤولين فقط",
|
||||
"admin_users.btn_password": "كلمة المرور",
|
||||
"admin_users.btn_reset": "إعادة تعيين",
|
||||
"admin_users.col_display_name": "اسم العرض",
|
||||
"admin_users.col_documents": "المستندات",
|
||||
"admin_users.col_email": "البريد الإلكتروني",
|
||||
"admin_users.col_last_upload": "آخر تحميل",
|
||||
"admin_users.col_plan": "الخطة",
|
||||
"admin_users.col_role": "الدور",
|
||||
"admin_users.col_upload_limit": "حد التحميل",
|
||||
"admin_users.col_user_id": "معرف المستخدم",
|
||||
"admin_users.col_username": "اسم المستخدم",
|
||||
"admin_users.create_local_account_btn": "إنشاء حساب محلي",
|
||||
"admin_users.create_local_title": "إنشاء حساب محلي",
|
||||
"admin_users.delete_account_btn": "حذف الحساب",
|
||||
"admin_users.delete_local_confirm": "هل أنت متأكد أنك تريد حذف الحساب لـ",
|
||||
"admin_users.delete_local_title": "حذف حساب محلي",
|
||||
"admin_users.delete_local_warning": "لا يمكن التراجع عن ذلك. المستندات المملوكة من قِبل هذا المستخدم لن تُحذف.",
|
||||
"admin_users.delete_profile_btn": "حذف الملف الشخصي",
|
||||
"admin_users.delete_profile_confirm": "هل أنت متأكد أنك تريد حذف الملف الشخصي لـ",
|
||||
"admin_users.delete_profile_title": "حذف ملف تعريف المستخدم",
|
||||
"admin_users.delete_profile_warning": "هذا يزيل فقط سجل الملف الشخصي الذي يديره المسؤول. المستندات المملوكة من قِبل هذا المستخدم لن تُحذف.",
|
||||
"admin_users.deleting": "جارٍ الحذف\n",
|
||||
"admin_users.edit_local_title": "تعديل الحساب المحلي",
|
||||
"admin_users.filter_placeholder": "تصفية حسب معرف المستخدم\n",
|
||||
"admin_users.global_default": "افتراضي عالمي",
|
||||
"admin_users.heading": "إدارة المستخدمين",
|
||||
"admin_users.js_account_created": "تم إنشاء الحساب",
|
||||
"admin_users.js_account_created_msg": "تم إنشاء حساب محلي لـ \"{username}\" بنجاح.",
|
||||
"admin_users.js_account_deleted_msg": "تم حذف الحساب لـ \"{username}\".",
|
||||
"admin_users.js_account_updated": "تم تحديث الحساب.",
|
||||
"admin_users.js_delete_failed": "فشل الحذف",
|
||||
"admin_users.js_deleted": "تم الحذف",
|
||||
"admin_users.js_email_not_sent": "لم يتم إرسال البريد الإلكتروني",
|
||||
"admin_users.js_email_sent": "تم إرسال البريد الإلكتروني",
|
||||
"admin_users.js_email_sent_msg": "تم إرسال بريد إعادة تعيين كلمة المرور إلى \"{email}\".",
|
||||
"admin_users.js_failed": "فشل",
|
||||
"admin_users.js_failed_create": "فشل في إنشاء الحساب.",
|
||||
"admin_users.js_failed_load_local": "فشل في تحميل المستخدمين المحليين",
|
||||
"admin_users.js_failed_load_users": "فشل في تحميل المستخدمين",
|
||||
"admin_users.js_failed_set_password": "فشل في تعيين كلمة المرور.",
|
||||
"admin_users.js_failed_update": "فشل في تحديث الحساب.",
|
||||
"admin_users.js_network_error": "خطأ في الشبكة",
|
||||
"admin_users.js_password_set": "تم تعيين كلمة المرور",
|
||||
"admin_users.js_password_set_msg": "تم تحديث كلمة المرور لـ \"{username}\".",
|
||||
"admin_users.js_profile_deleted": "تمت إزالة الملف الشخصي لـ \"{id}\".",
|
||||
"admin_users.js_profile_saved": "تم حفظ الملف الشخصي لـ \"{id}\".",
|
||||
"admin_users.js_save_failed": "فشل في الحفظ",
|
||||
"admin_users.js_saved": "تم الحفظ",
|
||||
"admin_users.js_smtp_not_configured": "لم يتم تكوين SMTP.",
|
||||
"admin_users.js_updated": "تم التحديث",
|
||||
"admin_users.loading_users": "جارٍ تحميل المستخدمين\u00002026",
|
||||
"admin_users.local_account_active": "الحساب نشط",
|
||||
"admin_users.local_accounts_heading": "حسابات المستخدمين المحلية",
|
||||
"admin_users.local_accounts_subheading": "حسابات البريد الإلكتروني/كلمة المرور تم إنشاؤها مباشرة على هذا الخادم.",
|
||||
"admin_users.local_admin_privileges": "منح امتيازات المدير",
|
||||
"admin_users.local_admin_privileges_short": "امتيازات المدير",
|
||||
"admin_users.local_create_btn": "إنشاء حساب",
|
||||
"admin_users.local_create_one": "إنشاء واحد.",
|
||||
"admin_users.local_creating": "جارٍ الإنشاء\u00002026",
|
||||
"admin_users.local_display_name_optional": "(اختياري)",
|
||||
"admin_users.local_loading": "جارٍ التحميل\u00002026",
|
||||
"admin_users.local_no_accounts": "لا توجد حسابات محلية بعد.",
|
||||
"admin_users.local_password_hint": "8 أحرف كحد أدنى.",
|
||||
"admin_users.local_saving": "جارٍ الحفظ\u00002026",
|
||||
"admin_users.local_username_hint": "3\u0001364 حرف. أحرف، أرقام، شرطات سفلية وشرطات فقط.",
|
||||
"admin_users.modal_add_title": "إضافة ملف تعريف مستخدم",
|
||||
"admin_users.modal_billing_cycle_label": "دورة الفاتورة",
|
||||
"admin_users.modal_billing_monthly": "شهري",
|
||||
"admin_users.modal_billing_yearly": "سنوي",
|
||||
"admin_users.modal_block_hint": "(يمنع تحميل مستندات جديدة)",
|
||||
"admin_users.modal_block_label": "حظر هذا المستخدم",
|
||||
"admin_users.modal_close_aria": "إغلاق الحوار",
|
||||
"admin_users.modal_complimentary_hint": "(المستخدم يحتفظ بمزايا المستوى ولكن لا يتم فوترة - يتم تعيينها تلقائيًا لحسابات المدير)",
|
||||
"admin_users.modal_complimentary_label": "خطة مجانية",
|
||||
"admin_users.modal_daily_limit_hint": "(اتركه فارغًا لاستخدام القيمة الافتراضية العالمية)",
|
||||
"admin_users.modal_daily_limit_label": "حد التحميل اليومي",
|
||||
"admin_users.modal_daily_limit_placeholder": "على سبيل المثال 50 (0 = غير محدود)",
|
||||
"admin_users.modal_display_name_label": "اسم العرض",
|
||||
"admin_users.modal_display_name_placeholder": "أليس سميث (اختياري)",
|
||||
"admin_users.modal_edit_title": "تعديل ملف المستخدم",
|
||||
"admin_users.modal_notes_label": "ملاحظات الإدارة",
|
||||
"admin_users.modal_notes_placeholder": "ملاحظات داخلية مرئية فقط للمسؤولين\n\n",
|
||||
"admin_users.modal_period_start_hint": "يتم حساب فترة الحمل السنوي من هذا التاريخ. اتركه فارغًا للتنفيذ الشهري.",
|
||||
"admin_users.modal_period_start_label": "بداية فترة الاشتراك",
|
||||
"admin_users.modal_plan_business": "تجاري — $7.99/شهر (300/شهر، صناديق بريد غير محدودة)",
|
||||
"admin_users.modal_plan_free": "مجاني — 25 ملف مدى الحياة",
|
||||
"admin_users.modal_plan_hint": "يحدد حدود الحصة لهذا المستخدم. يتم تنفيذ الحدود عند التحميل.",
|
||||
"admin_users.modal_plan_label": "خطة الاشتراك",
|
||||
"admin_users.modal_plan_professional": "مهني — $5.99/شهر (150/شهر، 3 صناديق بريد)",
|
||||
"admin_users.modal_plan_starter": "أساسي — $2.99/شهر (50/شهر، 1 صندوق بريد)",
|
||||
"admin_users.modal_save_changes": "حفظ التغييرات",
|
||||
"admin_users.modal_saving": "جاري الحفظ\n\n",
|
||||
"admin_users.modal_user_id_hint": "المعرف الثابت الذي يتطابق مع owner_id في المستندات.",
|
||||
"admin_users.modal_user_id_label": "معرف المستخدم",
|
||||
"admin_users.modal_user_id_placeholder": "user@example.com أو OAuth sub",
|
||||
"admin_users.new_account_btn": "حساب جديد",
|
||||
"admin_users.new_password_label": "كلمة المرور الجديدة",
|
||||
"admin_users.no_users_add_hint": "قم بتحميل بعض المستندات أو أضف ملفًا شخصيًا أعلاه.",
|
||||
"admin_users.no_users_found": "لم يتم العثور على مستخدمين.",
|
||||
"admin_users.no_users_search_hint": "جرب مصطلح بحث مختلف.",
|
||||
"admin_users.page_title": "إدارة المستخدمين – المسؤول – DocuElevate",
|
||||
"admin_users.pagination_page_of": "من",
|
||||
"admin_users.per_day": "/ يوم",
|
||||
"admin_users.role_admin": "مسؤول",
|
||||
"admin_users.role_user": "مستخدم",
|
||||
"admin_users.search_users_label": "ابحث عن المستخدمين",
|
||||
"admin_users.set_password_btn": "تعيين كلمة المرور",
|
||||
"admin_users.set_password_desc": "ينبغي على المستخدم تغيير هذه الكلمة المرور بعد تسجيل الدخول.",
|
||||
"admin_users.set_password_desc_pre": "قم بتعيين كلمة مرور جديدة مباشرة لـ",
|
||||
"admin_users.set_password_title": "تعيين كلمة مرور مؤقتة",
|
||||
"admin_users.setting": "الإعداد\n\n",
|
||||
"admin_users.status_blocked": "محظور",
|
||||
"admin_users.status_unverified": "غير موثق",
|
||||
"admin_users.subheading": "إدارة ملفات تعريف المستخدمين، وحدود التحميل لكل مستخدم، وملكية المستندات.",
|
||||
"admin_users.total_count_users": "{count} مستخدمين",
|
||||
"admin_users.total_no_users": "لا مستخدمين",
|
||||
"admin_users.total_one_user": "1 مستخدم",
|
||||
"api_tokens.col_created": "تم الإنشاء",
|
||||
"api_tokens.col_last_ip": "آخر IP",
|
||||
"api_tokens.col_last_used": "آخر استخدام",
|
||||
@@ -73,6 +347,19 @@
|
||||
"api_tokens.usage_intro_pre": "استخدم رمز API الخاص بك في",
|
||||
"api_tokens.your_tokens": "رموزك",
|
||||
"app.name": "DocuElevate",
|
||||
"attribution.heading": "نسب البرمجيات من جهات خارجية",
|
||||
"attribution.intro": "تستخدم DocuElevate عدة مكتبات وأدوات مفتوحة المصدر. نحن ممتنون لمطوري هذه المشاريع على مساهماتهم في البرمجيات مفتوحة المصدر.",
|
||||
"attribution.page_title": "DocuElevate - نسب الجهات الخارجية",
|
||||
"attribution.paramiko_lgpl_note": "ملاحظة: هذه المكتبة مرخصة بموجب رخصة GNU العامة الأقل (LGPL-2.1)",
|
||||
"attribution.section_docker": "صور دوكر",
|
||||
"attribution.section_frontend": "اعتماديات الواجهة الأمامية",
|
||||
"attribution.section_python": "اعتماديات بايثون",
|
||||
"attribution.special_lgpl_link": "هنا",
|
||||
"attribution.special_lgpl_post": ".",
|
||||
"attribution.special_lgpl_pre": "يمكن العثور على نسخة من رخصة LGPL",
|
||||
"attribution.special_source_post": ".",
|
||||
"attribution.special_source_pre": "تتضمن هذه البرمجيات Paramiko، المرخصة بموجب LGPL. الشيفرة المصدرية لـ Paramiko متاحة على",
|
||||
"attribution.special_title": "نسبة خاصة:",
|
||||
"audit.col_ip": "IP",
|
||||
"audit.col_resource": "الموارد",
|
||||
"audit.col_timestamp": "الطابع الزمني",
|
||||
@@ -252,6 +539,41 @@
|
||||
"cookie.notice_label": "إشعار ملفات تعريف الارتباط",
|
||||
"cookie.policy_link": "سياسة ملفات تعريف الارتباط",
|
||||
"cookie.privacy_link": "إشعار الخصوصية",
|
||||
"cookie_policy.heading": "سياسة الكوكيز",
|
||||
"cookie_policy.last_updated": "تم التحديث آخر مرة:",
|
||||
"cookie_policy.page_title": "سياسة الكوكيز - DocuElevate",
|
||||
"cookie_policy.s1_heading": "ما هي الكوكيز",
|
||||
"cookie_policy.s1_p1": "الكوكيز هي ملفات نصية صغيرة يتم تخزينها على جهاز الكمبيوتر أو الجهاز المحمول الخاص بك عندما تزور موقعًا إلكترونيًا. يتم استخدامها على نطاق واسع لجعل مواقع الويب تعمل بكفاءة أكبر وتقديم المعلومات لمالكي المواقع.",
|
||||
"cookie_policy.s2_heading": "كيف نستخدم الكوكيز",
|
||||
"cookie_policy.s2_li1_body": "لتحديد هويتك عندما تسجل الدخول والحفاظ على جلستك أثناء استخدام التطبيق.",
|
||||
"cookie_policy.s2_li1_label": "إدارة الهوية والجلسة:",
|
||||
"cookie_policy.s2_p1_post": "للغرض التالي:",
|
||||
"cookie_policy.s2_p1_pre": "تستخدم DocuElevate",
|
||||
"cookie_policy.s2_p1_strong": "فقط الكوكيز الضرورية بشكل صارم للجلسة",
|
||||
"cookie_policy.s2_p2": "هذه الكوكيز ضرورية لتشغيل خدمتنا بشكل صحيح. بدون هذه الكوكيز، سيتعين عليك تسجيل الدخول بشكل متكرر أثناء جلسة تصفحك.",
|
||||
"cookie_policy.s2_p3": "نظرًا لأن هذه الكوكيز ضرورية تمامًا لوظيفة الخدمة، فإنها معفاة من متطلبات الموافقة المسبقة بموجب توجيه الخصوصية الإلكترونية للاتحاد الأوروبي (المادة 5(3)) والتنفيذات الوطنية المعادلة. نحن لا نستخدم أي كوكيز اختيارية أو تحليلات أو إعلانات أو تتبع.",
|
||||
"cookie_policy.s3_col_duration": "المدة",
|
||||
"cookie_policy.s3_col_name": "الاسم",
|
||||
"cookie_policy.s3_col_purpose": "الغرض",
|
||||
"cookie_policy.s3_col_type": "النوع",
|
||||
"cookie_policy.s3_heading": "تفاصيل الكوكيز",
|
||||
"cookie_policy.s3_row1_duration": "جلسة (تحذف عند إغلاق المتصفح أو تسجيل الخروج)",
|
||||
"cookie_policy.s3_row1_purpose": "تحافظ على جلستك الموثقة؛ مطلوبة لوظيفة تسجيل الدخول.",
|
||||
"cookie_policy.s3_row1_type": "ضرورية بشكل صارم",
|
||||
"cookie_policy.s3_row2_duration": "دائمة (تخزين محلي في المتصفح)",
|
||||
"cookie_policy.s3_row2_purpose": "تخزن تأكيدك على إشعار الكوكيز حتى لا يتم عرضه بشكل متكرر (مخزنة في التخزين المحلي، ليست كوكيز).",
|
||||
"cookie_policy.s3_row2_type": "ضرورية بشكل صارم",
|
||||
"cookie_policy.s4_heading": "لا توجد كوكيز من طرف ثالث",
|
||||
"cookie_policy.s4_p1": "لا تستخدم DocuElevate أي كوكيز من طرف ثالث، أو كوكيز تتبع، أو كوكيز إعلانات، أو كوكيز تحليلات. نحن نحترم خصوصيتك وننفذ فقط الحد الأدنى من الكوكيز المطلوبة لعمل خدمتنا.",
|
||||
"cookie_policy.s4_p2_pre": "لمزيد من المعلومات حول كيفية تعاملنا مع بياناتك، يرجى الاطلاع على",
|
||||
"cookie_policy.s4_privacy_link": "إشعار الخصوصية",
|
||||
"cookie_policy.s5_heading": "إدارة الكوكيز",
|
||||
"cookie_policy.s5_p1": "تسمح معظم متصفحات الويب لك بالتحكم في الكوكيز من خلال إعداداتها. ومع ذلك، فإن حظر أو حذف كوكيز الجلسة الخاصة بنا سيمنع DocuElevate من العمل، حيث يعتمد مصادقة المستخدم على هذه الكوكيز.",
|
||||
"cookie_policy.s5_p2": "يمكنك أيضًا مسح تأكيد إشعار الكوكيز المخزنة في التخزين المحلي لمتصفحك في أي وقت من خلال أدوات المطور في متصفحك (التطبيق → التخزين المحلي).",
|
||||
"cookie_policy.s5_p3_and": "و",
|
||||
"cookie_policy.s5_p3_pre": "تعد سياسة الكوكيز هذه جزءًا من وتم تضمينها في",
|
||||
"cookie_policy.s5_privacy_link": "إشعار الخصوصية",
|
||||
"cookie_policy.s5_terms_link": "شروط الخدمة",
|
||||
"credentials.col_action": "الإجراء",
|
||||
"credentials.col_credential": "بيانات الاعتماد",
|
||||
"credentials.col_source": "المصدر",
|
||||
@@ -517,6 +839,31 @@
|
||||
"help.workflows_step_5_create": "احفظ – سيتم معالجة المستندات الجديدة من خلال هذا الخط تلقائيًا.",
|
||||
"help.workflows_typical_steps": "الخطوات النموذجية",
|
||||
"help.workflows_what_is": "ما هو خط الأنابيب؟",
|
||||
"imprint.business_registration_heading": "تسجيل الأعمال",
|
||||
"imprint.business_registration_vat": "رقم تعريف ضريبة القيمة المضافة وفقًا لقانون ضريبة القيمة المضافة 27 أ:",
|
||||
"imprint.contact_heading": "معلومات الاتصال",
|
||||
"imprint.dispute_heading": "حل النزاعات عبر الإنترنت",
|
||||
"imprint.dispute_p1": "توفر المفوضية الأوروبية منصة لحل النزاعات عبر الإنترنت (OS):",
|
||||
"imprint.dispute_p2": "نحن غير مستعدين أو ملزمين بالمشاركة في إجراءات حل المنازعات أمام هيئة تحكيم المستهلك.",
|
||||
"imprint.heading": "البصمة",
|
||||
"imprint.legal_copyright": "جميع المحتويات على هذا الموقع محمية بموجب حقوق الطبع والنشر. يتطلب أي استخدام خارج حدود حقوق الطبع والنشر موافقة كتابية من المؤلف أو المنشئ المعني.",
|
||||
"imprint.legal_heading": "الإشعارات القانونية",
|
||||
"imprint.legal_liability": "على الرغم من التحكم الدقيق في المحتوى، فإننا لا نتحمل أي مسؤولية عن محتوى الروابط الخارجية. يتحمل مشغلو الصفحات المرتبطة مسؤولية كاملة عن محتواها.",
|
||||
"imprint.page_title": "البصمة - DocuElevate",
|
||||
"imprint.policies_cookie_desc": "معلومات حول ملفات تعريف الارتباط التي نستخدمها",
|
||||
"imprint.policies_cookie_label": "سياسة ملفات تعريف الارتباط",
|
||||
"imprint.policies_heading": "السياسات ذات الصلة",
|
||||
"imprint.policies_intro": "خدماتنا تخضع للسياسات التالية:",
|
||||
"imprint.policies_license_desc": "كيف يتم ترخيص برنامجنا",
|
||||
"imprint.policies_license_label": "معلومات الترخيص",
|
||||
"imprint.policies_privacy_desc": "كيف نتعامل مع بياناتك",
|
||||
"imprint.policies_privacy_label": "سياسة الخصوصية",
|
||||
"imprint.policies_terms_desc": "القواعد لاستخدام DocuElevate",
|
||||
"imprint.policies_terms_label": "شروط الخدمة",
|
||||
"imprint.provider_heading": "مزود الخدمة",
|
||||
"imprint.responsible_content_heading": "مسؤول عن المحتوى",
|
||||
"imprint.responsible_content_rstv": "وفقًا للمادة 55 الفقرة 2 من RStV:",
|
||||
"imprint.subtitle": "معلومات وفقًا للمادة 5 TMG (القانون الألماني للوسائط الإلكترونية)",
|
||||
"index.badge_intelligent": "معالجة المستندات الذكية",
|
||||
"index.button_browse_files": "تصفح الملفات",
|
||||
"index.button_upload": "رفع",
|
||||
@@ -728,6 +1075,19 @@
|
||||
"language.tr": "التركية",
|
||||
"language.uk": "الأوكرانية",
|
||||
"language.zh": "الصينية",
|
||||
"license.apache_description": "يتم توزيع DocuElevate بموجب رخصة Apache 2.0، وهي رخصة برمجيات مفتوحة المصدر مسموح بها تتيح لك استخدام وتعديل وتوزيع والمساهمة في المشروع.",
|
||||
"license.apache_heading": "رخصة Apache 2.0",
|
||||
"license.heading": "معلومات الترخيص",
|
||||
"license.page_title": "معلومات الترخيص - DocuElevate",
|
||||
"license.related_about_link": "صفحة حول",
|
||||
"license.related_and": "و",
|
||||
"license.related_heading": "معلومات ذات صلة",
|
||||
"license.related_p1_post": "لمزيد من المعلومات حول استخدام خدمة DocuElevate.",
|
||||
"license.related_p1_pre": "بينما تنظم هذه الرخصة استخدام برنامجنا، يرجى مراجعة",
|
||||
"license.related_p2_post": ".",
|
||||
"license.related_p2_pre": "للحصول على مزيد من المعلومات حول DocuElevate، يرجى زيارة",
|
||||
"license.related_privacy_link": "سياسة الخصوصية",
|
||||
"license.related_terms_link": "شروط الخدمة",
|
||||
"nav.about": "حول",
|
||||
"nav.account_menu": "قائمة الحساب",
|
||||
"nav.account_menu_for": "قائمة الحساب لـ {name}",
|
||||
@@ -828,6 +1188,185 @@
|
||||
"pipelines.system_label": "نظام",
|
||||
"pipelines.system_pipeline_label": "خط أنابيب النظام (مرئي لجميع المستخدمين)",
|
||||
"pipelines.title": "خطوط أنابيب المعالجة",
|
||||
"privacy.heading": "DocuElevate – إشعار الخصوصية",
|
||||
"privacy.last_updated": "آخر تحديث:",
|
||||
"privacy.page_title": "إشعار الخصوصية - DocuElevate",
|
||||
"privacy.s10_access_body": "يمكنك طلب نسخة من البيانات الشخصية التي نحتفظ بها عنك.",
|
||||
"privacy.s10_access_label": "حق الوصول (المادة 15):",
|
||||
"privacy.s10_complaint_body": "لديك الحق في تقديم شكوى إلى هيئة حماية البيانات الوطنية الخاصة بك. في ألمانيا: المفوض الفيدرالي لحماية البيانات وحرية المعلومات (BfDI). في المملكة المتحدة: مكتب مفوض المعلومات (ICO). في سويسرا: المفوض الفيدرالي لحماية البيانات والمعلومات (FDPIC).",
|
||||
"privacy.s10_complaint_label": "حق تقديم شكوى:",
|
||||
"privacy.s10_contact": "للتمتع بأي من الحقوق المذكورة أعلاه، اتصل بنا على",
|
||||
"privacy.s10_erasure_body": "يمكنك طلب حذف بياناتك الشخصية حيث لا يوجد سبب مشروع يتطلب منا الاحتفاظ بها.",
|
||||
"privacy.s10_erasure_label": "حق الحذف (المادة 17):",
|
||||
"privacy.s10_heading": "10. حقوقك (الاتحاد الأوروبي / المنطقة الاقتصادية الأوروبية / المملكة المتحدة / سويسرا)",
|
||||
"privacy.s10_object_body": "يمكنك الاعتراض على المعالجة بناءً على المصالح المشروعة في أي وقت.",
|
||||
"privacy.s10_object_label": "حق الاعتراض (المادة 21):",
|
||||
"privacy.s10_p1": "بموجب اللائحة العامة لحماية البيانات (GDPR) (وقانون حماية البيانات في المملكة المتحدة / معايير حماية البيانات في سويسرا)، لديك الحقوق التالية:",
|
||||
"privacy.s10_portability_body": "يمكنك طلب بياناتك بتنسيق منظم، يستخدم عادة، وقابل للقراءة الآلية.",
|
||||
"privacy.s10_portability_label": "حق نقل البيانات (المادة 20):",
|
||||
"privacy.s10_rectification_body": "يمكنك طلب تصحيح البيانات الشخصية غير الدقيقة أو غير المكتملة.",
|
||||
"privacy.s10_rectification_label": "حق التصحيح (المادة 16):",
|
||||
"privacy.s10_response": "سوف نرد خلال شهر تقويمي واحد (قابل للتمديد شهرين إضافيين للطلبات المعقدة).",
|
||||
"privacy.s10_restriction_body": "يمكنك طلب أن نوقف معالجة بياناتك مؤقتًا في ظروف معينة.",
|
||||
"privacy.s10_restriction_label": "حق التقييد (المادة 18):",
|
||||
"privacy.s10_withdraw_body": "عندما تكون المعالجة قائمة على الموافقة، يمكنك سحب تلك الموافقة في أي وقت دون التأثير على قانونية المعالجة السابقة.",
|
||||
"privacy.s10_withdraw_label": "حق سحب الموافقة:",
|
||||
"privacy.s11_categories_body": "المعرفات (الاسم، البريد الإلكتروني)، رموز مصادقة الحساب، وبيانات وصف المستندات التي تختار تحميلها.",
|
||||
"privacy.s11_categories_label": "فئات المعلومات الشخصية التي تم جمعها:",
|
||||
"privacy.s11_contact": "لتقديم طلب مستهلك يمكن التحقق منه، اتصل بنا على",
|
||||
"privacy.s11_correct_body": "يمكنك طلب تصحيح المعلومات الشخصية غير الدقيقة.",
|
||||
"privacy.s11_correct_label": "حق التصحيح:",
|
||||
"privacy.s11_delete_body": "يمكنك طلب حذف المعلومات الشخصية التي جمعناها، مع وجود استثناءات معينة.",
|
||||
"privacy.s11_delete_label": "حق الحذف:",
|
||||
"privacy.s11_heading": "11. حقوق إضافية - الولايات المتحدة (CCPA / CPRA)",
|
||||
"privacy.s11_know_body": "يمكنك طلب الكشف عن الفئات والمعلومات الشخصية المحددة التي جمعناها عنك.",
|
||||
"privacy.s11_know_label": "حق المعرفة:",
|
||||
"privacy.s11_limit_body": "نحن لا نستخدم المعلومات الشخصية الحساسة بما يتجاوز ما هو ضروري لتوفير الخدمة.",
|
||||
"privacy.s11_limit_label": "حق تقييد استخدام المعلومات الشخصية الحساسة:",
|
||||
"privacy.s11_nondiscrim_body": "لن نميز ضدك بسبب ممارسة أي من هذه الحقوق.",
|
||||
"privacy.s11_nondiscrim_label": "عدم التمييز:",
|
||||
"privacy.s11_optout_body": "نحن لا نبيع أو نتشارك المعلومات الشخصية كما هو محدد بموجب CCPA/CPRA. لا يتم required آلية الانسحاب؛ ومع ذلك، يمكنك الاتصال بنا لتأكيد ذلك.",
|
||||
"privacy.s11_optout_label": "حق الانسحاب من البيع / المشاركة:",
|
||||
"privacy.s11_p1": "إذا كنت مقيمًا في كاليفورنيا أو ولاية أمريكية أخرى ذات تشريعات خصوصية قابلة للتطبيق (بما في ذلك قانون حماية البيانات في فرجينيا VCDPA، CPA كولورادو، CTDPA كونيتيكت، UCPA يوتا)، فإن الإفصاحات الإضافية التالية تنطبق:",
|
||||
"privacy.s11_purpose_body": "تقديم وتحسين وتأمين خدمة DocuElevate. نحن لا نبيع أو نتشارك المعلومات الشخصية للإعلانات السلوكية عبر السياقات.",
|
||||
"privacy.s11_purpose_label": "غرض الجمع:",
|
||||
"privacy.s11_response": "سوف نرد خلال 45 يومًا (قابل للتمديد 45 يومًا إضافيًا عند الحاجة بشكل معقول).",
|
||||
"privacy.s12_access_body": "يمكنك طلب الوصول إلى معلوماتك الشخصية والمعلومات حول كيفية استخدامها أو إفشائها.",
|
||||
"privacy.s12_access_label": "حق الوصول:",
|
||||
"privacy.s12_contact": "قم بتوجيه الشكاوى المتعلقة بالخصوصية مباشرة إلى مسؤول الخصوصية لدينا على",
|
||||
"privacy.s12_contact_post": "، أو إلى مكتب مفوض الخصوصية في كندا.",
|
||||
"privacy.s12_correction_body": "يمكنك تحدي دقة أو اكتمال معلوماتك الشخصية وطلب التصحيح.",
|
||||
"privacy.s12_correction_label": "حق التصحيح:",
|
||||
"privacy.s12_heading": "12. حقوق إضافية - كندا (قانون حماية المعلومات الشخصية والوثائق الإلكترونية / قانون كيبك 25)",
|
||||
"privacy.s12_li1": "نقوم بجمع واستخدام وإفشاء المعلومات الشخصية فقط بمعرفتك وموافقتك، أو كما يسمح به القانون.",
|
||||
"privacy.s12_p1": "إذا كنت موجودًا في كندا، فإن ما يلي ينطبق بموجب قانون حماية المعلومات الشخصية والوثائق الإلكترونية (PIPEDA) والتشريعات الإقليمية المعمول بها (بما في ذلك قانون كيبك 25 / مشروع قانون 64):",
|
||||
"privacy.s12_quebec_body": "بموجب القانون 25، لديك حقوق إضافية تشمل حق نقل البيانات (ساري اعتبارًا من سبتمبر 2023) وحق الإلغاء حيث يتم نشر المعلومات الشخصية عبر الإنترنت.",
|
||||
"privacy.s12_quebec_label": "سكان كيبك:",
|
||||
"privacy.s12_withdraw_body": "مع مراعاة القيود القانونية أو التعاقدية، يمكنك سحب موافقتك على جمع أو استخدام أو إفشاء معلوماتك الشخصية بإشعار معقول.",
|
||||
"privacy.s12_withdraw_label": "حق سحب الموافقة:",
|
||||
"privacy.s13_brazil_body": "إذا كنت موجودًا في البرازيل، فلد لديك الحقوق التالية بموجب LGPD:",
|
||||
"privacy.s13_brazil_label": "البرازيل (LGPD - قانون حماية البيانات العامة، القانون 13.709/2018):",
|
||||
"privacy.s13_contact": "تواصل:",
|
||||
"privacy.s13_heading": "13. حقوق إضافية - أمريكا اللاتينية (LGPD وأخرى)",
|
||||
"privacy.s13_li1": "تأكيد وجود المعالجة والوصول إلى بياناتك.",
|
||||
"privacy.s13_li2": "تصحيح البيانات غير المكتملة أو غير الدقيقة أو القديمة.",
|
||||
"privacy.s13_li3": "إخفاء الهوية، أو الحظر، أو حذف البيانات غير الضرورية أو المفرطة.",
|
||||
"privacy.s13_li4": "نقل بياناتك إلى مزود خدمة أو منتج آخر.",
|
||||
"privacy.s13_li5": "حذف البيانات الشخصية المعالجة بموافقتك.",
|
||||
"privacy.s13_li6": "معلومات حول الكيانات التي تمت مشاركة بياناتك معها.",
|
||||
"privacy.s13_li7": "معلومات حول إمكانية عدم الموافقة ونتائج الرفض.",
|
||||
"privacy.s13_li8": "إلغاء الموافقة.",
|
||||
"privacy.s13_other_body": "نحن نُقر أيضًا بقوانين الخصوصية المعمول بها في الأرجنتين (PDPA) والمكسيك (LFPDPPP) وتشيلي وكولومبيا (القانون 1581) وغيرها. يمكن للمستخدمين في هذه الولايات القضائية ممارسة حقوق معادلة كما هو موضح بموجب قانونهم الوطني من خلال الاتصال بنا.",
|
||||
"privacy.s13_other_label": "دول أمريكا اللاتينية الأخرى:",
|
||||
"privacy.s14_apj_body": "نحن نُقر بحقوق حماية البيانات الممنوحة لسكان هذه الولايات القضائية بموجب قوانينهم الوطنية. اتصل بنا لممارسة حقوقك.",
|
||||
"privacy.s14_apj_label": "أسواق APJ الأخرى (قانون خصوصية سنغافورة، قانون الخصوصية في نيوزيلندا، قانون DPDP في الهند):",
|
||||
"privacy.s14_australia_body": "يمكن لسكان أستراليا طلب الوصول إلى معلوماتهم الشخصية وتصحيحها. سنرد على طلبات الوصول في غضون 30 يومًا. يمكن تقديم الشكاوى إلى مكتب مفوض المعلومات الأسترالي (OAIC).",
|
||||
"privacy.s14_australia_label": "أستراليا (قانون الخصوصية 1988 ومبادئ الخصوصية الأسترالية):",
|
||||
"privacy.s14_contact": "تواصل:",
|
||||
"privacy.s14_heading": "14. حقوق إضافية - منطقة آسيا والمحيط الهادئ واليابان",
|
||||
"privacy.s14_japan_body": "يمكن لسكان اليابان طلب الإفصاح أو التصحيح أو الإضافة أو الحذف أو تعليق الاستخدام أو محو أو تعليق تقديم معلوماتهم الشخصية التي نحتفظ بها. تتطلب الإفصاحات من طرف ثالث موافقتك المسبقة ما لم يسمح بها القانون.",
|
||||
"privacy.s14_japan_label": "اليابان (APPI - قانون حماية المعلومات الشخصية):",
|
||||
"privacy.s14_korea_body": "يمكن لسكان كوريا الجنوبية طلب الوصول أو التصحيح أو الحذف أو تعليق المعالجة. نحن نتعامل مع المعلومات الشخصية لسكان كوريا الجنوبية وفقًا لقانون PIPA.",
|
||||
"privacy.s14_korea_label": "كوريا الجنوبية (PIPA - قانون حماية المعلومات الشخصية):",
|
||||
"privacy.s15_contact": "تواصل:",
|
||||
"privacy.s15_heading": "15. حقوق إضافية - أوكرانيا",
|
||||
"privacy.s15_p1": "يحمي القانون الأوكراني \"بشأن حماية البيانات الشخصية\" (رقم 2297-VI) المستخدمين الموجودين في أوكرانيا. تشمل حقوقك الوصول إلى، وتصحيح، وحظر، وحذف بياناتك الشخصية، بالإضافة إلى الحق في الاعتراض على المعالجة.",
|
||||
"privacy.s16_cookies_link": "سياسة الكوكيز",
|
||||
"privacy.s16_heading": "16. تحديثات على هذه الإشعار الخصوصية",
|
||||
"privacy.s16_license_link": "معلومات الترخيص",
|
||||
"privacy.s16_p1": "قد نقوم بتحديث هذا الإشعار من وقت لآخر لتعكس التغييرات في ممارساتنا أو القوانين المعمول بها. تاريخ \"آخر تحديث\" في أعلى هذه الصفحة يشير إلى آخر مرة تم فيها مراجعة الإشعار. عندما تكون التغييرات جوهرية، سنقوم بإخطار المستخدمين عبر إشعار داخل التطبيق أو عبر البريد الإلكتروني حيثما كان ذلك مناسبًا.",
|
||||
"privacy.s16_p2_pre": "إذا كانت لديك أي أسئلة أو مخاوف بشأن إشعار الخصوصية هذا أو بياناتك الشخصية، يرجى الاتصال بنا على",
|
||||
"privacy.s16_p3_pre": "يرجى أيضًا مراجعة",
|
||||
"privacy.s16_terms_link": "شروط الخدمة",
|
||||
"privacy.s1_address": "Alter Steinweg 3, 20459 هامبورغ، ألمانيا",
|
||||
"privacy.s1_company": "Christian Louis IT Beratung",
|
||||
"privacy.s1_contact_label": "البريد الإلكتروني للتواصل:",
|
||||
"privacy.s1_heading": "1. مراقب البيانات",
|
||||
"privacy.s1_p1": "المراقب المسؤول عن معالجة بياناتك الشخصية بموجب اللائحة العامة لحماية البيانات بالاتحاد الأوروبي (GDPR) والقوانين المماثلة لحماية الخصوصية في جميع أنحاء العالم هو:",
|
||||
"privacy.s1_p3": "لجميع الطلبات المتعلقة بالخصوصية (الوصول، الحذف، التصحيح، الانسحاب، أو الشكاوى)، يرجى الاتصال بنا على عنوان البريد الإلكتروني أعلاه. سنقوم بالرد خلال 30 يومًا (أو الفترة المحددة بموجب القانون المعمول به).",
|
||||
"privacy.s2_heading": "2. نطاق إشعار الخصوصية هذا",
|
||||
"privacy.s2_p1_pre": "ينطبق هذا الإشعار على تطبيق DocuElevate على الويب، المستضاف في",
|
||||
"privacy.s2_p2": "يغطي جميع المستخدمين على مستوى العالم، بما في ذلك أولئك الموجودين في الاتحاد الأوروبي (EU)، المنطقة الاقتصادية الأوروبية (EEA)، ألمانيا، المملكة المتحدة (UK)، سويسرا، أوكرانيا، الولايات المتحدة (US)، كندا، أمريكا اللاتينية (Latam)، منطقة آسيا والمحيط الهادئ، واليابان. يتم تقديم الإفصاحات الخاصة بالسوق في أقسام مخصصة أدناه.",
|
||||
"privacy.s3_audit_body": "نحتفظ بسجلات تدقيق محدودة (نوع الإجراء، التاريخ والوقت، معرف المستخدم) لضمان سلامة الخدمة وأمانها. لا تتضمن هذه السجلات محتوى الوثائق.",
|
||||
"privacy.s3_audit_label": "سجلات التدقيق:",
|
||||
"privacy.s3_auth_body": "نستخدم OAuth 2.0 (Google، Dropbox، Microsoft/OneDrive) والمصادقة المحلية الاختيارية. من خلال OAuth، قد نتلقى اسمك، عنوان بريدك الإلكتروني، وصورة ملفك الشخصي.",
|
||||
"privacy.s3_auth_label": "المصادقة على المستخدم:",
|
||||
"privacy.s3_doc_body": "الوثائق التي ترفعها تتم معالجتها لأغراض OCR (التعرف الضوئي على الحروف)، استخراج البيانات الوصفية، والتخزين لدى مزود السحابة الذي تختاره. يتم معالجة محتوى الوثيقة فقط للغرض الذي ت Initiate، ولا يتم تخزينه لأكثر مما هو ضروري تشغيليًا.",
|
||||
"privacy.s3_doc_label": "معالجة الوثائق:",
|
||||
"privacy.s3_heading": "3. جمع البيانات والأغراض",
|
||||
"privacy.s3_legal_body": "أسسنا القانونية الأساسية للمعالجة هي:",
|
||||
"privacy.s3_legal_label": "الأساس القانوني (GDPR المادة 6):",
|
||||
"privacy.s3_li1": "(1)(ب) تنفيذ العقد: لتقديم خدمة DocuElevate التي طلبتها.",
|
||||
"privacy.s3_li2": "(1)(ج) الالتزام القانوني: للامتثال للقوانين واللوائح المعمول بها.",
|
||||
"privacy.s3_li3": "(1)(و) المصالح المشروعة: لضمان أمان الخدمة ومنع الاحتيال.",
|
||||
"privacy.s4_heading": "4. الحد من البيانات وقيود الغرض",
|
||||
"privacy.s4_li1": "نجمع فقط الحد الأدنى من البيانات الشخصية المطلوبة لتشغيل الخدمة.",
|
||||
"privacy.s4_li2": "يتم معالجة محتوى الوثيقة بشكل صارم للغرض الذي ت Initiate (OCR، التخزين، استخراج البيانات الوصفية). لا نستخدم وثائقك لتدريب نماذج الذكاء الاصطناعي أو لأي غرض ثانوي.",
|
||||
"privacy.s4_li3": "لا يتم إجراء أي إعلانات، تتبع سلوكي، أو تكوين ملفات تعريف.",
|
||||
"privacy.s4_li4": "لا يتم تحميل أي ملفات تعريف ارتباط للتتبع أو سكربتات تحليلات.",
|
||||
"privacy.s4_li5": "تتم الاستعانة بخدمات الذكاء الاصطناعي من طرف ثالث (مثل OpenAI، Azure Document Intelligence) فقط عندما ت Initiate معالجة الوثائق، ويتم نقل البيانات بموجب اتفاقيات معالجة البيانات.",
|
||||
"privacy.s4_p1": "تم تصميم DocuElevate مع الحد من البيانات كمبدأ أساسي (GDPR المادة 5(1)(ج)):",
|
||||
"privacy.s5_cookie_link": "سياسة ملفات تعريف الارتباط",
|
||||
"privacy.s5_heading": "5. استخدام ملفات تعريف الارتباط والتقنيات المماثلة",
|
||||
"privacy.s5_p1_post": "للحفاظ على جلسة مصادق عليها. تعتبر هذه الملفات ضرورية لتعمل الخدمة وهي معفاة من متطلبات الموافقة المسبقة بموجب توجيه الخصوصية الإلكترونية للاتحاد الأوروبي (المادة 5(3)) والقوانين الوطنية المعادلة.",
|
||||
"privacy.s5_p1_pre": "يستخدم DocuElevate",
|
||||
"privacy.s5_p1_strong": "فقط ملفات تعريف الارتباط الجلسة الضرورية بشكل صارم",
|
||||
"privacy.s5_p2_body": "ملفات تعريف الارتباط التحليلية، ملفات تعريف الارتباط الإعلانية، بيكسلات التتبع، أو أي ملفات تعريف ارتباط من طرف ثالث تحتاج إلى موافقتك.",
|
||||
"privacy.s5_p2_label": "نحن لا نستخدم:",
|
||||
"privacy.s5_p3_pre": "للحصول على تفاصيل كاملة عن ملفات تعريف الارتباط التي نعيّنها، وأسمائها، ومدة بقائها، والغرض منها، يرجى زيارة",
|
||||
"privacy.s6_ai_body": "عند Initiate OCR أو استخراج البيانات الوصفية المعتمدة على الذكاء الاصطناعي، يتم نقل بيانات الوثيقة إلى خدمة الذكاء الاصطناعي التي قمت بتكوينها أنت أو المدير الخاص بك. يتم تنظيم هذا النقل بموجب اتفاقية معالجة بيانات مع المزود المعني.",
|
||||
"privacy.s6_ai_label": "خدمات معالجة الذكاء الاصطناعي (OpenAI، Azure Document Intelligence، وغيرها):",
|
||||
"privacy.s6_heading": "6. خدمات الطرف الثالث",
|
||||
"privacy.s6_no_sale_body": "نحن لا نبيع أو نؤجر أو نشارك بياناتك الشخصية مع أطراف ثالثة لأغراض الإعلان أو التسويق أو لأي غرض غير مرتبط بتقديم الخدمة.",
|
||||
"privacy.s6_no_sale_label": "لا بيع أو مشاركة لأغراض الإعلان:",
|
||||
"privacy.s6_oauth_body": "عندما تختار المصادقة عبر OAuth، يقوم المزود المعني بمعالجة بيانات اعتمادك وقد يشارك معلومات ملف تعريف محدودة معنا. هؤلاء المزودون يحتفظون بسياسات الخصوصية الخاصة بهم.",
|
||||
"privacy.s6_oauth_label": "مزودو OAuth (Google، Dropbox، Microsoft):",
|
||||
"privacy.s6_storage_body": "تُخزن الوثائق في مزود السحابة الذي تقوم بتكوينه. تُخزن بيانات اعتمادك المكونة مشفرة في قاعدة بيانات التطبيق وتستخدم فقط لأداء عمليات التخزين التي تطلبها.",
|
||||
"privacy.s6_storage_label": "مزودو التخزين السحابي (Google Drive، Dropbox، OneDrive، Amazon S3، Nextcloud، WebDAV/SFTP/FTP):",
|
||||
"privacy.s7_adequacy_body": "حيث اعترفت المفوضية الأوروبية بمستوى معادل من الحماية (مثل المملكة المتحدة، سويسرا، كندا (المنظمات التجارية)، اليابان، كوريا الجنوبية).",
|
||||
"privacy.s7_adequacy_label": "قرارات الملاءمة",
|
||||
"privacy.s7_contact": "يمكنك طلب نسخة من الضمانات المناسبة من خلال الاتصال بنا على",
|
||||
"privacy.s7_heading": "7. نقل البيانات الدولية",
|
||||
"privacy.s7_idta_body": "لعمليات النقل من المملكة المتحدة بعد خروج بريطانيا من الاتحاد الأوروبي.",
|
||||
"privacy.s7_idta_label": "اتفاقيات نقل البيانات الدولية في المملكة المتحدة (IDTAs)",
|
||||
"privacy.s7_p1": "يستضيف DocuElevate في الاتحاد الأوروبي / المنطقة الاقتصادية الأوروبية بشكل افتراضي. حيث يتم نقل البيانات الشخصية خارج المنطقة الاقتصادية الأوروبية (على سبيل المثال إلى مزودي خدمات الذكاء الاصطناعي في الولايات المتحدة مثل OpenAI)، نعتمد على الضمانات المناسبة بما في ذلك:",
|
||||
"privacy.s7_scc_body": "المعتمدة من قبل المفوضية الأوروبية (2021/914/EU) لعمليات النقل إلى المعالجات والمراقبين في البلدان الثالثة.",
|
||||
"privacy.s7_scc_label": "شروط تعاقدية قياسية (SCCs)",
|
||||
"privacy.s8_audit_body": "تم الاحتفاظ بها لمدة تصل إلى 90 يومًا لأغراض الأمان والامتثال.",
|
||||
"privacy.s8_audit_label": "سجلات التدقيق:",
|
||||
"privacy.s8_contact": "لطلب حذف حسابك وجميع بياناتك الشخصية المرتبطة، يرجى الاتصال بنا على",
|
||||
"privacy.s8_files_body": "تم الاحتفاظ بها لمدة استخدامك للخدمة. يمكنك حذف ملفات فردية في أي وقت من خلال التطبيق.",
|
||||
"privacy.s8_files_label": "سجلات الملفات والبيانات الوصفية:",
|
||||
"privacy.s8_heading": "8. الاحتفاظ بالبيانات",
|
||||
"privacy.s8_oauth_body": "تُخزن في شكل مشفر ويمكن إلغاء استخدامها في أي وقت من خلال مزود OAuth الخاص بك.",
|
||||
"privacy.s8_oauth_label": "رموز OAuth:",
|
||||
"privacy.s8_p1": "نحتفظ بالبيانات الشخصية فقط طالما أنه ضروري تمامًا لتقديم خدمة DocuElevate أو للامتثال للالتزامات القانونية:",
|
||||
"privacy.s8_session_body": "يتم حذفها عند تسجيل الخروج أو بعد انتهاء جلسة العمل.",
|
||||
"privacy.s8_session_label": "بيانات الجلسة:",
|
||||
"privacy.s9_heading": "9. أمان البيانات",
|
||||
"privacy.s9_li1": "تشفير بيانات الاعتماد والتكوينات الحساسة أثناء الراحة.",
|
||||
"privacy.s9_li2": "أمان طبقة النقل (TLS/HTTPS) لجميع الاتصالات.",
|
||||
"privacy.s9_li3": "ضوابط وصول قائمة على الدور تحد من الوصول إلى البيانات الشخصية.",
|
||||
"privacy.s9_li4": "عمليات تدقيق الأمان العادية وفحص نقاط ضعف الاعتماد.",
|
||||
"privacy.s9_li5": "حماية CSRF على جميع الطلبات التي تغير الحالة.",
|
||||
"privacy.s9_p1": "نحن نطبق تدابير تقنية وتنظيمية مناسبة (TOMs) لحماية بياناتك الشخصية، بما في ذلك:",
|
||||
"privacy.toc_1": "التحكم في البيانات",
|
||||
"privacy.toc_10": "حقوقك (الاتحاد الأوروبي / المنطقة الاقتصادية الأوروبية / المملكة المتحدة / سويسرا)",
|
||||
"privacy.toc_11": "حقوق إضافية – الولايات المتحدة (CCPA/CPRA)",
|
||||
"privacy.toc_12": "حقوق إضافية – كندا (PIPEDA / القانون 25)",
|
||||
"privacy.toc_13": "حقوق إضافية – أمريكا اللاتينية (LGPD وغيرها)",
|
||||
"privacy.toc_14": "حقوق إضافية – منطقة آسيا والمحيط الهادئ واليابان",
|
||||
"privacy.toc_15": "حقوق إضافية – أوكرانيا",
|
||||
"privacy.toc_16": "تحديثات على إشعار الخصوصية هذا",
|
||||
"privacy.toc_2": "نطاق إشعار الخصوصية هذا",
|
||||
"privacy.toc_3": "جمع البيانات والأغراض",
|
||||
"privacy.toc_4": "تقليل البيانات وقيود الغرض",
|
||||
"privacy.toc_5": "استخدام الكوكيز والتقنيات المشابهة",
|
||||
"privacy.toc_6": "الخدمات التابعة لجهات خارجية",
|
||||
"privacy.toc_7": "نقل البيانات الدولية",
|
||||
"privacy.toc_8": "احتفاظ البيانات",
|
||||
"privacy.toc_9": "أمن البيانات",
|
||||
"privacy.toc_heading": "المحتويات",
|
||||
"profile.avatar_alt": "صورتك الشخصية",
|
||||
"profile.avatar_heading": "الصورة الشخصية",
|
||||
"profile.avatar_remove": "إزالة الصورة الرمزية المخصصة",
|
||||
@@ -838,6 +1377,9 @@
|
||||
"profile.contact_email_label": "البريد الإلكتروني للتواصل / الإشعارات",
|
||||
"profile.contact_email_placeholder": "you@example.com",
|
||||
"profile.current_password": "كلمة المرور الحالية",
|
||||
"profile.default_document_language_auto": "استخدم الافتراضي للنظام",
|
||||
"profile.default_document_language_hint": "يتم ترجمة المستندات بلغات أخرى تلقائياً إلى هذه اللغة. اتركه فارغاً لاستخدام الافتراضي للنظام (الإنجليزية).",
|
||||
"profile.default_document_language_label": "لغة المستند الافتراضية",
|
||||
"profile.dismiss": "تجاهل",
|
||||
"profile.display_name_hint": "اتركه فارغًا لاستخدام اسم المستخدم أو البريد الإلكتروني الخاص بك.",
|
||||
"profile.display_name_label": "اسم العرض",
|
||||
@@ -1040,14 +1582,66 @@
|
||||
"similarity.subtitle": "أزواج من المستندات ذات التشابه الدلالي العالي، مرتبة حسب الدرجة.",
|
||||
"similarity.trigger_aria": "ابدأ حساب التضمين لجميع الملفات المفقودة التضمين",
|
||||
"similarity.trigger_now": "ابدأ الآن",
|
||||
"status.active": "نشط",
|
||||
"status.ai_empty_response": "(فارغ)",
|
||||
"status.ai_extraction_desc": "الصق محتوى النص العادي لمستند أدناه ومرره من خلال مزود الذكاء الاصطناعي المكونات لعرض الاستجابة الخام، JSON المستخرج، والعلامات.",
|
||||
"status.ai_extraction_failed": "فشل الاستخراج بالذكاء الاصطناعي",
|
||||
"status.ai_extraction_label": "نص المستند",
|
||||
"status.ai_extraction_placeholder": "الصق محتوى النص العادي لمستندك هنا\u00002026",
|
||||
"status.ai_extraction_title": "اختبار استخراج الذكاء الاصطناعي",
|
||||
"status.app_version": "إصدار التطبيق",
|
||||
"status.as_account": "كـ",
|
||||
"status.auth_required": "مطلوب مصادقة",
|
||||
"status.build_date": "تاريخ البناء",
|
||||
"status.config_settings": "إعدادات التكوين",
|
||||
"status.config_settings_desc": "لإعدادات التكوين الأكثر تفصيلاً ومتغيرات البيئة، تحقق من صفحة الإعدادات.",
|
||||
"status.configure_now": "قم بالتكوين الآن",
|
||||
"status.configured": "تم التكوين",
|
||||
"status.connection_error": "خطأ في الاتصال",
|
||||
"status.connection_test_failed": "فشل اختبار الاتصال",
|
||||
"status.connection_test_successful": "نجح اختبار الاتصال",
|
||||
"status.container_id": "معرف الحاوية",
|
||||
"status.container_started": "تم بدء الحاوية",
|
||||
"status.dashboard_subtitle": "يعرض هذا اللوحة حالة جميع التكاملات والأهداف المكونة.",
|
||||
"status.debug_mode": "وضع التصحيح",
|
||||
"status.error_running_extraction": "خطأ في تشغيل الاستخراج: ",
|
||||
"status.error_testing_connection": "خطأ في اختبار الاتصال: ",
|
||||
"status.error_testing_notifications": "خطأ في اختبار الإشعارات: ",
|
||||
"status.extracted_tags": "العلامات المستخرجة",
|
||||
"status.git_commit": "التزام Git",
|
||||
"status.inactive": "غير نشط",
|
||||
"status.json_parse_issue": "مشكلة تحليل JSON: ",
|
||||
"status.last_check": "آخر فحص",
|
||||
"status.manage": "إدارة",
|
||||
"status.modal_default_message": "تم إكمال العملية بنجاح.",
|
||||
"status.modal_default_title": "نجاح",
|
||||
"status.no_details": "لا توجد تفاصيل متاحة",
|
||||
"status.not_configured": "غير مُكوّن",
|
||||
"status.notification_config_missing": "تكوين الإخطار مفقود",
|
||||
"status.open": "فتح",
|
||||
"status.page_title": "حالة النظام",
|
||||
"status.parsed_json_label": "JSON مُحلل",
|
||||
"status.provider_config_details": "تفاصيل تكوين {name}",
|
||||
"status.provider_details": "تفاصيل المزود",
|
||||
"status.raw_llm_response": "استجابة LLM خام",
|
||||
"status.run_extraction": "تشغيل استخراج",
|
||||
"status.running": "يعمل\u0014",
|
||||
"status.sending": "إرسال...",
|
||||
"status.setting_label": "الإعداد",
|
||||
"status.test_connection": "اختبار الاتصال",
|
||||
"status.test_extraction": "اختبار الاستخراج",
|
||||
"status.test_failed": "فشل الاختبار",
|
||||
"status.test_notification_failed": "فشل إشعار الاختبار",
|
||||
"status.test_notification_sent": "تم إرسال إشعار الاختبار",
|
||||
"status.test_notifications": "اختبارات الإشعارات",
|
||||
"status.test_provider": "اختبار {name}",
|
||||
"status.test_successful": "الاختبار ناجح",
|
||||
"status.testing": "يتم الاختبار...",
|
||||
"status.token_expired": "لقد انتهت صلاحية الرمز الخاص بك أو أنه غير صالح. يرجى إعادة تكوين هذا الاتصال.",
|
||||
"status.token_valid_for": "الرمز صالح لمدة:",
|
||||
"status.value_label": "القيمة",
|
||||
"status.view_config": "عرض التكوين التفصيلي",
|
||||
"status.view_details": "عرض التفاصيل",
|
||||
"subscription.available_plans_heading": "الخطط المتاحة",
|
||||
"subscription.back_to_dashboard": "العودة إلى اللوحة",
|
||||
"subscription.cancel_pending": "إلغاء التغيير",
|
||||
@@ -1083,6 +1677,51 @@
|
||||
"subscription.upgrade_info": "تدخل الترقيات حيز التنفيذ على الفور. يتم جدولة التخفيضات لنهاية فترة الفوترة الحالية.",
|
||||
"subscription.upgrade_to_prefix": "ترقية إلى",
|
||||
"subscription.usage_heading": "الاستخدام",
|
||||
"terms.cookie_link": "سياسة الكوكيز",
|
||||
"terms.heading": "شروط الخدمة",
|
||||
"terms.last_updated": "آخر تحديث:",
|
||||
"terms.license_link": "معلومات الترخيص",
|
||||
"terms.page_title": "شروط الخدمة - DocuElevate",
|
||||
"terms.privacy_link": "سياسة الخصوصية",
|
||||
"terms.s1_heading": "1. قبول الشروط",
|
||||
"terms.s1_p1": "من خلال الوصول إلى DocuElevate أو استخدامه، فإنك توافق على الالتزام بهذه الشروط. إذا كنت لا توافق على هذه الشروط، يرجى عدم استخدام هذه الخدمة.",
|
||||
"terms.s2_heading": "2. وصف الخدمة",
|
||||
"terms.s2_p1": "يوفر DocuElevate خدمات معالجة الوثائق، والتعرف الضوئي على الأحرف، واستخراج البيانات الوصفية، والتخزين. نحتفظ بالحق في تعديل أو إيقاف أي جانب من جوانب الخدمة في أي وقت.",
|
||||
"terms.s3_heading": "3. مسؤوليات المستخدم",
|
||||
"terms.s3_li1": "جميع المحتويات التي تقوم بتحميلها إلى DocuElevate",
|
||||
"terms.s3_li2": "ضمان أن لديك الحقوق المناسبة لتحميل ومعالجة الوثائق",
|
||||
"terms.s3_li3": "الحفاظ على سرية بيانات اعتماد حسابك",
|
||||
"terms.s3_li4": "أي نشاط يحدث تحت حسابك",
|
||||
"terms.s3_p1": "أنت مسؤول عن:",
|
||||
"terms.s3_p2_and": "و",
|
||||
"terms.s3_p2_post": ".",
|
||||
"terms.s3_p2_pre": "باستخدام خدمتنا، فإنك توافق أيضًا على",
|
||||
"terms.s4_heading": "4. حقوق الملكية الفكرية",
|
||||
"terms.s4_p1": "يحترم DocuElevate حقوق الملكية الفكرية. لا يجوز للمستخدمين تحميل محتوى ينتهك حقوق الملكية الفكرية للآخرين.",
|
||||
"terms.s5_heading": "5. تحديد المسؤولية",
|
||||
"terms.s5_p1": "يقدم DocuElevate الخدمة \"كما هي\" دون أي ضمانات من أي نوع. لن نكون مسؤولين عن أي أضرار مباشرة أو غير مباشرة أو عرضية أو خاصة أو تبعية أو تأديبية ناتجة عن استخدامك أو عدم قدرتك على استخدام الخدمة.",
|
||||
"terms.s6_heading": "6. القانون الحاكم",
|
||||
"terms.s6_p1": "تخضع هذه الشروط لقوانين ألمانيا، دون النظر إلى أحكام تعارض القوانين الخاصة بها.",
|
||||
"terms.s6_p2_post": ".",
|
||||
"terms.s6_p2_pre": "إذا كان لديك أي أسئلة حول هذه الشروط، يرجى الاتصال بنا على",
|
||||
"terms.s6_p3_mid": ". للحصول على معلومات عن الترخيص، يرجى الرجوع إلى",
|
||||
"terms.s6_p3_post": ".",
|
||||
"terms.s6_p3_pre": "لمزيد من المعلومات حول كيفية استخدامنا للكوكيز، يرجى الاطلاع على",
|
||||
"translation.copied": "تم النسخ!",
|
||||
"translation.copy": "نسخ",
|
||||
"translation.default_language_version": "إصدار اللغة الافتراضية",
|
||||
"translation.detected_language": "اللغة المكتشفة",
|
||||
"translation.hide_text": "إخفاء النص",
|
||||
"translation.load_translation": "تحميل الترجمة",
|
||||
"translation.no_translation": "لا تتوفر ترجمة بعد \u0014 قد تكون لا تزال تحت المعالجة",
|
||||
"translation.select_language": "اختر اللغة\u00002026",
|
||||
"translation.select_target": "يرجى اختيار لغة الهدف.",
|
||||
"translation.show_text": "عرض النص",
|
||||
"translation.translate_btn": "ترجم",
|
||||
"translation.translate_to": "ترجمة إلى لغة أخرى",
|
||||
"translation.translated_to": "تمت الترجمة إلى",
|
||||
"translation.translating": "جارٍ الترجمة\u00002026",
|
||||
"translation.translation_failed": "فشلت الترجمة",
|
||||
"upload.browse_button": "تصفح الملفات",
|
||||
"upload.button_processing": "جاري المعالجة...",
|
||||
"upload.camera_button": "التقط صورة / امسح المستند",
|
||||
|
||||
@@ -41,6 +41,280 @@
|
||||
"about.story_heading": "Hekayəmiz",
|
||||
"about.story_p1": "DocuElevate bir hədəflə yaradılıb: sənəd idarəetməsini kiçik startapdan böyük müəssisəyə qədər hər kəs üçün sadələşdirmək və axınlaşdırmaq.",
|
||||
"about.story_p2": "Məlumatların çıxarılması və mətnin təkmilləşdirilməsi üçün AI təminatçılarının (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey və daha çox) gücünü istifadə edirik, Dropbox, Nextcloud və Paperless NGX ilə saxlama və indeksləşdirilmək üçün məsafəsiz inteqrasiya edirik, Azure Document Intelligence-dən OCR üçün istifadə edirik və hətta faylları PDF-ə çevirmək üçün Gotenberg-i istifadə edirik.",
|
||||
"admin_files.admin_only_badge": "Yalnız Admin",
|
||||
"admin_files.aria_breadcrumb": "Çörək qırıntısı",
|
||||
"admin_files.badge_delta_detected": "Dəyişiklik aşkar edildi",
|
||||
"admin_files.badge_duplicate": "təkrarlama",
|
||||
"admin_files.badge_in_db": "DB-də",
|
||||
"admin_files.badge_on_disk": "diskdə",
|
||||
"admin_files.breadcrumb_workdir": "işçi",
|
||||
"admin_files.btn_download": "Yüklə",
|
||||
"admin_files.col_actions": "Hərəkətlər",
|
||||
"admin_files.col_db": "DB",
|
||||
"admin_files.col_health": "Sağlamlıq",
|
||||
"admin_files.col_id": "ID",
|
||||
"admin_files.col_ingested": "İşlənmiş",
|
||||
"admin_files.col_local_filename": "yerli_fayl_adı",
|
||||
"admin_files.col_missing_paths": "İtən yollar",
|
||||
"admin_files.col_modified": "Dəyişdirilmiş",
|
||||
"admin_files.col_name": "Ad",
|
||||
"admin_files.col_original_file_path": "əsas_fayl_yolu",
|
||||
"admin_files.col_original_filename": "Əsas Fayl Adı",
|
||||
"admin_files.col_path_relative": "Yol (işçi kataloguna nisbətən)",
|
||||
"admin_files.col_processed_file_path": "işlənmiş_fayl_yolu",
|
||||
"admin_files.col_size": "Ölçü",
|
||||
"admin_files.delta_detected_detail": "Diskdə {orphan_count} anadan bəri faylı tapıldı, DB qeydi yoxdur və diskdə itən fayllarla {ghost_count} DB qeyd var.",
|
||||
"admin_files.delta_detected_title": "Delta aşkar edildi.",
|
||||
"admin_files.empty_database": "Verilənlər bazasında heç bir fayl qeydi tapılmadı.",
|
||||
"admin_files.empty_directory": "Bu katalog boşdur.",
|
||||
"admin_files.ghost_records_desc": "(DB-də, diskdə itən fayl(lar))",
|
||||
"admin_files.ghost_records_heading": "Spirit qeydləri",
|
||||
"admin_files.heading": "Fayl Meneceri",
|
||||
"admin_files.health_missing": "İtən",
|
||||
"admin_files.health_ok": "Yaxşı",
|
||||
"admin_files.legend_file_exists": "Fayl diskdə mövcuddur",
|
||||
"admin_files.legend_file_missing": "Fayl disksdən itmişdir",
|
||||
"admin_files.legend_found_in_db": "DB-də tapıldı",
|
||||
"admin_files.legend_not_in_db": "DB-də yoxdur (anadan)",
|
||||
"admin_files.legend_path_not_set": "Yol təyin edilməyib",
|
||||
"admin_files.no_delta": "Delta tapılmadı – fayl sistemi və verilənlər bazası uyğun görünür.",
|
||||
"admin_files.no_ghost_records": "Heç bir ghost qeydi tapılmadı.",
|
||||
"admin_files.no_orphan_files": "Heç bir anadan bəri fayl tapılmadı.",
|
||||
"admin_files.orphan_files_desc": "(diskdə, DB qeydi yoxdur)",
|
||||
"admin_files.orphan_files_heading": "Anadan fayllar",
|
||||
"admin_files.page_title": "Fayl Meneceri – Admin",
|
||||
"admin_files.status_in_db": "DB-də",
|
||||
"admin_files.status_orphan": "Anadan",
|
||||
"admin_files.tab_database": "Verilənlər Bazası Qeydləri",
|
||||
"admin_files.tab_filesystem": "Fayl Sistemi",
|
||||
"admin_files.tab_reconcile": "Qayda",
|
||||
"admin_plans.aria_delete_plan": "Sil {name}",
|
||||
"admin_plans.aria_edit_plan": "Düzenlə {name}",
|
||||
"admin_plans.aria_feature_n": "Xüsusiyyət {n}",
|
||||
"admin_plans.aria_move_down": "{name} aşağı köçür",
|
||||
"admin_plans.aria_move_up": "{name} yuxarı köçür",
|
||||
"admin_plans.aria_remove_feature_n": "Xüsusiyyət {n} sil",
|
||||
"admin_plans.btn_add_feature": "Xüsusiyyət Əlavə Et",
|
||||
"admin_plans.btn_add_plan": "Plan Əlavə Et",
|
||||
"admin_plans.btn_cancel": "İmtina Et",
|
||||
"admin_plans.btn_create": "Plan Yarat",
|
||||
"admin_plans.btn_delete": "Sil",
|
||||
"admin_plans.btn_edit": "Düzenlə",
|
||||
"admin_plans.btn_restore_defaults": "Fərzi Bərpa Et",
|
||||
"admin_plans.btn_restore_defaults_title": "Bütün dörd fərzi bərpa et (yalnız əgər hələ plan yoxdursa)",
|
||||
"admin_plans.btn_restoring": "Bərpa olunur\u001a",
|
||||
"admin_plans.btn_save_changes": "Dəyişiklikləri Saxla",
|
||||
"admin_plans.btn_save_order": "Sıralamayı Saxla",
|
||||
"admin_plans.btn_saving": "Saxlanılır\u001a",
|
||||
"admin_plans.btn_stripe_setup": "Stripe Quraşdırması",
|
||||
"admin_plans.btn_stripe_setup_title": "API açarlarını konfiqurasiya etmək və planları sinkronizasiya etmək üçün Stripe Quraşdırma Wizard-ını aç",
|
||||
"admin_plans.col_actions": "Əməliyyatlar",
|
||||
"admin_plans.col_active": "Aktiv",
|
||||
"admin_plans.col_monthly": "Aylıq",
|
||||
"admin_plans.col_monthly_limit": "Aylıq Limit",
|
||||
"admin_plans.col_order": "Sıra",
|
||||
"admin_plans.col_overage_pct": "Artıqlıq %",
|
||||
"admin_plans.col_plan": "Plan",
|
||||
"admin_plans.col_yearly": "İllik",
|
||||
"admin_plans.coming_soon": "Tezliklə",
|
||||
"admin_plans.featured_badge": "Seçilmiş",
|
||||
"admin_plans.field_active": "Aktiv",
|
||||
"admin_plans.field_allow_overage": "Artıqlıq Nizamlamasını İcazə Ver",
|
||||
"admin_plans.field_api_access": "API Girişi",
|
||||
"admin_plans.field_badge_text": "Baj Text",
|
||||
"admin_plans.field_buffer": "Tampon:",
|
||||
"admin_plans.field_cta_text": "CTA Düymə Mətni",
|
||||
"admin_plans.field_docs_month": "Sənədlər / Aylıq",
|
||||
"admin_plans.field_featured": "Seçilmiş / Vurğulanmış",
|
||||
"admin_plans.field_lifetime_docs": "Həyat Boyu Sənədlər",
|
||||
"admin_plans.field_mailboxes": "Email Poçt Qutuları",
|
||||
"admin_plans.field_max_file_size": "Maksimum Fayl Ölçüsü (MB)",
|
||||
"admin_plans.field_name": "Ad",
|
||||
"admin_plans.field_ocr_pages": "OCR Səhifələri / Ay",
|
||||
"admin_plans.field_overage_doc_price": "Həddən Artıq Qiyməti / sənəd ($)",
|
||||
"admin_plans.field_overage_ocr_price": "Həddən Artıq Qiyməti / OCR səhifəsi ($)",
|
||||
"admin_plans.field_plan_id": "Plan ID",
|
||||
"admin_plans.field_price_monthly": "Aylıq Qiymət ($)",
|
||||
"admin_plans.field_price_yearly": "İllik Qiymət ($)",
|
||||
"admin_plans.field_sort_order": "Sıralama Nömrəsi",
|
||||
"admin_plans.field_storage_dests": "Saxlama Məqsədləri",
|
||||
"admin_plans.field_stripe_monthly": "Stripe Qiymət ID (aylıq)",
|
||||
"admin_plans.field_stripe_yearly": "Stripe Qiymət ID (illik)",
|
||||
"admin_plans.field_tagline": "Şüar",
|
||||
"admin_plans.field_trial_days": "İmtihan Günləri",
|
||||
"admin_plans.free_label": "Pulsuz",
|
||||
"admin_plans.heading": "Plan Dizayneri",
|
||||
"admin_plans.hint_features": "Bu nöqtələr bu plan üçün qiymət səhifəsinin kartında görünəcək.",
|
||||
"admin_plans.hint_plan_id": "Kiçik hərflərlə yazılmış slug, yaradıldıqdan sonra dəyişdirilə bilməz.",
|
||||
"admin_plans.hint_zero_unlimited": "Limitsiz üçün 0 daxil edin.",
|
||||
"admin_plans.js_delete_confirm": "\"{id}\" planını silmək istəyirsiniz? Bu geri alınmayacaq.",
|
||||
"admin_plans.js_delete_failed": "Silinmə baş tutmadı",
|
||||
"admin_plans.js_failed_load": "Planlar yüklənə bilmədi",
|
||||
"admin_plans.js_order_saved": "Sifariş saxlanıldı!",
|
||||
"admin_plans.js_plan_created": "Plan yaradıldı!",
|
||||
"admin_plans.js_plan_deleted": "\"{id}\" planı silindi.",
|
||||
"admin_plans.js_plan_updated": "Plan yeniləndi!",
|
||||
"admin_plans.js_reorder_failed": "Təkrar sifariş baş tutmadı",
|
||||
"admin_plans.js_save_failed": "Saxlama baş tutmadı",
|
||||
"admin_plans.js_seed_confirm": "Dörd standart planın toxumlarını atmaq istəyirsiniz? Əgər planlar artıq mövcuddursa, bu heç bir təsir etmir.",
|
||||
"admin_plans.js_seed_failed": "Toxum atma baş tutmadı",
|
||||
"admin_plans.js_yearly_enter": "Saxlamaq üçün illik qiyməti daxil edin",
|
||||
"admin_plans.js_yearly_save": "Aylıqla müqayisədə {pct}% qazanmaq",
|
||||
"admin_plans.loading": "Planlar yüklənir\u001e",
|
||||
"admin_plans.modal_close_aria": "Modalı bağla",
|
||||
"admin_plans.modal_create_title": "Plan əlavə et",
|
||||
"admin_plans.modal_edit_title_prefix": "Planı redaktə et: ",
|
||||
"admin_plans.no_plans_intro": "Hələ plan yoxdur. Click",
|
||||
"admin_plans.no_plans_suffix": "dörd daxili planın toxumlarını atmaq üçün.",
|
||||
"admin_plans.overage_0pct": "0% (dəqiq)",
|
||||
"admin_plans.overage_100pct": "100%",
|
||||
"admin_plans.overage_50pct": "50%",
|
||||
"admin_plans.overage_announce": "\u00122 announce",
|
||||
"admin_plans.overage_buffer_body_prefix": "Çıxış tamponu",
|
||||
"admin_plans.overage_buffer_body_suffix": "Biz X sənəd/aylıq reklam edirik, amma yalnız",
|
||||
"admin_plans.overage_buffer_formula": "X \u0000d7 (1 + tampon%)",
|
||||
"admin_plans.overage_buffer_invisible": "istifadəçilər üçün görünməz",
|
||||
"admin_plans.overage_buffer_tail": "sənəd. Məsələn, 20% tamponla 150-sənəd/aylıq plan 180 sənəd üzrə icra edir. Bu kritik limitdə sərt kəsmələrın qarşısını alır, istifadəçilərə nazik bir yumşaq eniş verir.",
|
||||
"admin_plans.overage_buffer_title": "Çıxış Tamponu Haqda",
|
||||
"admin_plans.overage_docs": "sənəd,",
|
||||
"admin_plans.overage_docs_end": "sənəd",
|
||||
"admin_plans.overage_enforce_at": "icra et",
|
||||
"admin_plans.page_title": "Plan Dizayneri \u0014 DocuElevate Admin",
|
||||
"admin_plans.section_basic_info": "Əsas Məlumat",
|
||||
"admin_plans.section_display": "Göstər",
|
||||
"admin_plans.section_features": "Xüsusiyyətlər Siyahısı",
|
||||
"admin_plans.section_overage": "Çıxış Dizayneri",
|
||||
"admin_plans.section_pricing": "Qiymətləndirmə",
|
||||
"admin_plans.section_stripe": "Stripe Inteqrasiya",
|
||||
"admin_plans.section_volume": "Həcm Məhdudiyyətləri",
|
||||
"admin_plans.status_active": "Aktiv",
|
||||
"admin_plans.status_inactive": "İnaktiv",
|
||||
"admin_plans.stripe_desc_after": "onları avtomatik yaratmaq. Pulsuz planlar üçün Stripe Qiymət ID-lərinə ehtiyac yoxdur.",
|
||||
"admin_plans.stripe_desc_before": "Bu plan üçün Stripe Qiymət ID-lərini daxil edin, ya da",
|
||||
"admin_plans.stripe_wizard_aria": "Yeni tabda Stripe Quraşdırma H wizardını aç",
|
||||
"admin_plans.stripe_wizard_link": "Stripe Wizard",
|
||||
"admin_plans.stripe_wizard_text": "Stripe Quraşdırma Wizardı",
|
||||
"admin_plans.subheading": "İctimai qiymət səhifəsində göstərilən abunə planlarını idarə edin.",
|
||||
"admin_plans.table_aria_label": "Abunə planları",
|
||||
"admin_users.add_user_profile_btn": "İstifadəçi Profilini əlavə et",
|
||||
"admin_users.admin_only_badge": "Yalnız Admin",
|
||||
"admin_users.btn_password": "Şifrə",
|
||||
"admin_users.btn_reset": "Təzələmək",
|
||||
"admin_users.col_display_name": "Göstərilən Ad",
|
||||
"admin_users.col_documents": "Sənədlər",
|
||||
"admin_users.col_email": "E-poçt",
|
||||
"admin_users.col_last_upload": "Son Yükləmə",
|
||||
"admin_users.col_plan": "Plan",
|
||||
"admin_users.col_role": "Rol",
|
||||
"admin_users.col_upload_limit": "Yükləmə Limiti",
|
||||
"admin_users.col_user_id": "İstifadəçi ID",
|
||||
"admin_users.col_username": "İstifadəçi adı",
|
||||
"admin_users.create_local_account_btn": "Yerel Hesab Yarat",
|
||||
"admin_users.create_local_title": "Yerel Hesab Yarat",
|
||||
"admin_users.delete_account_btn": "Hesabı Sil",
|
||||
"admin_users.delete_local_confirm": "Bu hesabı silmək istədiyinizdən əminsinizmi?",
|
||||
"admin_users.delete_local_title": "Yerel Hesabı Sil",
|
||||
"admin_users.delete_local_warning": "Bu geri alına bilməz. Bu istifadəçiyə aid olan sənədlər silinmir.",
|
||||
"admin_users.delete_profile_btn": "Profilin Silinməsi",
|
||||
"admin_users.delete_profile_confirm": "Bu profili silmək istədiyinizdən əminsinizmi?",
|
||||
"admin_users.delete_profile_title": "İstifadəçi Profilini Sil",
|
||||
"admin_users.delete_profile_warning": "Bu yalnız admin tərəfindən idarə olunan profil qeydini silir. Bu istifadəçiyə aid olan sənədlər silinmir.",
|
||||
"admin_users.deleting": "Silinməkdə\u00127",
|
||||
"admin_users.edit_local_title": "Yerel Hesabı Redaktə Et",
|
||||
"admin_users.filter_placeholder": "İstifadəçi ID ilə filtrləyin\u00127",
|
||||
"admin_users.global_default": "qlobal defolt",
|
||||
"admin_users.heading": "İstifadəçi İdarəetməsi",
|
||||
"admin_users.js_account_created": "Hesab yaradıldı",
|
||||
"admin_users.js_account_created_msg": "\"{username}\" üçün yerel hesab müvəffəqiyyətlə yaradıldı.",
|
||||
"admin_users.js_account_deleted_msg": "\"{username}\" üçün hesab silindi.",
|
||||
"admin_users.js_account_updated": "Hesab yeniləndi.",
|
||||
"admin_users.js_delete_failed": "Silinmə uğursuz oldu",
|
||||
"admin_users.js_deleted": "Silinmişdir",
|
||||
"admin_users.js_email_not_sent": "E-poçt göndərilmədi",
|
||||
"admin_users.js_email_sent": "E-poçt göndərildi",
|
||||
"admin_users.js_email_sent_msg": "\"{email}\" ünvanına şifrə yeniləmə e-poçtu göndərildi.",
|
||||
"admin_users.js_failed": "Uğursuz oldu",
|
||||
"admin_users.js_failed_create": "Hesab yaratmaqda uğursuz oldu.",
|
||||
"admin_users.js_failed_load_local": "Yerel istifadəçiləri yükləməkdə uğursuz oldu",
|
||||
"admin_users.js_failed_load_users": "İstifadəçiləri yükləmək mümkün olmadı",
|
||||
"admin_users.js_failed_set_password": "Şifrəni təyin etmək mümkün olmadı.",
|
||||
"admin_users.js_failed_update": "Hesabı yeniləmək mümkün olmadı.",
|
||||
"admin_users.js_network_error": "Şəbəkə xətası",
|
||||
"admin_users.js_password_set": "Şifrə təyin edilib",
|
||||
"admin_users.js_password_set_msg": "\"{username}\" üçün şifrə yenilənib.",
|
||||
"admin_users.js_profile_deleted": "\"{id}\" üçün profil silinib.",
|
||||
"admin_users.js_profile_saved": "\"{id}\" üçün profil saxlanılıb.",
|
||||
"admin_users.js_save_failed": "Saxlamaq mümkün olmadı",
|
||||
"admin_users.js_saved": "Saxlanıldı",
|
||||
"admin_users.js_smtp_not_configured": "SMTP konfiqurasiya edilməyib.",
|
||||
"admin_users.js_updated": "Yeniləndi",
|
||||
"admin_users.loading_users": "İstifadəçilər yüklənir\u000206",
|
||||
"admin_users.local_account_active": "Hesab aktivdir",
|
||||
"admin_users.local_accounts_heading": "Yerli İstifadəçi Hesabları",
|
||||
"admin_users.local_accounts_subheading": "Bu serverdə birbaşa yaradılan e-poçt/şifrə hesabları.",
|
||||
"admin_users.local_admin_privileges": "Administrator hüquqları ver",
|
||||
"admin_users.local_admin_privileges_short": "Administrator hüquqları",
|
||||
"admin_users.local_create_btn": "Hesab Yarat",
|
||||
"admin_users.local_create_one": "Birini yaradın.",
|
||||
"admin_users.local_creating": "Yaradılır\u000206",
|
||||
"admin_users.local_display_name_optional": "(isteğe bağlı)",
|
||||
"admin_users.local_loading": "Yüklənir\u000206",
|
||||
"admin_users.local_no_accounts": "Hələlik yerli hesab yoxdur.",
|
||||
"admin_users.local_password_hint": "Minimum 8 simvol.",
|
||||
"admin_users.local_saving": "Saxlanılır\u000206",
|
||||
"admin_users.local_username_hint": "3\t64 simvol. Yalnız hərflər, ədədlər, tirelər və alt xətt.",
|
||||
"admin_users.modal_add_title": "İstifadəçi Profilini Əlavə Et",
|
||||
"admin_users.modal_billing_cycle_label": "Ödəniş Dövrü",
|
||||
"admin_users.modal_billing_monthly": "Aylıq",
|
||||
"admin_users.modal_billing_yearly": "İllik",
|
||||
"admin_users.modal_block_hint": "(yeni sənəd yükləmələrini maneə törədir)",
|
||||
"admin_users.modal_block_label": "Bu istifadəçini bloklayın",
|
||||
"admin_users.modal_close_aria": "Dialoqu bağla",
|
||||
"admin_users.modal_complimentary_hint": "(istifadəçi səviyyə faydalarını saxlayır, amma heç vaxt ödənilmir - administrator hesabları üçün avtomatik təyin edilir)",
|
||||
"admin_users.modal_complimentary_label": "Pulsuz plan",
|
||||
"admin_users.modal_daily_limit_hint": "(qlobal standartdan istifadə etmək üçün boş qoyun)",
|
||||
"admin_users.modal_daily_limit_label": "Gündəlik Yükləmə Limiti",
|
||||
"admin_users.modal_daily_limit_placeholder": "məsələn, 50 (0 = limitsiz)",
|
||||
"admin_users.modal_display_name_label": "Göstərilən Ad",
|
||||
"admin_users.modal_display_name_placeholder": "Alice Smith (isteğe bağlı)",
|
||||
"admin_users.modal_edit_title": "İstifadəçi Profilini Dəyiş",
|
||||
"admin_users.modal_notes_label": "Admin Qeydləri",
|
||||
"admin_users.modal_notes_placeholder": "Yalnız adminlərə görünən daxili qeydlər\u0010",
|
||||
"admin_users.modal_period_start_hint": "İllik mükafat bu tarixdən etibarən hesablanır. Aylıq icra üçün boş buraxın.",
|
||||
"admin_users.modal_period_start_label": "Abunə Müddətinin Başlanğıcı",
|
||||
"admin_users.modal_plan_business": "Biznes \u0010 $7.99/ay (300/ay, limitsiz poçt qutuları)",
|
||||
"admin_users.modal_plan_free": "Pulsuz \u0010 25 ömürlük fayl",
|
||||
"admin_users.modal_plan_hint": "Bu istifadəçi üçün kvota məhdudiyyətlərini müəyyənləşdirir. Yüklənməyə məhdudiyyətlər tətbiq olunur.",
|
||||
"admin_users.modal_plan_label": "Abunə Planı",
|
||||
"admin_users.modal_plan_professional": "Peşəkar \u0010 $5.99/ay (150/ay, 3 poçt qutusu)",
|
||||
"admin_users.modal_plan_starter": "Başlanğıc \u0010 $2.99/ay (50/ay, 1 poçt qutusu)",
|
||||
"admin_users.modal_save_changes": "Dəyişiklikləri Yadda Saxla",
|
||||
"admin_users.modal_saving": "Yadda saxlanılır\u0010",
|
||||
"admin_users.modal_user_id_hint": "Sənədlərdə owner_id ilə uyğun gələn stabil identifikator.",
|
||||
"admin_users.modal_user_id_label": "İstifadəçi ID",
|
||||
"admin_users.modal_user_id_placeholder": "user@example.com və ya OAuth sub",
|
||||
"admin_users.new_account_btn": "Yeni Hesab",
|
||||
"admin_users.new_password_label": "Yeni Şifrə",
|
||||
"admin_users.no_users_add_hint": "Bəzi sənədləri yükləyin və ya yuxarıda bir profil əlavə edin.",
|
||||
"admin_users.no_users_found": "İstifadəçi tapılmadı.",
|
||||
"admin_users.no_users_search_hint": "Fərqli bir axtarış termini sınayın.",
|
||||
"admin_users.page_title": "İstifadəçi İdarəetməsi \u0010 Admin \u0010 DocuElevate",
|
||||
"admin_users.pagination_page_of": "dən",
|
||||
"admin_users.per_day": "/ gün",
|
||||
"admin_users.role_admin": "Admin",
|
||||
"admin_users.role_user": "İstifadəçi",
|
||||
"admin_users.search_users_label": "İstifadəçiləri Axtar",
|
||||
"admin_users.set_password_btn": "Şifrə Təyin Et",
|
||||
"admin_users.set_password_desc": "İstifadəçi bu şifrəni daxil olandan sonra dəyişməlidir.",
|
||||
"admin_users.set_password_desc_pre": "Birbaşa üçün yeni şifrə təyin et",
|
||||
"admin_users.set_password_title": "Müvəqqəti Şifrəni Təyin Et",
|
||||
"admin_users.setting": "Tənzimlənir\u0010",
|
||||
"admin_users.status_blocked": "Bloklanmış",
|
||||
"admin_users.status_unverified": "Təsdiqlənməmiş",
|
||||
"admin_users.subheading": "İstifadəçi profillərini, fərdi yükləmə məhdudiyyətlərini və sənəd sahibliyini idarə edin.",
|
||||
"admin_users.total_count_users": "{count} istifadəçi",
|
||||
"admin_users.total_no_users": "İstifadəçi yoxdur",
|
||||
"admin_users.total_one_user": "1 istifadəçi",
|
||||
"api_tokens.col_created": "Yaradıldı",
|
||||
"api_tokens.col_last_ip": "Son IP",
|
||||
"api_tokens.col_last_used": "Son İstifadə",
|
||||
@@ -73,6 +347,19 @@
|
||||
"api_tokens.usage_intro_pre": "API tokeninizi istifadə edin",
|
||||
"api_tokens.your_tokens": "Sizin Tokenləriniz",
|
||||
"app.name": "DocuElevate",
|
||||
"attribution.heading": "Üçüncü Tərəf Proqramları İxtisasları",
|
||||
"attribution.intro": "DocuElevate bir neçə açıq mənbə kitabxanası və alətlərindən istifadə edir. Bu layihələrin inkişaf etdiricilərinə açıq mənbə proqram təminatına verdikləri töhfələr üçün minnətdarıq.",
|
||||
"attribution.page_title": "DocuElevate - Üçüncü Tərəf İxtisasları",
|
||||
"attribution.paramiko_lgpl_note": "Qeyd: Bu kitabxana GNU Kiçik Ümumi İctimai Lisenziya v2.1 (LGPL-2.1) ilə lisenziyalaşdırılmışdır.",
|
||||
"attribution.section_docker": "Docker Şəkilləri",
|
||||
"attribution.section_frontend": "Frontend Asılılıqları",
|
||||
"attribution.section_python": "Python Asılılıqları",
|
||||
"attribution.special_lgpl_link": "burada",
|
||||
"attribution.special_lgpl_post": ".",
|
||||
"attribution.special_lgpl_pre": "LGPL lisenziyasının bir nüsxəsi burada tapa bilər.",
|
||||
"attribution.special_source_post": ".",
|
||||
"attribution.special_source_pre": "Bu proqram Paramiko daxil edir, hansı ki, LGPL ilə lisenziyalaşdırılmışdır. Paramiko-nun mənbə kodu",
|
||||
"attribution.special_title": "Xüsusi İxtisas:",
|
||||
"audit.col_ip": "IP",
|
||||
"audit.col_resource": "Resurs",
|
||||
"audit.col_timestamp": "Zaman Damğası",
|
||||
@@ -252,6 +539,41 @@
|
||||
"cookie.notice_label": "Çerez xəbərdarlığı",
|
||||
"cookie.policy_link": "Çerez Siyasəti",
|
||||
"cookie.privacy_link": "Məxfilik Xəbərdarlığı",
|
||||
"cookie_policy.heading": "Kuki Siyasəti",
|
||||
"cookie_policy.last_updated": "Son Dəyişiklik:",
|
||||
"cookie_policy.page_title": "Kuki Siyasəti - DocuElevate",
|
||||
"cookie_policy.s1_heading": "Kukilər Nedir",
|
||||
"cookie_policy.s1_p1": "Kukilər, bir veb saytını ziyarət etdiyiniz zaman kompüterinizdə və ya mobil cihazınızda saxlanılan kiçik mətn fayllarıdır. Onlar veb saytların daha səmərəli işləməsi və veb sayt sahiblərinə məlumat vermək üçün geniş istifadə olunur.",
|
||||
"cookie_policy.s2_heading": "Kukiləri Necə İstifadə Edirik",
|
||||
"cookie_policy.s2_li1_body": "Tətbiqi istifadə edərkən sizi tanımaq və sessiyanızı saxlamaq üçün.",
|
||||
"cookie_policy.s2_li1_label": "Doğrulama və Sessiya İdarəsi:",
|
||||
"cookie_policy.s2_p1_post": "aşağıdakı məqsəd üçün:",
|
||||
"cookie_policy.s2_p1_pre": "DocuElevate",
|
||||
"cookie_policy.s2_p1_strong": "yalnız ciddi şəkildə zəruri sessiya kukilərindən istifadə edir",
|
||||
"cookie_policy.s2_p2": "Bu kukilər xidmətimizin düzgün işləməsi üçün məcburidir. Bu kukilər olmadan, veb brauzer sessiyanız zamanı davamlı olaraq daxil olmanız tələb olunacaq.",
|
||||
"cookie_policy.s2_p3": "Bu kukilər xidmətin işləməsi üçün ciddi şəkildə zəruri olduğu üçün, onlar AB ePrivacy İdarəsi (Maddə 5(3)) və ekvivalent milli icrasından əvvəlcədən razılıq tələblərindən azaddır. Biz heç bir seçimlik, analitik, reklam və ya izləmə kukiləri qoymuruq.",
|
||||
"cookie_policy.s3_col_duration": "Müddət",
|
||||
"cookie_policy.s3_col_name": "Ad",
|
||||
"cookie_policy.s3_col_purpose": "Məqsəd",
|
||||
"cookie_policy.s3_col_type": "Növ",
|
||||
"cookie_policy.s3_heading": "Kuki Ətraflı məlumatı",
|
||||
"cookie_policy.s3_row1_duration": "Sessiya (brauzer bağlandıqda və ya çıxış etdikdə silinir)",
|
||||
"cookie_policy.s3_row1_purpose": "Sizin autentifikasiya olunmuş sessiyanızı saxlayır; daxil olmanı işləməsi üçün tələb olunur.",
|
||||
"cookie_policy.s3_row1_type": "Ciddi Zəruri",
|
||||
"cookie_policy.s3_row2_duration": "Davamlı (brauzer lokal Saxlama)",
|
||||
"cookie_policy.s3_row2_purpose": "Kuki bildirişi qəbulunu saxlayır ki, bu da dəfələrlə göstərilmir (lokal Saxlamada saxlanılır, kuki deyil).",
|
||||
"cookie_policy.s3_row2_type": "Ciddi Zəruri",
|
||||
"cookie_policy.s4_heading": "Üçüncü Şəxs Kukiləri Yoxdur",
|
||||
"cookie_policy.s4_p1": "DocuElevate heç bir üçüncü şəxs kukilərini, izləmə kukilərini, reklam kukilərini və ya analitik kukilərini istifadə etmir. Biz sizin məxfiliyinizə hörmət edirik və xidmətimizin işləməsi üçün yalnız minimum kukiləri tətbiq edirik.",
|
||||
"cookie_policy.s4_p2_pre": "Verilənlərinizi necə idarə etdiyimiz haqqında daha çox məlumat üçün xahiş edirik ki, bizim",
|
||||
"cookie_policy.s4_privacy_link": "Məxfilik Bildirişi",
|
||||
"cookie_policy.s5_heading": "Kukiləri İdarə Etmək",
|
||||
"cookie_policy.s5_p1": "Çox sayda veb brauzer kukiləri idarə etməyə imkan verir. Ancaq sessiya kukilərimizi bloklamaq və ya silmək DocuElevate-nin işləməsini dayandırar, çünki istifadəçi autentifikasiyası bu kukilərdən aslıdır.",
|
||||
"cookie_policy.s5_p2": "Bundan əlavə, brauzerinizin lokal Saxlamasında saxlanılan kuki bildirişi qəbulunu istənilən vaxt brauzerinizin inkişaf etdirici alətləri vasitəsilə (Tətbiq \u0000\u0006e Local Storage) silə bilərsiniz.",
|
||||
"cookie_policy.s5_p3_and": "və",
|
||||
"cookie_policy.s5_p3_pre": "Bu Kuki Siyasəti bizim",
|
||||
"cookie_policy.s5_privacy_link": "Məxfilik Bildirişi",
|
||||
"cookie_policy.s5_terms_link": "Xidmət Şərtləri",
|
||||
"credentials.col_action": "Fəaliyyət",
|
||||
"credentials.col_credential": "Kredensial",
|
||||
"credentials.col_source": "Mənbə",
|
||||
@@ -517,6 +839,31 @@
|
||||
"help.workflows_step_5_create": "Saxla – yeni sənədlər bu pipeline ilə avtomatik emal ediləcək.",
|
||||
"help.workflows_typical_steps": "Tipik Addımlar",
|
||||
"help.workflows_what_is": "Pipeline nədir?",
|
||||
"imprint.business_registration_heading": "Biznes Qeydiyyatı",
|
||||
"imprint.business_registration_vat": "ƏDV İdentifikasiya Nömrəsi \u0000\u0006a 27a Dəyərli Artım Vergisi Qanununun tələblərinə uyğun:",
|
||||
"imprint.contact_heading": "Əlaqə Məlumatları",
|
||||
"imprint.dispute_heading": "Onlayn Mübahisə Həlli",
|
||||
"imprint.dispute_p1": "Avropa Komissiyası onlayn mübahisə həlli üçün bir platforma təqdim edir (OS):",
|
||||
"imprint.dispute_p2": "Biz istehlakçı arbitraj idarəsi qarşısında mübahisə həlli prosedurlarında iştirak etməyə istəkli və ya öhdəlikli deyilik.",
|
||||
"imprint.heading": "İzahat",
|
||||
"imprint.legal_copyright": "Bu veb saytdakı bütün məzmun müəllif hüquqları ilə qorunur. Müəllif hüquqları qanunlarının sərhədlərindən kənar istifadələr müvafiq müəllifin və ya yaradıcının yazılı razılığını tələb edir.",
|
||||
"imprint.legal_heading": "Hüquqi Bildirişlər",
|
||||
"imprint.legal_liability": "Diqqətlə məzmun nəzarətinə baxmayaraq, xarici linklərin məzmununa görə heç bir məsuliyyət qəbul etmirik. Bağlı səhifələrin operatorları yalnız öz məzmunlarına görə məsuliyyət daşıyırlar.",
|
||||
"imprint.page_title": "İzahat - DocuElevate",
|
||||
"imprint.policies_cookie_desc": "İstifadə etdiyimiz cookies haqqında məlumat",
|
||||
"imprint.policies_cookie_label": "Cookie Siyasəti",
|
||||
"imprint.policies_heading": "Əlaqədar Siyasətlər",
|
||||
"imprint.policies_intro": "Xidmətimiz aşağıdakı siyasətlərlə tənzimlənir:",
|
||||
"imprint.policies_license_desc": "Proqramımızın necə lisenziyalandığı",
|
||||
"imprint.policies_license_label": "Lisenziya Məlumatları",
|
||||
"imprint.policies_privacy_desc": "Məlumatlarınızı necə idarə edirik",
|
||||
"imprint.policies_privacy_label": "Məxfilik Siyasəti",
|
||||
"imprint.policies_terms_desc": "DocuElevate-dən istifadə qaydaları",
|
||||
"imprint.policies_terms_label": "Xidmət Şərtləri",
|
||||
"imprint.provider_heading": "Xidmət Təchizatı",
|
||||
"imprint.responsible_content_heading": "Məzmun üçün Məsul",
|
||||
"imprint.responsible_content_rstv": "§ 55 Abs. 2 RStV-ə görə:",
|
||||
"imprint.subtitle": "TMG (§ 5) üzrə məlumat (Alman Telemedia Qanunu)",
|
||||
"index.badge_intelligent": "İntellektual Sənəd Emalı",
|
||||
"index.button_browse_files": "Sənədləri Gəz",
|
||||
"index.button_upload": "Yüklə",
|
||||
@@ -728,6 +1075,19 @@
|
||||
"language.tr": "Türkçe",
|
||||
"language.uk": "Ukrayna dili",
|
||||
"language.zh": "Çince",
|
||||
"license.apache_description": "DocuElevate, istifadə etməyə, dəyişdirməyə, yaymağa və layihəyə töhfə verməyə icazə verən, icazə verici açıq mənbəli proqram lisenziyası olan Apache License 2.0 altında paylanır.",
|
||||
"license.apache_heading": "Apache Lisenziyası 2.0",
|
||||
"license.heading": "Lisenziya Məlumatları",
|
||||
"license.page_title": "Lisenziya Məlumatları - DocuElevate",
|
||||
"license.related_about_link": "Haqqında səhifəsi",
|
||||
"license.related_and": "və",
|
||||
"license.related_heading": "Əlaqədar Məlumat",
|
||||
"license.related_p1_post": "DocuElevate xidmətini istifadə etməklə bağlı məlumat üçün.",
|
||||
"license.related_p1_pre": "Bu lisenziya proqramımızın istifadəsini tənzimlədiyinə görə, xahiş edirik, habelə bizim",
|
||||
"license.related_p2_post": ".",
|
||||
"license.related_p2_pre": "DocuElevate haqqında daha ətraflı məlumat üçün, zəhmət olmasa,",
|
||||
"license.related_privacy_link": "Məxfilik Siyasəti",
|
||||
"license.related_terms_link": "Xidmət Şərtləri",
|
||||
"nav.about": "Haqqında",
|
||||
"nav.account_menu": "Hesab menyusu",
|
||||
"nav.account_menu_for": "{name} üçün hesab menyusu",
|
||||
@@ -828,6 +1188,185 @@
|
||||
"pipelines.system_label": "Sistem",
|
||||
"pipelines.system_pipeline_label": "Sistem pipeline-ı (bütün istifadəçilərə görünür)",
|
||||
"pipelines.title": "Emal Pipeline-ları",
|
||||
"privacy.heading": "DocuElevate – Məxfilik Bildirişi",
|
||||
"privacy.last_updated": "Son Yeniləmə:",
|
||||
"privacy.page_title": "Məxfilik Bildirişi - DocuElevate",
|
||||
"privacy.s10_access_body": "Sizin haqqınızda saxladığımız şəxsi məlumatların surətini tələb edə bilərsiniz.",
|
||||
"privacy.s10_access_label": "Giriş Hüququ (Maddə 15):",
|
||||
"privacy.s10_complaint_body": "Siz öz milli Məlumatların Qorunması İdarənizlə (DPA) şikayət etmə hüququna maliksiniz. Almaniyada: Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI). Birləşmiş Krallıqda: Information Commissioner's Office (ICO). İsveçrədə: Federal Data Protection and Information Commissioner (FDPIC).",
|
||||
"privacy.s10_complaint_label": "Şikayət Etmə Hüququ:",
|
||||
"privacy.s10_contact": "Yuxarıda göstərilən hüquqlardan hər hansı birini həyata keçirmək üçün bizimlə əlaqə saxlayın",
|
||||
"privacy.s10_erasure_body": "Şəxsi məlumatlarınızı saxlamaq üçün bizim tərəfimizdən üstün müvafiq səbəb olmadıqda, onların silinməsini tələb edə bilərsiniz.",
|
||||
"privacy.s10_erasure_label": "Silinmə hüququ (Maddə 17):",
|
||||
"privacy.s10_heading": "10. Sizin Hüquqlarınız (AB / EEA / Birləşmiş Krallıq / İsveçrə)",
|
||||
"privacy.s10_object_body": "Həqiqi maraqlara əsaslanan emalına hər zaman etiraz edə bilərsiniz.",
|
||||
"privacy.s10_object_label": "Etiraz hüququ (Maddə 21):",
|
||||
"privacy.s10_p1": "GDPR (və Birləşmiş Krallığın GDPR / İsveçrənin nFADP ekvivalentləri) çərçivəsində aşağıdakı hüquqlara sahibsiniz:",
|
||||
"privacy.s10_portability_body": "Məlumatlarınızı strukturlaşdırılmış, geniş istifadə olunan, maşın oxuna bilən formatda tələb edə bilərsiniz.",
|
||||
"privacy.s10_portability_label": "Məlumatın Köçürülmə Hüququ (Maddə 20):",
|
||||
"privacy.s10_rectification_body": "Düzgün olmayan və ya tamamlanmamış şəxsi məlumatların düzəldilməsini tələb edə bilərsiniz.",
|
||||
"privacy.s10_rectification_label": "Düzəliş Huququ (Maddə 16):",
|
||||
"privacy.s10_response": "Biz bir təqvim ayı içində cavab verəcəyik (müraciətlərin mürəkkəb olduğu hallarda iki əlavə ay ilə uzadıla bilər).",
|
||||
"privacy.s10_restriction_body": "Müəyyən hallarda məlumatlarınızın emalını müvəqqəti dayandırmağı tələb edə bilərsiniz.",
|
||||
"privacy.s10_restriction_label": "Məhdudlaşdırma Hüququ (Maddə 18):",
|
||||
"privacy.s10_withdraw_body": "Emal razılığa əsaslandıqda, əvvəlki emalın qanuniliyinə təsir etmədən istənilən vaxt razılığı geri ala bilərsiniz.",
|
||||
"privacy.s10_withdraw_label": "Razılığı Geri Çəkmək Hüququ:",
|
||||
"privacy.s11_categories_body": "Tanıma ölçüləri (ad, e-poçt), hesabın autentifikasiyası üçün tokenlər və yüklədiyiniz sənəd meta məlumatları.",
|
||||
"privacy.s11_categories_label": "Toplanmış şəxsi məlumat kateqoriyaları:",
|
||||
"privacy.s11_contact": "Doğrulanabilir istehlakçı tələbi təqdim etmək üçün bizimlə əlaqə saxlayın",
|
||||
"privacy.s11_correct_body": "Düzgün olmayan şəxsi məlumatların düzəldilməsini tələb edə bilərsiniz.",
|
||||
"privacy.s11_correct_label": "Düzəltmə Hüququ:",
|
||||
"privacy.s11_delete_body": "Topladığımız şəxsi məlumatların silinməsini tələb edə bilərsiniz, müəyyən istisnalar mövcuddur.",
|
||||
"privacy.s11_delete_label": "Silinmə Hüququ:",
|
||||
"privacy.s11_heading": "11. Əlavə Hüquqlar - Birləşmiş Ştatlar (CCPA / CPRA)",
|
||||
"privacy.s11_know_body": "Sizinlə bağlı topladığımız şəxsi məlumatların kateqoriyaları və spesifik hissələrinin açıqlanmasını tələb edə bilərsiniz.",
|
||||
"privacy.s11_know_label": "Məlumat Alma Hüququ:",
|
||||
"privacy.s11_limit_body": "Biz xidməti təmin etmək üçün lazım olandan artıq həssas şəxsi məlumatlardan istifadə etmirik.",
|
||||
"privacy.s11_limit_label": "Həssas şəxsi məlumatların istifadəsini məhdudlaşdırma hüququ:",
|
||||
"privacy.s11_nondiscrim_body": "Bu hüquqlardan istifadə etdiyiniz üçün sizə qarşı ayrımcılıq etməyəcəyik.",
|
||||
"privacy.s11_nondiscrim_label": "Ayrımcılıqsızlıq:",
|
||||
"privacy.s11_optout_body": "Biz CCPA/CPRA tərəfindən təyin olunan şəxsi məlumatları satmırıq və paylaşmırıq. İstədiyiniz zaman bizimlə əlaqə saxlamaqla bunu təsdiq edə bilərsiniz; opt-out mexanizmi tələb olunmur.",
|
||||
"privacy.s11_optout_label": "Satışdan / Paylaşmadan Çıxma Hüququ:",
|
||||
"privacy.s11_p1": "Əgər siz Kaliforniya və ya müvafiq məxfilik qanunvericiliyi olan başqa bir ABŞ ştatının sakiniysanız (Virginia VCDPA, Colorado CPA, Connecticut CTDPA, Utah UCPA daxil olmaqla), aşağıdakı əlavə açıqlamalar tətbiq olunur:",
|
||||
"privacy.s11_purpose_body": "DocuElevate xidmətini təmin etmək, inkişaf etdirmək və təhlükəsizliyini artırmaq. Biz şəxsi məlumatları kontekst arasında davranış reklamı üçün satmırıq və ya paylaşmırıq.",
|
||||
"privacy.s11_purpose_label": "Toplama məqsədi:",
|
||||
"privacy.s11_response": "Biz 45 gün ərzində cavab verəcəyik (məqbul olduqda əlavə 45 günlə uzadıla bilər).",
|
||||
"privacy.s12_access_body": "Şəxsi məlumatlarınıza və onların necə istifadə olunduğu və ya açıqlanması haqqında məlumat tələb edə bilərsiniz.",
|
||||
"privacy.s12_access_label": "Giriş Hüququ:",
|
||||
"privacy.s12_contact": "Birbaşa məxfilik şikayətlərini bizim Məxfilik Mütəxəssisinə göndərin",
|
||||
"privacy.s12_contact_post": ", ya da Kanadanın Məxfilik Komissiyasının ofisinə.",
|
||||
"privacy.s12_correction_body": "Şəxsi məlumatlarınızın dəqiqliyinə və ya tamlığına etiraz edə və düzəliş tələb edə bilərsiniz.",
|
||||
"privacy.s12_correction_label": "Düzəliş Hüququ:",
|
||||
"privacy.s12_heading": "12. Əlavə Hüquqlar – Kanada (PIPEDA / Québec Qanunu 25)",
|
||||
"privacy.s12_li1": "Şəxsi məlumatları yalnız sizin bilginiz və razılığınızla, ya da qanunla icazə verilən halda toplamaq, istifadə etmək və açıqlamaq.",
|
||||
"privacy.s12_p1": "Əgər siz Kanadadasınızsa, aşağıdakılar Şəxsi Məlumatların Qorunması və Elektron Sənədlər Qanunu (PIPEDA) və müvafiq əyalət qanunları (Québec Qanunu 25 / Bill 64 daxil olmaqla) çərçivəsində tətbiq olunur:",
|
||||
"privacy.s12_quebec_body": "Qanun 25-ə əsasən, sizdə məlumatların daşınması hüququ (sentyabr 2023-dən etibarən qüvvədədir) və şəxsi məlumatların onlayn yayıldığı hallarda de-indeksasiya hüququ da daxil olmaqla əlavə hüquqlar var.",
|
||||
"privacy.s12_quebec_label": "Québec sakinləri:",
|
||||
"privacy.s12_withdraw_body": "Hüquqi və ya müqavilə məhdudiyyətlərinə tabedir, şəxsi məlumatlarınızı toplamaq, istifadə etmək və ya açıqlamaq üçün razılığınızı məqbul nəzarət altında geri ala bilərsiniz.",
|
||||
"privacy.s12_withdraw_label": "Razılığı Geri Çəkmək Hüququ:",
|
||||
"privacy.s13_brazil_body": "Əgər siz Braziliyada yerləşirsinizsə, LGPD altında aşağıdakı hüquqlara maliksiniz:",
|
||||
"privacy.s13_brazil_label": "Braziliya (LGPD – Ümumi Məlumatların Qorunması Qanunu, Qanun 13.709/2018):",
|
||||
"privacy.s13_contact": "Əlaqə:",
|
||||
"privacy.s13_heading": "13. Əlavə Hüquqlar – Latın Amerikası (LGPD və Digərləri)",
|
||||
"privacy.s13_li1": "Emalın mövcudluğunun təsdiqi və məlumatlarınıza çıxış.",
|
||||
"privacy.s13_li2": "Tam olmayan, dəqiq olmayan və ya köhnəlmiş məlumatların düzəlişi.",
|
||||
"privacy.s13_li3": "Lazımsız və ya artıq məlumatların anonimləşdirilməsi, bloklanması və ya silinməsi.",
|
||||
"privacy.s13_li4": "Məlumatlarınızın başqa bir xidmət və ya məhsul təminatçısına daşınması.",
|
||||
"privacy.s13_li5": "Razılığınızla emal olunmuş şəxsi məlumatların silinməsi.",
|
||||
"privacy.s13_li6": "Məlumatlarınızın paylaşılmış olduğu qurumlar haqqında məlumat.",
|
||||
"privacy.s13_li7": "Razılıq verməməyin imkanının və imtinanın nəticələrinin məlumatı.",
|
||||
"privacy.s13_li8": "Razılığın geri götürülməsi.",
|
||||
"privacy.s13_other_body": "Argentinalı (PDPA), Meksikalı (LFPDPPP), Çili, Kolumbiya (Ley 1581) və digər həddən artıq müvafiq gizlilik qanunlarını da tanıyırıq. Bu yurisdiksiyalarda olan istifadəçilər, milli qanunlarına uyğun olaraq bərabər hüquqları yerinə yetirmək üçün bizimlə əlaqə saxlaya bilərlər.",
|
||||
"privacy.s13_other_label": "Digər Latın Amerika Ölkləri:",
|
||||
"privacy.s14_apj_body": "Bu yurisdiksiyalardakı sakinlərə onların müvafiq milli qanunları ilə təqdim olunan məlumatların qorunması hüquqlarını tanıyırıq. Hüquqlarınızı yerinə yetirmək üçün bizimlə əlaqə saxlayın.",
|
||||
"privacy.s14_apj_label": "Digər APJ bazarları (Singapur PDPA, Yeni Zelandiya Gizlilik Qanunu, Hindistan DPDP Qanunu):",
|
||||
"privacy.s14_australia_body": "Avstraliya sakinləri şəxsi məlumatlarına çıxış və düzəliş tələb edə bilərlər. Çıxış tələblərinə 30 gün ərzində cavab verəcəyik. Şikayətlər Avstraliya Məlumat Komissarı Ofisinə (OAIC) təqdim edilə bilər.",
|
||||
"privacy.s14_australia_label": "Avstraliya (Gizlilik Qanunu 1988 və Avstraliya Gizlilik Prinsipləri):",
|
||||
"privacy.s14_contact": "Əlaqə:",
|
||||
"privacy.s14_heading": "14. Əlavə Hüquqlar – Asiya-Sakit Okeanı və Yaponiya",
|
||||
"privacy.s14_japan_body": "Yaponiyadan olan sakinlərdən bizdə saxlanılan şəxsi məlumatlarının açıqlanması, düzəlişi, əlavə edilməsi və ya silinməsi, istifadə hüququnun dayandırılması, silinməsi və ya üçüncü tərəf tərəfindən təqdimatın dayandırılması tələbi edilə bilər. Üçüncü tərəf açıqlamaları sizin əvvəlcədən razılığınızı tələb edir, qanunla icazə verilmədikcə.",
|
||||
"privacy.s14_japan_label": "Yaponiya (APPI – Şəxsi Məlumatların Qorunması Qanunu):",
|
||||
"privacy.s14_korea_body": "Koreyada yaşayan sakinlərdən iştirakı, düzəlişi, silinməsi və emalın dayandırılmasını tələb edə bilərlər. Biz koreyalı sakinlərin şəxsi məlumatlarını PIPA qanununa uyğun olaraq emal edirik.",
|
||||
"privacy.s14_korea_label": "Cənubi Koreya (PIPA – Şəxsi Məlumatların Qorunması Qanunu):",
|
||||
"privacy.s15_contact": "Əlaqə:",
|
||||
"privacy.s15_heading": "15. Əlavə Hüquqlar – Ukrayna",
|
||||
"privacy.s15_p1": "Ukraynada yaşayan istifadəçilər Ukrayna “Şəxsi Məlumatların Qorunması” qanunu (No. 2297-VI) ilə qorunur. Sizin hüquqlarınıza şəxsi məlumatlarınıza giriş, düzəliş, bloklama və silinmə hüququ, eyni zamanda emal prosesinə etiraz etmə hüququ daxildir.",
|
||||
"privacy.s16_cookies_link": "Cookie Siyasəti",
|
||||
"privacy.s16_heading": "16. Bu Məlumat Bildirişi Üzrə Yeniliklər",
|
||||
"privacy.s16_license_link": "Lisenziya Məlumatı",
|
||||
"privacy.s16_p1": "Bu bildirişi, təcrübələrimizdə və ya müvafiq qanunlarda baş verən dəyişiklikləri əks etdirmək üçün zaman-zaman yeniləyə bilərik. Bu səhifənin üstündəki “Son Yenilənmə” tarixi bildirişin son dəfə nə vaxt dəyişdirildiyini göstərir. Dəyişikliklər əhəmiyyətli olduqda, istifadəçiləri tətbiqat içi bildiriş və ya uyğun olduqda e-poçt vasitəsilə məlumatlandıracağıq.",
|
||||
"privacy.s16_p2_pre": "Əgər bu Məlumat Bildirişi və ya şəxsi məlumatlarınızla bağlı suallarınız və ya narahatlıqlarınız varsa, zəhmət olmasa bizimlə aşağıdakı ünvanla əlaqə saxlayın",
|
||||
"privacy.s16_p3_pre": "Zəhmət olmasa bizim",
|
||||
"privacy.s16_terms_link": "Xidmət Şərtləri",
|
||||
"privacy.s1_address": "Alter Steinweg 3, 20459 Hamburg, Almaniya",
|
||||
"privacy.s1_company": "Christian Louis IT Beratung",
|
||||
"privacy.s1_contact_label": "Əlaqə E-poçtu:",
|
||||
"privacy.s1_heading": "1. Məlumat Nümayəndəsi",
|
||||
"privacy.s1_p1": "Avropa İttifaqı Ümumi Məlumatların Qorunması Qaydası (GDPR) və dünyada ekvivalent gizlilik qanunları çərçivəsində sizin şəxsi məlumatlarınızı emal etməkdən məsul nümayəndə:",
|
||||
"privacy.s1_p3": "Gizlilik ilə bağlı bütün tələblər (giriş, silinmə, düzəliş, çıxış və ya şikayətlər) üçün, zəhmət olmasa, yuxarıdakı e-poçt ünvanı ilə bizimlə əlaqə saxlayın. 30 gün ərzində (və ya müvafiq qanunla göstərilən dövr ərzində) cavab verəcəyik.",
|
||||
"privacy.s2_heading": "2. Bu Məlumat Bildirişi üzrə Əhatə",
|
||||
"privacy.s2_p1_pre": "Bu bildiriş DocuElevate veb tətbiqi üçün tətbiq olunur, yerləşdiyi yer:",
|
||||
"privacy.s2_p2": "Bu, Avropa İttifaqı (EU), Avropa İqtisadi Məkanı (EEA), Almaniya, Birləşmiş Krallıq (UK), İsveçrə, Ukrayna, Amerika Birləşmiş Ştatları (US), Kanada, Latın Amerikası (Latam), Asiya-Sakit Okeanı və Yaponiya daxil olmaqla bütün istifadəçiləri əhatə edir. Bazar spesifik açıqlamalar aşağıda xüsusi bölmələrdə təqdim olunur.",
|
||||
"privacy.s3_audit_body": "Xidmətin bütünlüyünü və təhlükəsizliyini təmin etmək üçün məhdud audit qeydlərini (hərəkət növü, vaxt möhürü, istifadəçi identifikatoru) saxlayırıq. Bu qeydlər sənəd məzmununu əhatə etmir.",
|
||||
"privacy.s3_audit_label": "Audit Qeydləri:",
|
||||
"privacy.s3_auth_body": "Biz OAuth 2.0 (Google, Dropbox, Microsoft/OneDrive) və əlavə yerli avtorizasiyadan istifadə edirik. OAuth vasitəsilə, adınızı, e-poçt ünvanınızı və profil şəklinizi ala bilərik.",
|
||||
"privacy.s3_auth_label": "İstifadəçi Avtorizasiyası:",
|
||||
"privacy.s3_doc_body": "Yüklədiyiniz sənədlər OCR (optik sim tanıma), metadata çıxarılması və seçdiyiniz bulud təminatçısına saxlanılması üçün emal edilir. Sənəd məzmunu yalnız sizin başladığınız məqsəd üçün emal edilir və operacional olaraq zəruri olan müddətdən daha uzun müddət saxlanılmır.",
|
||||
"privacy.s3_doc_label": "Sənəd Emalı:",
|
||||
"privacy.s3_heading": "3. Məlumat Toplanması və Məqsədləri",
|
||||
"privacy.s3_legal_body": "Emal etmək üçün bizim əsas hüquqi əsaslarımız bunlardır:",
|
||||
"privacy.s3_legal_label": "Hüquqi Səbəb (GDPR Maddəsi 6):",
|
||||
"privacy.s3_li1": "(1)(b) Müqavilənin yerinə yetirilməsi: tələb etdiyiniz DocuElevate xidmətini təmin etmək.",
|
||||
"privacy.s3_li2": "(1)(c) Hüquqi öhdəlik: müvafiq qanun və qaydalara riayət etmək.",
|
||||
"privacy.s3_li3": "(1)(f) Qanuni maraqlar: xidmətin təhlükəsizliyini təmin etmək və fırıldaqçılığın qarşısını almaq.",
|
||||
"privacy.s4_heading": "4. Məlumatın Azaldılması və Məqsəd Məhdudlaşdırılması",
|
||||
"privacy.s4_li1": "Xidməti idarə etmək üçün yalnız minimum şəxsi məlumat toplamaqdayıq.",
|
||||
"privacy.s4_li2": "Sənəd məzmunu yalnız sizin başladığınız məqsəd üçün (OCR, saxlama, metadata çıxarılması) emal edilir. Sizin sənədlərinizi AI modellərini təlim etmək üçün və ya hər hansı ikinci dərəcəli məqsəd üçün istifadə etmirik.",
|
||||
"privacy.s4_li3": "Reklam, davranışsal izləmə və ya profil yaratma həyata keçirilmir.",
|
||||
"privacy.s4_li4": "İzləmə kukiləri və ya analitik skriptlər yüklənmir.",
|
||||
"privacy.s4_li5": "Üçüncü tərəf AI xidmətləri (məsələn, OpenAI, Azure Document Intelligence) yalnız siz sənəd emalına başladıqda çağırılır və məlumatlar data emalı razılaşmaları çərçivəsində ötürülür.",
|
||||
"privacy.s4_p1": "DocuElevate, məlumatların minimalizasiyasını əsas prinsip olaraq nəzərdə tutur (GDPR Art. 5(1)(c)):",
|
||||
"privacy.s5_cookie_link": "Kukilər Siyasəti",
|
||||
"privacy.s5_heading": "5. Kukilərin və Baxış Texnologiyalarının İstifadəsi",
|
||||
"privacy.s5_p1_post": "sizin autentifikasiya edilmiş sessiyanızı saxlamaq üçün. Bu kukilər xidmətin işləməsi üçün vacibdir və AB ePrivacy Direktivi (Art. 5(3)) və müvafiq milli qanunlar altında əvvəlcədən razılıq tələblərindən azaddır.",
|
||||
"privacy.s5_p1_pre": "DocuElevate istifadə edir",
|
||||
"privacy.s5_p1_strong": "yalnız ciddi şəkildə zəruri sessiya kukilərini",
|
||||
"privacy.s5_p2_body": "analitik kukilər, reklam kukilər, izləmə pikseləri və ya sizin razılığınızı tələb edən hər hansı bir üçüncü tərəf kukilərini.",
|
||||
"privacy.s5_p2_label": "Biz istifadə etmirik:",
|
||||
"privacy.s5_p3_pre": "Qoyduğumuz kukilərlə, onların adları, müddəti və məqsədi haqqında tam məlumat üçün, zəhmət olmasa bizim",
|
||||
"privacy.s6_ai_body": "OCR və ya AI əsaslı metadata çıxarmaq üçün başladığınızda, sənəd məlumatlarınız siz və ya administratorunuz tərəfindən tənzimlənmiş AI xidmətinə ötürülür. Bu ötürmə müvafiq təchizatçı ilə data emalı razılaşması ilə tənzimlənir.",
|
||||
"privacy.s6_ai_label": "AI Emal Xidmətləri (OpenAI, Azure Document Intelligence, digərləri):",
|
||||
"privacy.s6_heading": "6. Üçüncü Tərəf Xidmətləri",
|
||||
"privacy.s6_no_sale_body": "Biz sizin şəxsi məlumatlarınızı üçüncü tərəflərə reklam, marketinq və ya xidmətin təminatı ilə ilişkiləndirilməyən hər hansı bir məqsəd üçün satmır, icarəyə vermir və ya bölüşmürük.",
|
||||
"privacy.s6_no_sale_label": "Reklam üçün Satış və ya Paylaşma yoxdur:",
|
||||
"privacy.s6_oauth_body": "OAuth vasitəsilə autentifikasiya etməyə qərar verdiyiniz zaman, müvafiq təminatçı sizin etimadnamələrinizi emal edir və bizə məhdud profil məlumatlarını paylaşa bilər. Bu təminatçılar öz şəxsi məlumatlar siyasətlərini saxlayırlar.",
|
||||
"privacy.s6_oauth_label": "OAuth Təminatçıları (Google, Dropbox, Microsoft):",
|
||||
"privacy.s6_storage_body": "Sənədlər sizin tənzimlədiyiniz bulud təminatçısında saxlanılır. Tənzimlənmiş etimadnamələriniz tətbiqetmə məlumat bazasında şifrlənmiş olaraq saxlanır və yalnız sizin tələb etdiyiniz saxlama əməliyyatlarını yerinə yetirmək üçün istifadə olunur.",
|
||||
"privacy.s6_storage_label": "Bulud Saxlama Təminatçıları (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):",
|
||||
"privacy.s7_adequacy_body": "Avropa Komissiyası bərabər qoruma səviyyəsini tanıdığı yerlərdə (məsələn, Birləşmiş Krallıq, İsveçrə, Kanada (ticarət təşkilatları), Yaponiya, Cənubi Koreya).",
|
||||
"privacy.s7_adequacy_label": "Kafilik Qərarları",
|
||||
"privacy.s7_contact": "Müvafiq qoruma tədbirlərinin surətini istəmək üçün bizimlə əlaqə saxlayın:",
|
||||
"privacy.s7_heading": "7. Beynəlxalq Məlumat Transferləri",
|
||||
"privacy.s7_idta_body": "Brexit sonrası UK-dən köçürmələr üçün.",
|
||||
"privacy.s7_idta_label": "Birləşmiş Krallıq Beynəlxalq Məlumat Transferi Razılaşmaları (IDTAs)",
|
||||
"privacy.s7_p1": "DocuElevate standart olaraq Avropa İttifaqında / EEA-da yerləşdirilir. Şəxsi məlumatlar EEA-dan kənara köçürüldükdə (məsələn, ABŞ-da yerləşən AI xidmət təminatçılarına, məsələn, OpenAI) biz müvafiq qoruma tədbirlərinə əsaslanırıq:",
|
||||
"privacy.s7_scc_body": "Üçüncü ölkələrdə emalçılar və idarəçilər üçün Avropa Komissiyası tərəfindən qəbul edilən (2021/914/EU) müdafiə tədbirləri.",
|
||||
"privacy.s7_scc_label": "Standart Müqavilə Maddələri (SCCs)",
|
||||
"privacy.s8_audit_body": "Təhlükəsizlik və uyğunluq məqsədilə 90 günə qədər saxlanılır.",
|
||||
"privacy.s8_audit_label": "Audit qeydləri:",
|
||||
"privacy.s8_contact": "Hesabınızın və bütün bağlı şəxsi məlumatların silinməsini tələb etmək üçün, zəhmət olmasa bizimlə əlaqə saxlayın:",
|
||||
"privacy.s8_files_body": "Xidmətin istifadəsi müddətində saxlanılır. Tətbiq vasitəsilə istənilən zaman fərdi faylları silə bilərsiniz.",
|
||||
"privacy.s8_files_label": "Fayl qeydləri və metadata:",
|
||||
"privacy.s8_heading": "8. Məlumatın Saxlanması",
|
||||
"privacy.s8_oauth_body": "Şifrlənmiş formada saxlanılır və hər zaman sizin OAuth təminatçınız vasitəsilə ləğv edilə bilər.",
|
||||
"privacy.s8_oauth_label": "OAuth tokenləri:",
|
||||
"privacy.s8_p1": "Biz şəxsi məlumatları yalnız DocuElevate xidmətini təmin etmək və ya hüquqi öhdəliklərlə uyğunlaşmaq üçün lazım olan müddət qədər saxlayırıq:",
|
||||
"privacy.s8_session_body": "Siz çıxış etdikdə və ya sessiya vaxtı bitdikdə silinir.",
|
||||
"privacy.s8_session_label": "Seans məlumatları:",
|
||||
"privacy.s9_heading": "9. Məlumat Təhlükəsizliyi",
|
||||
"privacy.s9_li1": "Şəxsiyyət və həssas konfiqurasiyanın təhlükəsizliyi üçün şifrələmə.",
|
||||
"privacy.s9_li2": "Bütün ünsiyyətlər üçün Transport Layer Security (TLS/HTTPS).",
|
||||
"privacy.s9_li3": "Şəxsi məlumatlara girişin məhdudlaşdırılması üçün rol əsaslı erişim idarəetməsi.",
|
||||
"privacy.s9_li4": "Müntəzəm təhlükəsizlik auditləri və asılılıqların zəiflik skanları.",
|
||||
"privacy.s9_li5": "Bütün vəziyyət dəyişdirici sorğular üçün CSRF müdafiəsi.",
|
||||
"privacy.s9_p1": "Şəxsi məlumatlarınızı qorumaq üçün müvafiq texniki və təşkilati tədbirləri (TOM-lar) həyata keçiririk, bunlara daxildir:",
|
||||
"privacy.toc_1": "Məlumat İdarəçisi",
|
||||
"privacy.toc_10": "Sizin Haqlarınız (AB / EEA / Birləşmiş Krallıq / İsveçrə)",
|
||||
"privacy.toc_11": "Əlavə Haqlar – Amerika Birləşmiş Ştatları (CCPA/CPRA)",
|
||||
"privacy.toc_12": "Əlavə Haqlar – Kanada (PIPEDA / Qanun 25)",
|
||||
"privacy.toc_13": "Əlavə Haqlar – Latın Amerikası (LGPD və digərləri)",
|
||||
"privacy.toc_14": "Əlavə Haqlar – Asiya-Sakit Okean bölgəsi və Yaponiya",
|
||||
"privacy.toc_15": "Əlavə Haqlar – Ukrayna",
|
||||
"privacy.toc_16": "Bu Məxfilik Bildirişi üçün Yeniləmələr",
|
||||
"privacy.toc_2": "Bu Məxfilik Bildirişinin Əhatəsi",
|
||||
"privacy.toc_3": "Məlumat Toplama və Məqsədlər",
|
||||
"privacy.toc_4": "Məlumat Minimallaşdırılması və Məqsəd Məhdudlaşdırması",
|
||||
"privacy.toc_5": "Çərəzlər və Oxşar Texnologiyalardan İstifadə",
|
||||
"privacy.toc_6": "Üçüncü Tərəf Xidmətləri",
|
||||
"privacy.toc_7": "Beynəlxalq Məlumat Transferləri",
|
||||
"privacy.toc_8": "Məlumatın Saxlanması",
|
||||
"privacy.toc_9": "Məlumat Təhlükəsizliyi",
|
||||
"privacy.toc_heading": "Mündəricat",
|
||||
"profile.avatar_alt": "Profil şəkliniz",
|
||||
"profile.avatar_heading": "Profil Şəkli",
|
||||
"profile.avatar_remove": "Xüsusi avatarı sil",
|
||||
@@ -838,6 +1377,9 @@
|
||||
"profile.contact_email_label": "Əlaqə / Bildiriş E-poçtu",
|
||||
"profile.contact_email_placeholder": "you@example.com",
|
||||
"profile.current_password": "Mövcud Şifrə",
|
||||
"profile.default_document_language_auto": "Sistem standartını istifadə et",
|
||||
"profile.default_document_language_hint": "Başqa dillərdə olan sənədlər avtomatik olaraq bu dilə tərcümə olunur. Sistemin standartını (İngilis dili) istifadə etmək üçün boş qoyun.",
|
||||
"profile.default_document_language_label": "Standart Sənəd Dili",
|
||||
"profile.dismiss": "İmtina et",
|
||||
"profile.display_name_hint": "Hesab istifadəçi adınızı və ya e-poçtunuzu istifadə etmək üçün boş saxlayın.",
|
||||
"profile.display_name_label": "Ekran Adı",
|
||||
@@ -1040,14 +1582,66 @@
|
||||
"similarity.subtitle": "Yüksək semantik oxşarlığa malik sənəd cütləri, balına görə sıralanmışdır.",
|
||||
"similarity.trigger_aria": "İnterpolasiya hesablanmasını bütün interpolasiyaları olmayan sənədlər üçün işə salın",
|
||||
"similarity.trigger_now": "indi işə sal",
|
||||
"status.active": "Aktiv",
|
||||
"status.ai_empty_response": "(boş)",
|
||||
"status.ai_extraction_desc": "Aşağıda bir sənədin düz mətn məzmununu yapışdırın və konfiqurasiya edilmiş AI təminatçısına işlətmək üçün xam cavabı, çıxarılmış JSON-u və etiketləri yoxlayın.",
|
||||
"status.ai_extraction_failed": "AI Çıxarılması Uğursuz Oldu",
|
||||
"status.ai_extraction_label": "Sənəd Mətni",
|
||||
"status.ai_extraction_placeholder": "Sənədinizin düz mətn məzmununu buraya yapışdırın\n\n",
|
||||
"status.ai_extraction_title": "AI Çıxarılması Testi",
|
||||
"status.app_version": "Tətbiq Versiyası",
|
||||
"status.as_account": "kimi",
|
||||
"status.auth_required": "Təsdiq Edilməsi Zəruridir",
|
||||
"status.build_date": "Yaradılma Tarixi",
|
||||
"status.config_settings": "Konfiqurasiya Parametrləri",
|
||||
"status.config_settings_desc": "Daha ətraflı konfiqurasiya parametrləri və mühit dəyişənləri üçün, parametrlər səhifəsinə baxın.",
|
||||
"status.configure_now": "İndi Konfiqurasiya Et",
|
||||
"status.configured": "Konfiqurasiya Olundu",
|
||||
"status.connection_error": "Bağlantı Xətası",
|
||||
"status.connection_test_failed": "Bağlantı Testi Uğursuz Oldu",
|
||||
"status.connection_test_successful": "Bağlantı Testi Uğurlu",
|
||||
"status.container_id": "İç container ID",
|
||||
"status.container_started": "Konteyner Başladı",
|
||||
"status.dashboard_subtitle": "Bu tablosu konfiqurasiya olunmuş bütün inteqrasiya və hədəflərin statusunu göstərir.",
|
||||
"status.debug_mode": "Debug Məhz",
|
||||
"status.error_running_extraction": "Çıxarma zamanı xəta: ",
|
||||
"status.error_testing_connection": "Bağlantı testi zamanı xəta: ",
|
||||
"status.error_testing_notifications": "Bildirişləri test edərkən xəta: ",
|
||||
"status.extracted_tags": "Çıxarılmış Etiketlər",
|
||||
"status.git_commit": "Git Beyannaməsi",
|
||||
"status.inactive": "Aktiv deyil",
|
||||
"status.json_parse_issue": "JSON ayırma problemi: ",
|
||||
"status.last_check": "Son Yoxlama",
|
||||
"status.manage": "İdarə et",
|
||||
"status.modal_default_message": "Əməliyyat uğurla tamamlandı.",
|
||||
"status.modal_default_title": "Uğur",
|
||||
"status.no_details": "Məlumat yoxdur",
|
||||
"status.not_configured": "Konfiqurasiya edilməyib",
|
||||
"status.notification_config_missing": "Bildiriş Konfiqurasiyası Yoxdur",
|
||||
"status.open": "Açıq",
|
||||
"status.page_title": "Sistem Statusu",
|
||||
"status.parsed_json_label": "Ayırılmış JSON",
|
||||
"status.provider_config_details": "{name} Konfiqurasiya Təfərrüatları",
|
||||
"status.provider_details": "Təchizatçı Təfərrüatları",
|
||||
"status.raw_llm_response": "Xam LLM Cavabı",
|
||||
"status.run_extraction": "Çıxarmanı İşə Sal",
|
||||
"status.running": "İşləyir\u001e",
|
||||
"status.sending": "Göndərilir...",
|
||||
"status.setting_label": "Ayar",
|
||||
"status.test_connection": "Bağlantını Test Et",
|
||||
"status.test_extraction": "Çıxarmanı Test Et",
|
||||
"status.test_failed": "Test Uğursuz Oldu",
|
||||
"status.test_notification_failed": "Test Bildirişi Uğursuz Oldu",
|
||||
"status.test_notification_sent": "Test Bildirişi Göndərildi",
|
||||
"status.test_notifications": "Test Bildirişləri",
|
||||
"status.test_provider": "Test {name}",
|
||||
"status.test_successful": "Test Uğurlu",
|
||||
"status.testing": "Test edilir...",
|
||||
"status.token_expired": "Token'iniz müddəti bitib ya da etibarsızdır. Zəhmət olmasa bu bağlantını yenidən konfiqurasiya edin.",
|
||||
"status.token_valid_for": "Token'in etibarlı olduğu müddət:",
|
||||
"status.value_label": "Dəyər",
|
||||
"status.view_config": "Ətraflı Konfiqurasiyanı Gör",
|
||||
"status.view_details": "Təfərrüatları Gör",
|
||||
"subscription.available_plans_heading": "Mövcud Planlar",
|
||||
"subscription.back_to_dashboard": "İdarəetmə panelinə geri",
|
||||
"subscription.cancel_pending": "Dəyişiklikləri ləğv et",
|
||||
@@ -1083,6 +1677,51 @@
|
||||
"subscription.upgrade_info": "Yüksəltmələr dərhal qüvvəyə minir. Aşağı salmalar mövcud faktura dövrünüzün sonunda təqvimi var.",
|
||||
"subscription.upgrade_to_prefix": "Yüksəlt",
|
||||
"subscription.usage_heading": "İstifadə",
|
||||
"terms.cookie_link": "Cookie Siyasəti",
|
||||
"terms.heading": "Xidmət Şartları",
|
||||
"terms.last_updated": "Son Yeniləmə:",
|
||||
"terms.license_link": "Lisenziya Məlumatı",
|
||||
"terms.page_title": "Xidmət Şartları - DocuElevate",
|
||||
"terms.privacy_link": "Məxfiliyə Siyasəti",
|
||||
"terms.s1_heading": "1. Şartların Qəbulu",
|
||||
"terms.s1_p1": "DocuElevate-ə daxil olmaqla və ya onu istifadə etməklə, bu Xidmət Şartlarına riayət etməyi qəbul edirsiniz. Əgər bu şərtlərə razı deyilsinizsə, xahiş edirik bu xidmətdən istifadə etməyin.",
|
||||
"terms.s2_heading": "2. Xidmətin Təsviri",
|
||||
"terms.s2_p1": "DocuElevate sənəd emalı, OCR, meta məlumatların çıxarılması və saxlama xidmətləri təqdim edir. Hər hansı bir aspekti istənilən vaxt dəyişdirmək və ya dayandırmaq hüququnu özümüzdə saxlayırıq.",
|
||||
"terms.s3_heading": "3. İstifadəçi Məsuliyyətləri",
|
||||
"terms.s3_li1": "DocuElevate-ə yüklədiyiniz bütün məzmun",
|
||||
"terms.s3_li2": "Sənədləri yükləmək və işlətmək üçün düzgün hüquqlara sahib olduğunuzu təmin etmək",
|
||||
"terms.s3_li3": "Hesab etimadnamələrinizin məxfiliyini qorumaq",
|
||||
"terms.s3_li4": "Hesabınız altında baş verən hər hansı fəaliyyət",
|
||||
"terms.s3_p1": "Siz məsuliyyət daşıyırsınız:",
|
||||
"terms.s3_p2_and": "və",
|
||||
"terms.s3_p2_post": ".",
|
||||
"terms.s3_p2_pre": "Xidmətimizdən istifadə etməklə, siz bizim",
|
||||
"terms.s4_heading": "4. İntellektual Mülkiyyət Haqları",
|
||||
"terms.s4_p1": "DocuElevate intellektual mülkiyyət haqlarına hörmət edir. İstifadəçilər başqalarının intellektual mülkiyyət haqlarını pozan məzmunu yükləyə bilməzlər.",
|
||||
"terms.s5_heading": "5. Məsuliyyətin Məhdudlaşdırılması",
|
||||
"terms.s5_p1": "DocuElevate xidməti \"dizayn edildiyi kimi\" təqdim edir, hər hansı bir zəmanət olmadan. Xidmətin istifadəsindən və ya istifadə edə bilməməyinizdən yaranan birbaşa, dolayısı ilə, təsadüfi, xüsusi, nəticəvi və ya cəzalandırıcı ziyanlar üçün məsuliyyət daşımırıq.",
|
||||
"terms.s6_heading": "6. Tənzimləyici Qanun",
|
||||
"terms.s6_p1": "Bu Şartlar Almaniyanın qanunları ilə tənzimlənəcəkdir, qanunların toqquşma müddəalarına baxmayaraq.",
|
||||
"terms.s6_p2_post": ".",
|
||||
"terms.s6_p2_pre": "Bu Şartlarla bağlı hər hansı sualınız varsa, xahiş edirik bizimlə əlaqə saxlayın",
|
||||
"terms.s6_p3_mid": ". Lisenziya məlumatı üçün xahiş edirik bizim",
|
||||
"terms.s6_p3_post": ".",
|
||||
"terms.s6_p3_pre": "Şəkillərdə çərəzlərin necə istifadə edildiyinə dair məlumat üçün, xahiş edirik bizim",
|
||||
"translation.copied": "Kopyalandı!",
|
||||
"translation.copy": "Kopyala",
|
||||
"translation.default_language_version": "Standart Dil Versiyası",
|
||||
"translation.detected_language": "Aşkar edilən dil",
|
||||
"translation.hide_text": "Mətni gizlədin",
|
||||
"translation.load_translation": "Tərcüməni yüklə",
|
||||
"translation.no_translation": "Hələ tərcümə mövcud deyil — hələ işlənə bilər",
|
||||
"translation.select_language": "Dil seçin\u00016",
|
||||
"translation.select_target": "Xahiş olunur, hədəf dili seçin.",
|
||||
"translation.show_text": "Mətni göstərin",
|
||||
"translation.translate_btn": "Tərcümə et",
|
||||
"translation.translate_to": "Başqa bir dilə tərcümə et",
|
||||
"translation.translated_to": "Tərcümə olundu",
|
||||
"translation.translating": "Tərcümə edilir\u00016",
|
||||
"translation.translation_failed": "Tərcümə baş tutmadı",
|
||||
"upload.browse_button": "Sənədləri Seç",
|
||||
"upload.button_processing": "Emal edilir...",
|
||||
"upload.camera_button": "Şəkil Çəkin / Sənəd Skan Edin",
|
||||
|
||||
@@ -41,6 +41,280 @@
|
||||
"about.story_heading": "Нашата история",
|
||||
"about.story_p1": "DocuElevate е създаден с една цел: да опрости и оптимизира управлението на документи за всеки — независимо дали сте малък стартъп или голямо предприятие.",
|
||||
"about.story_p2": "Използваме силата на доставчици на ИИ с поддръжка на плъгини (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey и други) за извличане на метаданни и усъвършенстване на текста, интегрираме се безпроблемно с Dropbox, Nextcloud и Paperless NGX за съхранение и индексиране, използваме Azure Document Intelligence за OCR и дори използваме Gotenberg за конвертиране на файлове в PDF.",
|
||||
"admin_files.admin_only_badge": "Само администрация",
|
||||
"admin_files.aria_breadcrumb": "Наследяване",
|
||||
"admin_files.badge_delta_detected": "Открито е изменение",
|
||||
"admin_files.badge_duplicate": "дубликат",
|
||||
"admin_files.badge_in_db": "в БД",
|
||||
"admin_files.badge_on_disk": "на диск",
|
||||
"admin_files.breadcrumb_workdir": "работна директория",
|
||||
"admin_files.btn_download": "Изтегли",
|
||||
"admin_files.col_actions": "Действия",
|
||||
"admin_files.col_db": "БД",
|
||||
"admin_files.col_health": "Здраве",
|
||||
"admin_files.col_id": "ID",
|
||||
"admin_files.col_ingested": "Инgestиран",
|
||||
"admin_files.col_local_filename": "локално_име_на_файл",
|
||||
"admin_files.col_missing_paths": "Липсващи пътеки",
|
||||
"admin_files.col_modified": "Променен",
|
||||
"admin_files.col_name": "Име",
|
||||
"admin_files.col_original_file_path": "оригинален_път_на_файл",
|
||||
"admin_files.col_original_filename": "Оригинално име на файл",
|
||||
"admin_files.col_path_relative": "Път (относително към работната директория)",
|
||||
"admin_files.col_processed_file_path": "обработен_път_на_файл",
|
||||
"admin_files.col_size": "Размер",
|
||||
"admin_files.delta_detected_detail": "Намерени са {orphan_count} орфанни файл(ове) на диска без запис в базата данни и {ghost_count} записа в базата данни с липсващи файлове на диска.",
|
||||
"admin_files.delta_detected_title": "Открит делта.",
|
||||
"admin_files.empty_database": "Не са намерени записи на файлове в базата данни.",
|
||||
"admin_files.empty_directory": "Тази директория е празна.",
|
||||
"admin_files.ghost_records_desc": "(в БД, файл(ове) липсват на диска)",
|
||||
"admin_files.ghost_records_heading": "Призрачни записи",
|
||||
"admin_files.heading": "Мениджър на файлове",
|
||||
"admin_files.health_missing": "Липсва",
|
||||
"admin_files.health_ok": "Добре",
|
||||
"admin_files.legend_file_exists": "Файлът съществува на диска",
|
||||
"admin_files.legend_file_missing": "Файлът липсва от диска",
|
||||
"admin_files.legend_found_in_db": "Намерен в БД",
|
||||
"admin_files.legend_not_in_db": "Не в БД (орфан)",
|
||||
"admin_files.legend_path_not_set": "Пътят не е зададен",
|
||||
"admin_files.no_delta": "Не е открит делта — файловата система и базата данни са синхронизирани.",
|
||||
"admin_files.no_ghost_records": "Не са намерени призрачни записи.",
|
||||
"admin_files.no_orphan_files": "Не са намерени орфанни файлове.",
|
||||
"admin_files.orphan_files_desc": "(на диска, без запис в БД)",
|
||||
"admin_files.orphan_files_heading": "Орфанни файлове",
|
||||
"admin_files.page_title": "Мениджър на файлове – Админ",
|
||||
"admin_files.status_in_db": "В БД",
|
||||
"admin_files.status_orphan": "Орфан",
|
||||
"admin_files.tab_database": "Записи в базата данни",
|
||||
"admin_files.tab_filesystem": "Файлова система",
|
||||
"admin_files.tab_reconcile": "Сравняване",
|
||||
"admin_plans.aria_delete_plan": "Изтрий {name}",
|
||||
"admin_plans.aria_edit_plan": "Редактирай {name}",
|
||||
"admin_plans.aria_feature_n": "Функция {n}",
|
||||
"admin_plans.aria_move_down": "Премести {name} надолу",
|
||||
"admin_plans.aria_move_up": "Премести {name} нагоре",
|
||||
"admin_plans.aria_remove_feature_n": "Премахни функция {n}",
|
||||
"admin_plans.btn_add_feature": "Добави функция",
|
||||
"admin_plans.btn_add_plan": "Добави план",
|
||||
"admin_plans.btn_cancel": "Отказ",
|
||||
"admin_plans.btn_create": "Създай план",
|
||||
"admin_plans.btn_delete": "Изтрий",
|
||||
"admin_plans.btn_edit": "Редактирай",
|
||||
"admin_plans.btn_restore_defaults": "Върни по подразбиране",
|
||||
"admin_plans.btn_restore_defaults_title": "Върни всичките четири плана по подразбиране (само ако няма планирани)",
|
||||
"admin_plans.btn_restoring": "Възстановяване\u0000B7",
|
||||
"admin_plans.btn_save_changes": "Запази промените",
|
||||
"admin_plans.btn_save_order": "Запази реда",
|
||||
"admin_plans.btn_saving": "Запазване\u0000B7",
|
||||
"admin_plans.btn_stripe_setup": "Настройка на Stripe",
|
||||
"admin_plans.btn_stripe_setup_title": "Отвори помощника за настройка на Stripe, за да конфигурираш API ключове и синхронизираш плановете",
|
||||
"admin_plans.col_actions": "Действия",
|
||||
"admin_plans.col_active": "Активен",
|
||||
"admin_plans.col_monthly": "Месечен",
|
||||
"admin_plans.col_monthly_limit": "Месечен лимит",
|
||||
"admin_plans.col_order": "Ред",
|
||||
"admin_plans.col_overage_pct": "Процент на надвишение",
|
||||
"admin_plans.col_plan": "План",
|
||||
"admin_plans.col_yearly": "Годишен",
|
||||
"admin_plans.coming_soon": "Скоро",
|
||||
"admin_plans.featured_badge": "Избрани",
|
||||
"admin_plans.field_active": "Активен",
|
||||
"admin_plans.field_allow_overage": "Позволи таксуване при надвишение",
|
||||
"admin_plans.field_api_access": "Достъп до API",
|
||||
"admin_plans.field_badge_text": "Текст на значка",
|
||||
"admin_plans.field_buffer": "Буфер:",
|
||||
"admin_plans.field_cta_text": "Текст на CTA бутона",
|
||||
"admin_plans.field_docs_month": "Документи / Месец",
|
||||
"admin_plans.field_featured": "Избрани / Подчертани",
|
||||
"admin_plans.field_lifetime_docs": "Документи за цял живот",
|
||||
"admin_plans.field_mailboxes": "Имейл кутии",
|
||||
"admin_plans.field_max_file_size": "Максимален размер на файл (MB)",
|
||||
"admin_plans.field_name": "Име",
|
||||
"admin_plans.field_ocr_pages": "OCR страници / месец",
|
||||
"admin_plans.field_overage_doc_price": "Цена над нормата / документ ($)",
|
||||
"admin_plans.field_overage_ocr_price": "Цена над нормата / OCR страница ($)",
|
||||
"admin_plans.field_plan_id": "Идентификатор на плана",
|
||||
"admin_plans.field_price_monthly": "Месечна цена ($)",
|
||||
"admin_plans.field_price_yearly": "Годишна цена ($)",
|
||||
"admin_plans.field_sort_order": "Ред на сортиране",
|
||||
"admin_plans.field_storage_dests": "Дестинации за съхранение",
|
||||
"admin_plans.field_stripe_monthly": "Идентификатор на цената на Stripe (месечно)",
|
||||
"admin_plans.field_stripe_yearly": "Идентификатор на цената на Stripe (годишно)",
|
||||
"admin_plans.field_tagline": "Слоган",
|
||||
"admin_plans.field_trial_days": "Дни на пробен период",
|
||||
"admin_plans.free_label": "Безплатно",
|
||||
"admin_plans.heading": "Дизайнер на планове",
|
||||
"admin_plans.hint_features": "Тези bullet точки се появяват на картата на страницата за ценообразуване за този план.",
|
||||
"admin_plans.hint_plan_id": "Малък регистър, не може да се променя след създаването.",
|
||||
"admin_plans.hint_zero_unlimited": "Въведете 0 за неограничено.",
|
||||
"admin_plans.js_delete_confirm": "Да изтриете плана \"{id}\"? Това не може да бъде отменено.",
|
||||
"admin_plans.js_delete_failed": "Изтриването не успя",
|
||||
"admin_plans.js_failed_load": "Неуспешно зареждане на планове",
|
||||
"admin_plans.js_order_saved": "Поръчката е запазена!",
|
||||
"admin_plans.js_plan_created": "Планът е създаден!",
|
||||
"admin_plans.js_plan_deleted": "Планът \"{id}\" е изтрит.",
|
||||
"admin_plans.js_plan_updated": "Планът е актуализиран!",
|
||||
"admin_plans.js_reorder_failed": "Неуспешно подреждане",
|
||||
"admin_plans.js_save_failed": "Запазването не успя",
|
||||
"admin_plans.js_seed_confirm": "Да се добавят четирите основни плана? Това е бездействие, ако плановете вече съществуват.",
|
||||
"admin_plans.js_seed_failed": "Неуспешно добавяне",
|
||||
"admin_plans.js_yearly_enter": "Въведете годишна цена, за да покажете спестявания",
|
||||
"admin_plans.js_yearly_save": "Спестете {pct}% спрямо месечната",
|
||||
"admin_plans.loading": "Зареждане на планове\n0...",
|
||||
"admin_plans.modal_close_aria": "Затворете модала",
|
||||
"admin_plans.modal_create_title": "Добавяне на план",
|
||||
"admin_plans.modal_edit_title_prefix": "Редактиране на план: ",
|
||||
"admin_plans.no_plans_intro": "Все още няма планове. Щракнете",
|
||||
"admin_plans.no_plans_suffix": "за добавяне на четирите вградени плана.",
|
||||
"admin_plans.overage_0pct": "0% (точно)",
|
||||
"admin_plans.overage_100pct": "100%",
|
||||
"admin_plans.overage_50pct": "50%",
|
||||
"admin_plans.overage_announce": "\u000e \b4",
|
||||
"admin_plans.overage_buffer_body_prefix": "Буферът за надвишаване е",
|
||||
"admin_plans.overage_buffer_body_suffix": "Рекламираме X документа/месец, но прилагаме само при",
|
||||
"admin_plans.overage_buffer_formula": "X \u0000d7 (1 + буфер%)",
|
||||
"admin_plans.overage_buffer_invisible": "невидим за потребителите",
|
||||
"admin_plans.overage_buffer_tail": "документи. Например, план от 150 документа/месец с 20% буфер прилага при 180 документа. Това предотвратява рязко спиране точно на обявения лимит, като дава на потребителите нежно гладко приземяване.",
|
||||
"admin_plans.overage_buffer_title": "За буфера за надвишаване",
|
||||
"admin_plans.overage_docs": "документи,",
|
||||
"admin_plans.overage_docs_end": "документи",
|
||||
"admin_plans.overage_enforce_at": "прилагане при",
|
||||
"admin_plans.page_title": "Дизайнер на планове — Админ на DocuElevate",
|
||||
"admin_plans.section_basic_info": "Основна информация",
|
||||
"admin_plans.section_display": "Дисплей",
|
||||
"admin_plans.section_features": "Списък с функции",
|
||||
"admin_plans.section_overage": "Дизайнер на надвишаване",
|
||||
"admin_plans.section_pricing": "Ценообразуване",
|
||||
"admin_plans.section_stripe": "Интеграция на Stripe",
|
||||
"admin_plans.section_volume": "Обемни ограничения",
|
||||
"admin_plans.status_active": "Активен",
|
||||
"admin_plans.status_inactive": "Неактивен",
|
||||
"admin_plans.stripe_desc_after": "за автоматично създаване на тях. Безплатните планове не се нуждаят от ID на цената на Stripe.",
|
||||
"admin_plans.stripe_desc_before": "Въведете ID на цената на Stripe за този план или използвайте",
|
||||
"admin_plans.stripe_wizard_aria": "Отворете Wizard за настройка на Stripe в нов таб",
|
||||
"admin_plans.stripe_wizard_link": "Магьосник на Stripe",
|
||||
"admin_plans.stripe_wizard_text": "Магьосник за настройка на Stripe",
|
||||
"admin_plans.subheading": "Управлявайте абонаментните планове, показани на публичната страница за ценообразуване.",
|
||||
"admin_plans.table_aria_label": "Абонаментни планове",
|
||||
"admin_users.add_user_profile_btn": "Добави потребителски профил",
|
||||
"admin_users.admin_only_badge": "Само за администратори",
|
||||
"admin_users.btn_password": "Парола",
|
||||
"admin_users.btn_reset": "Нулиране",
|
||||
"admin_users.col_display_name": "Име за показване",
|
||||
"admin_users.col_documents": "Документи",
|
||||
"admin_users.col_email": "Имейл",
|
||||
"admin_users.col_last_upload": "Последно качване",
|
||||
"admin_users.col_plan": "План",
|
||||
"admin_users.col_role": "Роля",
|
||||
"admin_users.col_upload_limit": "Лимит за качване",
|
||||
"admin_users.col_user_id": "Идентификатор на потребителя",
|
||||
"admin_users.col_username": "Потребителско име",
|
||||
"admin_users.create_local_account_btn": "Създай локален акаунт",
|
||||
"admin_users.create_local_title": "Създай локален акаунт",
|
||||
"admin_users.delete_account_btn": "Изтриване на акаунт",
|
||||
"admin_users.delete_local_confirm": "Сигурни ли сте, че искате да изтриете акаунта за",
|
||||
"admin_users.delete_local_title": "Изтриване на локален акаунт",
|
||||
"admin_users.delete_local_warning": "Това не може да бъде отменено. Документите, притежавани от този потребител, не се изтриват.",
|
||||
"admin_users.delete_profile_btn": "Изтриване на профил",
|
||||
"admin_users.delete_profile_confirm": "Сигурни ли сте, че искате да изтриете профила за",
|
||||
"admin_users.delete_profile_title": "Изтриване на потребителски профил",
|
||||
"admin_users.delete_profile_warning": "Това само премахва записа на профила, управляван от администратора. Документите, притежавани от този потребител, не се изтриват.",
|
||||
"admin_users.deleting": "Изтриване\n\n",
|
||||
"admin_users.edit_local_title": "Редактиране на локален акаунт",
|
||||
"admin_users.filter_placeholder": "Филтрирай по идентификатор на потребителя\n\n",
|
||||
"admin_users.global_default": "глобален по подразбиране",
|
||||
"admin_users.heading": "Управление на потребители",
|
||||
"admin_users.js_account_created": "Акаунтът е създаден",
|
||||
"admin_users.js_account_created_msg": "Локален акаунт за \"{username}\" беше успешно създаден.",
|
||||
"admin_users.js_account_deleted_msg": "Акаунтът за \"{username}\" е премахнат.",
|
||||
"admin_users.js_account_updated": "Акаунтът е актуализиран.",
|
||||
"admin_users.js_delete_failed": "Неуспешно изтриване",
|
||||
"admin_users.js_deleted": "Изтрито",
|
||||
"admin_users.js_email_not_sent": "Имейлът не е изпратен",
|
||||
"admin_users.js_email_sent": "Имейлът е изпратен",
|
||||
"admin_users.js_email_sent_msg": "Имейл за нулиране на паролата е изпратен на \"{email}\".",
|
||||
"admin_users.js_failed": "Неуспешно",
|
||||
"admin_users.js_failed_create": "Неуспешно създаване на акаунт.",
|
||||
"admin_users.js_failed_load_local": "Неуспешно зареждане на локални потребители",
|
||||
"admin_users.js_failed_load_users": "Неуспешно зареждане на потребители",
|
||||
"admin_users.js_failed_set_password": "Неуспешно задаване на парола.",
|
||||
"admin_users.js_failed_update": "Неуспешно актуализиране на акаунта.",
|
||||
"admin_users.js_network_error": "Мрежова грешка",
|
||||
"admin_users.js_password_set": "Паролата е зададена",
|
||||
"admin_users.js_password_set_msg": "Паролата за \"{username}\" е актуализирана.",
|
||||
"admin_users.js_profile_deleted": "Профилът за \"{id}\" е изтрит.",
|
||||
"admin_users.js_profile_saved": "Профилът за \"{id}\" е запазен.",
|
||||
"admin_users.js_save_failed": "Запазването не успя",
|
||||
"admin_users.js_saved": "Запазено",
|
||||
"admin_users.js_smtp_not_configured": "SMTP не е конфигуриран.",
|
||||
"admin_users.js_updated": "Актуализирано",
|
||||
"admin_users.loading_users": "Зареждане на потребители\n",
|
||||
"admin_users.local_account_active": "Акаунтът е активен",
|
||||
"admin_users.local_accounts_heading": "Локални потребителски акаунти",
|
||||
"admin_users.local_accounts_subheading": "Акаунти с имейл/парола, създадени директно на този сървър.",
|
||||
"admin_users.local_admin_privileges": "Предоставете администраторски права",
|
||||
"admin_users.local_admin_privileges_short": "Администраторски права",
|
||||
"admin_users.local_create_btn": "Създай акаунт",
|
||||
"admin_users.local_create_one": "Създайте един.",
|
||||
"admin_users.local_creating": "Създаване\n",
|
||||
"admin_users.local_display_name_optional": "(някаква опция)",
|
||||
"admin_users.local_loading": "Зареждане\n",
|
||||
"admin_users.local_no_accounts": "Все още няма локални акаунти.",
|
||||
"admin_users.local_password_hint": "Минимум 8 символа.",
|
||||
"admin_users.local_saving": "Запазване\n",
|
||||
"admin_users.local_username_hint": "3\n-64 символа. Само букви, цифри, тирета и долни черти.",
|
||||
"admin_users.modal_add_title": "Добавяне на потребителски профил",
|
||||
"admin_users.modal_billing_cycle_label": "Цикъл на фактуриране",
|
||||
"admin_users.modal_billing_monthly": "Месечно",
|
||||
"admin_users.modal_billing_yearly": "Годишно",
|
||||
"admin_users.modal_block_hint": "(предотвратява ново качване на документи)",
|
||||
"admin_users.modal_block_label": "Блокирайте този потребител",
|
||||
"admin_users.modal_close_aria": "Затваряне на диалога",
|
||||
"admin_users.modal_complimentary_hint": "(потребителят запазва предимствата от нивото, но никога не се таксува \n\n\n\n— задава се автоматично за администраторски акаунти)",
|
||||
"admin_users.modal_complimentary_label": "Комплиментарен план",
|
||||
"admin_users.modal_daily_limit_hint": "(оставете празно, за да използвате глобалния по подразбиране)",
|
||||
"admin_users.modal_daily_limit_label": "Дневен лимит за качвания",
|
||||
"admin_users.modal_daily_limit_placeholder": "напр. 50 (0 = неограничено)",
|
||||
"admin_users.modal_display_name_label": "Показвано име",
|
||||
"admin_users.modal_display_name_placeholder": "Алис Смит (по желание)",
|
||||
"admin_users.modal_edit_title": "Редактиране на профила на потребителя",
|
||||
"admin_users.modal_notes_label": "Административни бележки",
|
||||
"admin_users.modal_notes_placeholder": "Вътрешни бележки, видими само за администратори\u00100\u0010",
|
||||
"admin_users.modal_period_start_hint": "Годишното пренасяне започва от тази дата. Оставете празно за месечна валидност.",
|
||||
"admin_users.modal_period_start_label": "Начало на абонаментния период",
|
||||
"admin_users.modal_plan_business": "Бизнес \u0010\u0010 $7.99/месец (300/месец, неограничени пощи)",
|
||||
"admin_users.modal_plan_free": "Безплатно \u0010\u0010 25 файла за цял живот",
|
||||
"admin_users.modal_plan_hint": "Настройва лимитите за квота за този потребител. Лимитите се прилагат при качване.",
|
||||
"admin_users.modal_plan_label": "Абонаментен план",
|
||||
"admin_users.modal_plan_professional": "Професионален \u0010\u0010 $5.99/месец (150/месец, 3 пощи)",
|
||||
"admin_users.modal_plan_starter": "Стартов \u0010\u0010 $2.99/месец (50/месец, 1 поща)",
|
||||
"admin_users.modal_save_changes": "Запази промените",
|
||||
"admin_users.modal_saving": "Запазване\u0010\u0010",
|
||||
"admin_users.modal_user_id_hint": "Стабилният идентификатор, който съответства на owner_id в документите.",
|
||||
"admin_users.modal_user_id_label": "Идентификатор на потребителя",
|
||||
"admin_users.modal_user_id_placeholder": "user@example.com или OAuth под",
|
||||
"admin_users.new_account_btn": "Нова сметка",
|
||||
"admin_users.new_password_label": "Нова парола",
|
||||
"admin_users.no_users_add_hint": "Качете някои документи или добавете профил по-горе.",
|
||||
"admin_users.no_users_found": "Няма намерени потребители.",
|
||||
"admin_users.no_users_search_hint": "Опитайте с друга търсене.",
|
||||
"admin_users.page_title": "Управление на потребители \u0010\u0010 Администратор \u0010\u0010 DocuElevate",
|
||||
"admin_users.pagination_page_of": "от",
|
||||
"admin_users.per_day": "/ ден",
|
||||
"admin_users.role_admin": "Администратор",
|
||||
"admin_users.role_user": "Потребител",
|
||||
"admin_users.search_users_label": "Търсене на потребители",
|
||||
"admin_users.set_password_btn": "Задаване на парола",
|
||||
"admin_users.set_password_desc": "Потребителят трябва да смени тази парола след влизане.",
|
||||
"admin_users.set_password_desc_pre": "Задайте нова парола директно за",
|
||||
"admin_users.set_password_title": "Задаване на временна парола",
|
||||
"admin_users.setting": "Настройка\u0010\u0010",
|
||||
"admin_users.status_blocked": "Блокиран",
|
||||
"admin_users.status_unverified": "Непотвърден",
|
||||
"admin_users.subheading": "Управлявайте профилите на потребителите, лимитите за качване на потребител и собствеността на документи.",
|
||||
"admin_users.total_count_users": "{count} потребители",
|
||||
"admin_users.total_no_users": "Няма потребители",
|
||||
"admin_users.total_one_user": "1 потребител",
|
||||
"api_tokens.col_created": "Създадено",
|
||||
"api_tokens.col_last_ip": "Последен IP адрес",
|
||||
"api_tokens.col_last_used": "Последно използвано",
|
||||
@@ -73,6 +347,19 @@
|
||||
"api_tokens.usage_intro_pre": "Използвайте вашия API токен в",
|
||||
"api_tokens.your_tokens": "Вашите токени",
|
||||
"app.name": "DocuElevate",
|
||||
"attribution.heading": "Атрибуции на софтуер от трети страни",
|
||||
"attribution.intro": "DocuElevate използва няколко отворени библиотеки и инструменти с отворен код. Ние сме благодарни на разработчиците на тези проекти за техния принос към софтуера с отворен код.",
|
||||
"attribution.page_title": "DocuElevate - Атрибуции на трети страни",
|
||||
"attribution.paramiko_lgpl_note": "Забележка: Тази библиотека е лицензирана под GNU Lesser General Public License v2.1 (LGPL-2.1)",
|
||||
"attribution.section_docker": "Docker изображения",
|
||||
"attribution.section_frontend": "Зависимости в преден край",
|
||||
"attribution.section_python": "Python зависимости",
|
||||
"attribution.special_lgpl_link": "тук",
|
||||
"attribution.special_lgpl_post": ".",
|
||||
"attribution.special_lgpl_pre": "Копие от лицензията LGPL може да бъде намерено",
|
||||
"attribution.special_source_post": ".",
|
||||
"attribution.special_source_pre": "Този софтуер включва Paramiko, който е лицензираан под LGPL. Изходният код на Paramiko е наличен на",
|
||||
"attribution.special_title": "Специална атрибуция:",
|
||||
"audit.col_ip": "IP",
|
||||
"audit.col_resource": "Ресурс",
|
||||
"audit.col_timestamp": "Времева отметка",
|
||||
@@ -252,6 +539,41 @@
|
||||
"cookie.notice_label": "Уведомление за бисквитки",
|
||||
"cookie.policy_link": "Политика за бисквитките",
|
||||
"cookie.privacy_link": "Уведомление за поверителност",
|
||||
"cookie_policy.heading": "Политика за бисквитки",
|
||||
"cookie_policy.last_updated": "Последно обновление:",
|
||||
"cookie_policy.page_title": "Политика за бисквитки - DocuElevate",
|
||||
"cookie_policy.s1_heading": "Какви са бисквитките",
|
||||
"cookie_policy.s1_p1": "Бисквитките са малки текстови файлове, които се съхраняват на вашия компютър или мобилно устройство, когато посещавате уебсайт. Те се използват широко, за да направят уебсайтовете да работят по-ефективно и да предоставят информация на собствениците на уебсайтове.",
|
||||
"cookie_policy.s2_heading": "Как използваме бисквитки",
|
||||
"cookie_policy.s2_li1_body": "За да ви идентифицираме, когато влизате и да поддържаме вашата сесия, докато използвате приложението.",
|
||||
"cookie_policy.s2_li1_label": "Аутентификация и управление на сесията:",
|
||||
"cookie_policy.s2_p1_post": "за следната цел:",
|
||||
"cookie_policy.s2_p1_pre": "DocuElevate използва",
|
||||
"cookie_policy.s2_p1_strong": "само строго необходими сесийни бисквитки",
|
||||
"cookie_policy.s2_p2": "Тези бисквитки са задължителни за правилното функциониране на нашата услуга. Без тези бисквитки, бихте били задължени да се логнеш многократно по време на сесията си за сърфиране.",
|
||||
"cookie_policy.s2_p3": "Тъй като тези бисквитки са строго необходими за работата на услугата, те са освободени от изискванията за предварително съгласие по директивата на ЕС за електронната неприкосновеност (член 5(3)) и съответните национални изисквания. Ние не поставяме никакви опционални, аналитични, рекламни или проследяващи бисквитки.",
|
||||
"cookie_policy.s3_col_duration": "Продължителност",
|
||||
"cookie_policy.s3_col_name": "Име",
|
||||
"cookie_policy.s3_col_purpose": "Цел",
|
||||
"cookie_policy.s3_col_type": "Тип",
|
||||
"cookie_policy.s3_heading": "Детайли за бисквитките",
|
||||
"cookie_policy.s3_row1_duration": "Сесийна (изтрива се при затваряне на браузъра или излизане)",
|
||||
"cookie_policy.s3_row1_purpose": "Поддържа вашата автентифицирана сесия; необходима е за функционирането на входа.",
|
||||
"cookie_policy.s3_row1_type": "Строго необходими",
|
||||
"cookie_policy.s3_row2_duration": "Постоянна (местно хранилище на браузъра)",
|
||||
"cookie_policy.s3_row2_purpose": "Съхранява вашето потвърждение на уведомлението за бисквитки, така че да не се показва многократно (съхранява се в местното хранилище, не в бисквитка).",
|
||||
"cookie_policy.s3_row2_type": "Строго необходими",
|
||||
"cookie_policy.s4_heading": "Без бисквитки от трети страни",
|
||||
"cookie_policy.s4_p1": "DocuElevate не използва никакви бисквитки от трети страни, проследяващи бисквитки, рекламни бисквитки или аналитични бисквитки. Ние уважаваме вашата неприкосновеност и прилагаме само минималното количество бисквитки, необходими за функционирането на нашата услуга.",
|
||||
"cookie_policy.s4_p2_pre": "За повече информация относно начина, по който обработваме вашите данни, моля, вижте нашата",
|
||||
"cookie_policy.s4_privacy_link": "Уведомление за конфиденциалност",
|
||||
"cookie_policy.s5_heading": "Управление на бисквитки",
|
||||
"cookie_policy.s5_p1": "Повечето уеб браузъри ви позволяват да контролирате бисквитките чрез своите настройки. Въпреки това, блокирането или изтриването на нашите сесийни бисквитки ще предотврати функционирането на DocuElevate, тъй като потребителската автентикация зависи от тези бисквитки.",
|
||||
"cookie_policy.s5_p2": "Можете също така да изтриете признаването на уведомлението за бисквитки, съхранено в локалното хранилище на браузъра ви, по всяко време чрез инструментите за разработчици на браузъра ви (Приложение → Локално хранилище).",
|
||||
"cookie_policy.s5_p3_and": "и",
|
||||
"cookie_policy.s5_p3_pre": "Тази Политика за бисквитки е част от и е вградена в нашия",
|
||||
"cookie_policy.s5_privacy_link": "Уведомление за конфиденциалност",
|
||||
"cookie_policy.s5_terms_link": "Условия за обслужване",
|
||||
"credentials.col_action": "Действие",
|
||||
"credentials.col_credential": "Учетни данни",
|
||||
"credentials.col_source": "Източник",
|
||||
@@ -517,6 +839,31 @@
|
||||
"help.workflows_step_5_create": "Запазете — новите документи ще бъдат обработвани автоматично чрез този pipeline.",
|
||||
"help.workflows_typical_steps": "Типични стъпки",
|
||||
"help.workflows_what_is": "Какво е Pipeline?",
|
||||
"imprint.business_registration_heading": "Регистрация на бизнес",
|
||||
"imprint.business_registration_vat": "Идентификационен номер по ДДС съгласно §27а Закон за данък върху добавената стойност:",
|
||||
"imprint.contact_heading": "Информация за контакт",
|
||||
"imprint.dispute_heading": "Онлайн разрешаване на спорове",
|
||||
"imprint.dispute_p1": "Европейската комисия предоставя платформа за онлайн разрешаване на спорове (OS):",
|
||||
"imprint.dispute_p2": "Не сме склонни или задължени да участваме в процедури по разрешаване на спорове пред потребителски арбитраж.",
|
||||
"imprint.heading": "Импринт",
|
||||
"imprint.legal_copyright": "Цялото съдържание на този уебсайт е защитено с авторски права. Всяка употреба извън пределите на закона за авторското право изисква писмено съгласие на съответния автор или създател.",
|
||||
"imprint.legal_heading": "Правни известия",
|
||||
"imprint.legal_liability": "Въпреки внимателния контрол на съдържанието, не поемаме отговорност за съдържанието на външни линкове. Операторите на свързаните страници са единствено отговорни за тяхното съдържание.",
|
||||
"imprint.page_title": "Импринт - DocuElevate",
|
||||
"imprint.policies_cookie_desc": "Информация за бисквитките, които използваме",
|
||||
"imprint.policies_cookie_label": "Политика за бисквитки",
|
||||
"imprint.policies_heading": "Свързани политики",
|
||||
"imprint.policies_intro": "Нашата услуга се управлява от следните политики:",
|
||||
"imprint.policies_license_desc": "Как е лицензирано нашето софтуерно",
|
||||
"imprint.policies_license_label": "Информация за лиценз",
|
||||
"imprint.policies_privacy_desc": "Как обработваме вашите данни",
|
||||
"imprint.policies_privacy_label": "Политика за конфиденциалност",
|
||||
"imprint.policies_terms_desc": "Правила за използване на DocuElevate",
|
||||
"imprint.policies_terms_label": "Условия за обслужване",
|
||||
"imprint.provider_heading": "Носител на услугата",
|
||||
"imprint.responsible_content_heading": "Отговорен за съдържанието",
|
||||
"imprint.responsible_content_rstv": "Съгласно § 55 ал. 2 RStV:",
|
||||
"imprint.subtitle": "Информация съгласно § 5 TMG (Германски закон за телекомуникациите)",
|
||||
"index.badge_intelligent": "Интелигентна обработка на документи",
|
||||
"index.button_browse_files": "Преглед на файлове",
|
||||
"index.button_upload": "Качване",
|
||||
@@ -728,6 +1075,19 @@
|
||||
"language.tr": "Турски",
|
||||
"language.uk": "Украински",
|
||||
"language.zh": "Китайски",
|
||||
"license.apache_description": "DocuElevate се разпространява под Apache License 2.0, която е разрешителна лицензия с отворен код, която ви позволява да използвате, модифицирате, разпространявате и допринасяте за проекта.",
|
||||
"license.apache_heading": "Apache License 2.0",
|
||||
"license.heading": "Информация за лиценз",
|
||||
"license.page_title": "Информация за лиценз - DocuElevate",
|
||||
"license.related_about_link": "Страница 'За нас'",
|
||||
"license.related_and": "и",
|
||||
"license.related_heading": "Свързана информация",
|
||||
"license.related_p1_post": "за информация относно използването на услугата DocuElevate.",
|
||||
"license.related_p1_pre": "Докато тази лицензия регулира използването на нашия софтуер, моля, прегледайте и нашия",
|
||||
"license.related_p2_post": ".",
|
||||
"license.related_p2_pre": "За повече информация относно DocuElevate, моля, посетете",
|
||||
"license.related_privacy_link": "Политика за Поверителност",
|
||||
"license.related_terms_link": "Условия за ползване",
|
||||
"nav.about": "Относно",
|
||||
"nav.account_menu": "Меню на акаунта",
|
||||
"nav.account_menu_for": "Меню на акаунта за {name}",
|
||||
@@ -828,6 +1188,185 @@
|
||||
"pipelines.system_label": "Системен",
|
||||
"pipelines.system_pipeline_label": "Системен пайплайн (видим за всички потребители)",
|
||||
"pipelines.title": "Пайплайни за обработка",
|
||||
"privacy.heading": "DocuElevate – Уведомление за Поверителност",
|
||||
"privacy.last_updated": "Последна актуализация:",
|
||||
"privacy.page_title": "Уведомление за Поверителност - DocuElevate",
|
||||
"privacy.s10_access_body": "Можете да поискате копие на личните данни, които държим за вас.",
|
||||
"privacy.s10_access_label": "Право на достъп (Чл. 15):",
|
||||
"privacy.s10_complaint_body": "Имате право да подадете жалба до националния си орган за защита на данните (DPA). В Германия: Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI). В Обединеното кралство: Information Commissioner's Office (ICO). В Швейцария: Federal Data Protection and Information Commissioner (FDPIC).",
|
||||
"privacy.s10_complaint_label": "Право на подаване на жалба:",
|
||||
"privacy.s10_contact": "За да упражните някое от горепосочените права, свържете се с нас на",
|
||||
"privacy.s10_erasure_body": "Можете да поискате изтриване на вашите лични данни, когато няма надвишаваща легитимна причина за тяхното запазване.",
|
||||
"privacy.s10_erasure_label": "Право на изтриване (Чл. 17):",
|
||||
"privacy.s10_heading": "10. Вашите права (ЕС / ЕИП / Обединеното кралство / Швейцария)",
|
||||
"privacy.s10_object_body": "Можете да възразите на обработката на данни въз основа на легитимни интереси по всяко време.",
|
||||
"privacy.s10_object_label": "Право на възражение (Чл. 21):",
|
||||
"privacy.s10_p1": "Съгласно GDPR (и UK GDPR / швейцарския нFADP еквивалент), имате следните права:",
|
||||
"privacy.s10_portability_body": "Можете да поискате вашите данни в структуриран, широко използван, машинно четим формат.",
|
||||
"privacy.s10_portability_label": "Право на преносимост на данни (Чл. 20):",
|
||||
"privacy.s10_rectification_body": "Можете да поискате корекция на неточни или непълни лични данни.",
|
||||
"privacy.s10_rectification_label": "Право на корекция (Чл. 16):",
|
||||
"privacy.s10_response": "Ние ще отговорим в рамките на един календарен месец (продължимо с още два месеца за сложни запитвания).",
|
||||
"privacy.s10_restriction_body": "Можете да поискате временно да спрем обработката на вашите данни при определени обстоятелства.",
|
||||
"privacy.s10_restriction_label": "Право на ограничение (Чл. 18):",
|
||||
"privacy.s10_withdraw_body": "Когато обработката се основава на съгласие, можете да оттеглите това съгласие по всяко време, без да засяга законността на предишната обработка.",
|
||||
"privacy.s10_withdraw_label": "Право на оттегляне на съгласие:",
|
||||
"privacy.s11_categories_body": "Идентификатори (име, имейл), токени за удостоверяване на акаунта и метаданни на документи, които вие изберете да качите.",
|
||||
"privacy.s11_categories_label": "Категории лична информация, събрана:",
|
||||
"privacy.s11_contact": "За да подадете проверимо потребителско запитване, свържете се с нас на",
|
||||
"privacy.s11_correct_body": "Можете да поискате корекция на неточна лична информация.",
|
||||
"privacy.s11_correct_label": "Право на корекция:",
|
||||
"privacy.s11_delete_body": "Можете да поискате изтриване на личната информация, която сме събрали, с изключение на определени случаи.",
|
||||
"privacy.s11_delete_label": "Право на изтриване:",
|
||||
"privacy.s11_heading": "11. Допълнителни права – Съединени щати (CCPA / CPRA)",
|
||||
"privacy.s11_know_body": "Можете да поискате разкриване на категориите и конкретните части от лична информация, които сме събрали за вас.",
|
||||
"privacy.s11_know_label": "Право на информация:",
|
||||
"privacy.s11_limit_body": "Не използваме чувствителна лична информация извън необходимото за предоставяне на услугата.",
|
||||
"privacy.s11_limit_label": "Право да ограничите използването на чувствителна лична информация:",
|
||||
"privacy.s11_nondiscrim_body": "Няма да ви дискриминираме за упражняване на някое от тези права.",
|
||||
"privacy.s11_nondiscrim_label": "Недискриминация:",
|
||||
"privacy.s11_optout_body": "Не продаваме и не споделяме лична информация, както е определено от CCPA/CPRA. Не е необходим механизъм за отказ; обаче, можете да се свържете с нас, за да потвърдите това.",
|
||||
"privacy.s11_optout_label": "Право на отказ от продажба / споделяне:",
|
||||
"privacy.s11_p1": "Ако сте резидент на Калифорния или друг щат в САЩ с приложимо законодателство за защита на личните данни (включително Virginia VCDPA, Colorado CPA, Connecticut CTDPA, Utah UCPA), следните допълнителни разкрития важат:",
|
||||
"privacy.s11_purpose_body": "Предоставяне, подобряване и осигуряване на услугата DocuElevate. Не продаваме и не споделяме лична информация за поведенческа реклама в различни контексти.",
|
||||
"privacy.s11_purpose_label": "Цел на събирането:",
|
||||
"privacy.s11_response": "Ще отговорим в рамките на 45 дни (удължимо с още 45 дни, когато е разумно необходимо).",
|
||||
"privacy.s12_access_body": "Можете да поискате достъп до вашата лична информация и информация за това как е била използвана или разкрита.",
|
||||
"privacy.s12_access_label": "Право на достъп:",
|
||||
"privacy.s12_contact": "Насочете директни оплаквания относно конфиденциалността към нашия служител по конфиденциалност на",
|
||||
"privacy.s12_contact_post": ", или към Офиса на комисаря по конфиденциалност на Канада.",
|
||||
"privacy.s12_correction_body": "Можете да оспорите точността или пълнотата на вашата лична информация и да поискате корекция.",
|
||||
"privacy.s12_correction_label": "Право на корекция:",
|
||||
"privacy.s12_heading": "12. Допълнителни права – Канада (PIPEDA / Закон 25 на Квебек)",
|
||||
"privacy.s12_li1": "Събираме, използваме и разкриваме лична информация само с вашето знание и съгласие или както е разрешено от закона.",
|
||||
"privacy.s12_p1": "Ако се намирате в Канада, следното важи съгласно Закона за защита на личната информация и електронните документи (PIPEDA) и приложимото провинциално законодателство (включително Закон 25 на Квебек / Проект 64):",
|
||||
"privacy.s12_quebec_body": "Съгласно Закон 25, имате допълнителни права, включително правото на преносимост на данните (в сила от септември 2023) и правото на деиндексиране, когато личната информация е разпространена онлайн.",
|
||||
"privacy.s12_quebec_label": "Резиденти на Квебек:",
|
||||
"privacy.s12_withdraw_body": "При спазване на правни или договорни ограничения, можете да оттеглите съгласие за събиране, използване или разкриване на вашата лична информация с разумно уведомление.",
|
||||
"privacy.s12_withdraw_label": "Право на оттегляне на съгласие:",
|
||||
"privacy.s13_brazil_body": "Ако се намирате в Бразилия, имате следните права съгласно LGPD:",
|
||||
"privacy.s13_brazil_label": "Бразилия (LGPD – Закон за защита на данните, Закон 13.709/2018):",
|
||||
"privacy.s13_contact": "Контакт:",
|
||||
"privacy.s13_heading": "13. Допълнителни права – Латинска Америка (LGPD и други)",
|
||||
"privacy.s13_li1": "Потвърждение за съществуването на обработка и достъп до вашите данни.",
|
||||
"privacy.s13_li2": "Корекция на непълни, неточни или остарели данни.",
|
||||
"privacy.s13_li3": "Анонимизиране, блокиране или изтриване на ненужни или прекомерни данни.",
|
||||
"privacy.s13_li4": "Преносимост на вашите данни към друг доставчик на услуги или продукти.",
|
||||
"privacy.s13_li5": "Изтриване на лични данни, обработвани с вашето съгласие.",
|
||||
"privacy.s13_li6": "Информация за организациите, с които вашите данни са били споделени.",
|
||||
"privacy.s13_li7": "Информация за възможността за отказ от съгласие и последствията от отказа.",
|
||||
"privacy.s13_li8": "Отмяна на съгласие.",
|
||||
"privacy.s13_other_body": "Също така признаваеме приложимите закони за конфиденциалност в Аржентина (PDPA), Мексико (LFPDPPP), Чили, Колумбия (Закон 1581) и други. Потребителите в тези юрисдикции могат да упражняват еквивалентни права, както е изложено в тяхното национално законодателство, като се свържат с нас.",
|
||||
"privacy.s13_other_label": "Други латинскоамерикански държави:",
|
||||
"privacy.s14_apj_body": "Признаваме правата за защита на данните, предоставени на жителите на тези юрисдикции съгласно техните съответни национални закони. Свържете се с нас, за да упражните вашите права.",
|
||||
"privacy.s14_apj_label": "Други пазари APJ (Закон PDPA на Сингапур, Закон за конфиденциалност на Нова Зеландия, Закон DPDP на Индия):",
|
||||
"privacy.s14_australia_body": "Австралийски резиденти могат да поискат достъп до и корекция на личната си информация. Ще отговорим на исканията за достъп в рамките на 30 дни. Оплаквания могат да бъдат подавани в Офиса на австралийския комисар по информация (OAIC).",
|
||||
"privacy.s14_australia_label": "Австралия (Закон за конфиденциалност от 1988 г. и австралийски принципи за конфиденциалност):",
|
||||
"privacy.s14_contact": "Контакт:",
|
||||
"privacy.s14_heading": "14. Допълнителни права – Азиатско-тихоокеански регион и Япония",
|
||||
"privacy.s14_japan_body": "Японските жители могат да поискат разкриване, коригиране, добавяне или изтриване, спиране на използването, заличаване или спиране на предоставянето на личната им информация, която притежаваме. Разкрития на трети страни изискват предварително ваше съгласие, освен в случаи, разрешени от закона.",
|
||||
"privacy.s14_japan_label": "Япония (APPI – Закон за защита на личната информация):",
|
||||
"privacy.s14_korea_body": "Корейските жители могат да поискат достъп, коригиране, изтриване и спиране на обработването. Ние обработваме личната информация на корейските жители в съответствие с PIPA.",
|
||||
"privacy.s14_korea_label": "Южна Корея (PIPA – Закон за защита на личната информация):",
|
||||
"privacy.s15_contact": "Контакт:",
|
||||
"privacy.s15_heading": "15. Допълнителни права – Украйна",
|
||||
"privacy.s15_p1": "Потребителите, разположени в Украйна, са защитени от Закона на Украйна \"За защита на личните данни\" (№ 2297-VI). Вашите права включват достъп до, коригиране, блокиране и изтриване на вашите лични данни, както и правото да възразите срещу обработването.",
|
||||
"privacy.s16_cookies_link": "Политика за бисквитките",
|
||||
"privacy.s16_heading": "16. Актуализации на това уведомление за поверителност",
|
||||
"privacy.s16_license_link": "Информация за лиценза",
|
||||
"privacy.s16_p1": "Можем да актуализираме това уведомление от време на време, за да отразим промените в практиките ни или приложимите закони. Датата \"Последна актуализация\" в горната част на тази страница показва кога уведомлението е било ревизирано за последен път. Когато промените са съществени, ще уведомим потребителите чрез известие в приложението или по имейл, където е уместно.",
|
||||
"privacy.s16_p2_pre": "Ако имате въпроси или притеснения относно това уведомление за поверителност или вашите лични данни, моля, свържете се с нас на",
|
||||
"privacy.s16_p3_pre": "Моля, също така прегледайте нашите",
|
||||
"privacy.s16_terms_link": "Условия за ползване",
|
||||
"privacy.s1_address": "Alter Steinweg 3, 20459 Хамбург, Германия",
|
||||
"privacy.s1_company": "Кристиан Луис IT Консултации",
|
||||
"privacy.s1_contact_label": "Имейл за контакт:",
|
||||
"privacy.s1_heading": "1. Контролер на данни",
|
||||
"privacy.s1_p1": "Контролерът, отговорен за обработването на вашите лични данни по Регламента на ЕС за защита на данните (GDPR) и равнозначните закони за поверителност в световен мащаб, е:",
|
||||
"privacy.s1_p3": "За всички запитвания, свързани с поверителността (достъп, изтриване, корекция, отказ от оказване на влияние или оплаквания), моля, свържете се с нас на горния имейл адрес. Ние ще отговорим в рамките на 30 дни (или в срока, предвиден от приложимия закон).",
|
||||
"privacy.s2_heading": "2. Обхват на това уведомление за поверителност",
|
||||
"privacy.s2_p1_pre": "Това уведомление се отнася за уеб приложението DocuElevate, хоствано на",
|
||||
"privacy.s2_p2": "То обхваща всички потребители в световен мащаб, включително тези в Европейския съюз (ЕС), Европейското икономическо пространство (ЕИП), Германия, Обединеното кралство (Великобритания), Швейцария, Украйна, Съединените щати (САЩ), Канада, Латинска Америка (Латам), Азиатско-тихоокеанския регион и Япония. Специфични за пазарите разкрития са предоставени в отделни секции по-долу.",
|
||||
"privacy.s3_audit_body": "Поддържаме ограничени аудиторски журнали (тип действия, времеви печат, идентификатор на потребител) за да осигурим целостта и сигурността на услугата. Тези журнали не включват съдържанието на документите.",
|
||||
"privacy.s3_audit_label": "Аудиторски журнали:",
|
||||
"privacy.s3_auth_body": "Използваме OAuth 2.0 (Google, Dropbox, Microsoft/OneDrive) и опционална локална автентикация. Чрез OAuth можем да получим вашето име, имейл адрес и профилна снимка.",
|
||||
"privacy.s3_auth_label": "Аутентикация на потребителя:",
|
||||
"privacy.s3_doc_body": "Документите, които качвате, се обработват за OCR (разпознаване на оптични символи), извлечение на метаданни и съхранение в избрания от вас облачен доставчик. Съдържанието на документите се обработва само за целта, която инициирате и не се съхранява извън това, което е оперативно необходимо.",
|
||||
"privacy.s3_doc_label": "Обработка на документи:",
|
||||
"privacy.s3_heading": "3. Събиране на данни и цели",
|
||||
"privacy.s3_legal_body": "Нашите основни правни основания за обработване са:",
|
||||
"privacy.s3_legal_label": "Правно основание (GDPR чл. 6):",
|
||||
"privacy.s3_li1": "(1)(b) Изпълнение на договор: за предоставяне на услугата DocuElevate, която сте поискали.",
|
||||
"privacy.s3_li2": "(1)(c) Правно задължение: за спазване на приложимите закони и разпоредби.",
|
||||
"privacy.s3_li3": "(1)(f) Легитимни интереси: осигуряване на сигурността на услугата и предотвратяване на измами.",
|
||||
"privacy.s4_heading": "4. Минимизиране на данни и ограничаване на целите",
|
||||
"privacy.s4_li1": "Събираме само минималните лични данни, необходими за функциониране на услугата.",
|
||||
"privacy.s4_li2": "Съдържанието на документите се обработва строго за целта, която инициирате (OCR, съхранение, извлечение на метаданни). Не използваме вашите документи за обучение на ИИ модели или за каквато и да е вторична цел.",
|
||||
"privacy.s4_li3": "Не се извършват реклама, проследяване на поведение или профилиране.",
|
||||
"privacy.s4_li4": "Не се зареждат проследяващи бисквитки или аналитични скриптове.",
|
||||
"privacy.s4_li5": "Услугите на трети страни с изкуствен интелект (например, OpenAI, Azure Document Intelligence) се извикват само когато вие инициирате обработка на документ, а данните се предават съгласно споразумения за обработка на данни.",
|
||||
"privacy.s4_p1": "DocuElevate е проектиран с минимизация на данните като основен принцип (GDPR чл. 5(1)(c)):",
|
||||
"privacy.s5_cookie_link": "Политика за бисквитки",
|
||||
"privacy.s5_heading": "5. Използване на бисквитки и подобни технологии",
|
||||
"privacy.s5_p1_post": "за поддържане на вашата удостоверена сесия. Тези бисквитки са от съществено значение за функционирането на услугата и са освободени от изисквания за предварително съгласие съгласно Директивата за електронна конфиденциалност на ЕС (чл. 5(3)) и еквивалентните национални закони.",
|
||||
"privacy.s5_p1_pre": "DocuElevate използва",
|
||||
"privacy.s5_p1_strong": "само стриктно необходими сесийни бисквитки",
|
||||
"privacy.s5_p2_body": "аналитични бисквитки, рекламни бисквитки, проследяващи пиксели или всякакви бисквитки на трети страни, които биха изисквали вашето съгласие.",
|
||||
"privacy.s5_p2_label": "Не използваме:",
|
||||
"privacy.s5_p3_pre": "За пълни подробности относно бисквитките, които поставяме, техните имена, продължителност и цел, моля, посетете нашия",
|
||||
"privacy.s6_ai_body": "Когато инициирате OCR или извличане на метаданни въз основа на изкуствен интелект, данните на документа се предават на услугата с изкуствен интелект, която вие или вашият администратор са конфигурирали. Тази предаване се управлява от споразумение за обработка на данни с съответния доставчик.",
|
||||
"privacy.s6_ai_label": "Услуги за обработка с ИИ (OpenAI, Azure Document Intelligence, други):",
|
||||
"privacy.s6_heading": "6. Услуги на трети страни",
|
||||
"privacy.s6_no_sale_body": "Не продаваме, наемаме или споделяме вашите лични данни с трети страни за реклама, маркетинг или каквато и да е цел, несвързана с предоставянето на услугата.",
|
||||
"privacy.s6_no_sale_label": "Няма продажба или споделяне за реклама:",
|
||||
"privacy.s6_oauth_body": "Когато изберете да се удостоверите чрез OAuth, съответният доставчик обработва вашите идентификационни данни и може да сподели ограничена информация за профила с нас. Тези доставчици поддържат свои собствени политики за конфиденциалност.",
|
||||
"privacy.s6_oauth_label": "OAuth доставчици (Google, Dropbox, Microsoft):",
|
||||
"privacy.s6_storage_body": "Документите се съхраняват при облачния доставчик, който конфигурирате. Вашите конфигурирани идентификационни данни се съхраняват криптирано в базата данни на приложението и се използват единствено за извършване на операциите по съхранение, които вие искате.",
|
||||
"privacy.s6_storage_label": "Облачни доставчици за съхранение (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):",
|
||||
"privacy.s7_adequacy_body": "където Европейската комисия е признала еквивалентно ниво на защита (напр. Обединеното кралство, Швейцария, Канада (комерсиални организации), Япония, Южна Корея).",
|
||||
"privacy.s7_adequacy_label": "Решения за адекватност",
|
||||
"privacy.s7_contact": "Можете да поискате копие на съответните мерки за защита, като се свържете с нас на",
|
||||
"privacy.s7_heading": "7. Международни трансфери на данни",
|
||||
"privacy.s7_idta_body": "за трансфери от Великобритания след Brexit.",
|
||||
"privacy.s7_idta_label": "Споразумения за международни трансфери на данни от Великобритания (IDTA)",
|
||||
"privacy.s7_p1": "DocuElevate се хоства в Европейския съюз / ЕИП по подразбиране. Когато лични данни се прехвърлят извън ЕИП (например на базирани в САЩ доставчици на услуги с изкуствен интелект като OpenAI), разчитаме на подходящи мерки за защита, включително:",
|
||||
"privacy.s7_scc_body": "приети от Европейската комисия (2021/914/EU) за трансфери към обработващи и контролни лица в трети държави.",
|
||||
"privacy.s7_scc_label": "Стандартни договорни клаузи (SCC)",
|
||||
"privacy.s8_audit_body": "Съхраняват се до 90 дни за цели свързани с безопасността и съответствието.",
|
||||
"privacy.s8_audit_label": "Аудитни протоколи:",
|
||||
"privacy.s8_contact": "За да поискате изтриване на вашия акаунт и всички свързани лични данни, моля, свържете се с нас на",
|
||||
"privacy.s8_files_body": "Съхраняват се за времето на вашето ползване на услугата. Можете да изтривате отделни файлове по всяко време през приложението.",
|
||||
"privacy.s8_files_label": "Данни за файлове и метаданни:",
|
||||
"privacy.s8_heading": "8. Съхранение на данни",
|
||||
"privacy.s8_oauth_body": "Съхраняват се в криптиран вид и могат да бъдат оттеглени по всяко време чрез вашия OAuth доставчик.",
|
||||
"privacy.s8_oauth_label": "OAuth токени:",
|
||||
"privacy.s8_p1": "Съхраняваме лични данни само колкото е строго необходимо, за да предоставим услугата DocuElevate или да изпълним законови задължения:",
|
||||
"privacy.s8_session_body": "Изтрива се, когато се отключите или след изтичане на времето на сесията.",
|
||||
"privacy.s8_session_label": "Данни за сесията:",
|
||||
"privacy.s9_heading": "9. Сигурност на данните",
|
||||
"privacy.s9_li1": "Шифроване на удостоверителни данни и чувствителна конфигурация в покой.",
|
||||
"privacy.s9_li2": "Защита на трансферния слой (TLS/HTTPS) за всички комуникации.",
|
||||
"privacy.s9_li3": "Контрол на достъпа на основата на роли, ограничаващ достъпа до лични данни.",
|
||||
"privacy.s9_li4": "Редовни проверки на сигурността и сканиране на уязвимости в зависимостите.",
|
||||
"privacy.s9_li5": "CSRF защита на всички заявки, променящи състоянието.",
|
||||
"privacy.s9_p1": "Прилагаме подходящи технически и организационни мерки (TOMs) за защита на вашите лични данни, включително:",
|
||||
"privacy.toc_1": "Администратор на данните",
|
||||
"privacy.toc_10": "Вашите права (ЕС / ЕИП / Обединено кралство / Швейцария)",
|
||||
"privacy.toc_11": "Допълнителни права – Съединени щати (CCPA/CPRA)",
|
||||
"privacy.toc_12": "Допълнителни права – Канада (PIPEDA / Закон 25)",
|
||||
"privacy.toc_13": "Допълнителни права – Латинска Америка (LGPD и други)",
|
||||
"privacy.toc_14": "Допълнителни права – Азия-Тихоокеанския регион и Япония",
|
||||
"privacy.toc_15": "Допълнителни права – Украйна",
|
||||
"privacy.toc_16": "Актуализации на това известие за поверителност",
|
||||
"privacy.toc_2": "Обхват на това известие за поверителност",
|
||||
"privacy.toc_3": "Събиране на данни и цели",
|
||||
"privacy.toc_4": "Минимизиране на данните и ограничаване на целите",
|
||||
"privacy.toc_5": "Използване на бисквитки и подобни технологии",
|
||||
"privacy.toc_6": "Услуги на трети страни",
|
||||
"privacy.toc_7": "Международни трансфери на данни",
|
||||
"privacy.toc_8": "Запазване на данни",
|
||||
"privacy.toc_9": "Сигурност на данните",
|
||||
"privacy.toc_heading": "Съдържание",
|
||||
"profile.avatar_alt": "Вашата снимка на профила",
|
||||
"profile.avatar_heading": "Снимка на профила",
|
||||
"profile.avatar_remove": "Премахнете персонализираната аватара",
|
||||
@@ -838,6 +1377,9 @@
|
||||
"profile.contact_email_label": "Имейл за контакт / известие",
|
||||
"profile.contact_email_placeholder": "you@example.com",
|
||||
"profile.current_password": "Текуща парола",
|
||||
"profile.default_document_language_auto": "Използвай системната по подразбиране",
|
||||
"profile.default_document_language_hint": "Документите на други езици автоматично се превеждат на този език. Оставете празно, за да използвате системната по подразбиране (английски).",
|
||||
"profile.default_document_language_label": "Език по подразбиране за документа",
|
||||
"profile.dismiss": "Отхвърли",
|
||||
"profile.display_name_hint": "Оставете празно, за да използвате името на вашия акаунт или имейл.",
|
||||
"profile.display_name_label": "Име за показване",
|
||||
@@ -1040,14 +1582,66 @@
|
||||
"similarity.subtitle": "Двойки документи с високо семантично сходство, подредени по оценка.",
|
||||
"similarity.trigger_aria": "Задейства изчисляване на вграждания за всички файлове, на които липсват вграждания",
|
||||
"similarity.trigger_now": "задействай сега",
|
||||
"status.active": "Активен",
|
||||
"status.ai_empty_response": "(празен)",
|
||||
"status.ai_extraction_desc": "Поставете текста на документа тук и го пуснете през конфигурирания AI предоставящ, за да инспектирате суровия отговор, извлеченото JSON и таговете.",
|
||||
"status.ai_extraction_failed": "Неуспешно извличане от AI",
|
||||
"status.ai_extraction_label": "Текст на документа",
|
||||
"status.ai_extraction_placeholder": "Поставете текста на документа си тук\u001726",
|
||||
"status.ai_extraction_title": "Тест за извличане от AI",
|
||||
"status.app_version": "Версия на приложението",
|
||||
"status.as_account": "като",
|
||||
"status.auth_required": "Изисква се удостоверяване",
|
||||
"status.build_date": "Дата на изграждане",
|
||||
"status.config_settings": "Настройки на конфигурацията",
|
||||
"status.config_settings_desc": "За по-подробни настройки на конфигурацията и променливи на околната среда, проверете страницата с настройки.",
|
||||
"status.configure_now": "Конфигурирайте сега",
|
||||
"status.configured": "Конфигурирано",
|
||||
"status.connection_error": "Грешка в свързването",
|
||||
"status.connection_test_failed": "Неуспешно тестване на свързването",
|
||||
"status.connection_test_successful": "Тестът за свързване беше успешен",
|
||||
"status.container_id": "ID на контейнера",
|
||||
"status.container_started": "Контейнерът стартира",
|
||||
"status.dashboard_subtitle": "Тази таблица показва статуса на всички конфигурирани интеграции и цели.",
|
||||
"status.debug_mode": "Режим на отстраняване на грешки",
|
||||
"status.error_running_extraction": "Грешка при извличане: ",
|
||||
"status.error_testing_connection": "Грешка при тестване на връзката: ",
|
||||
"status.error_testing_notifications": "Грешка при тестване на известията: ",
|
||||
"status.extracted_tags": "Извлечени тагове",
|
||||
"status.git_commit": "Git комит",
|
||||
"status.inactive": "Неактивен",
|
||||
"status.json_parse_issue": "Проблем при парсиране на JSON: ",
|
||||
"status.last_check": "Последна проверка",
|
||||
"status.manage": "Управление",
|
||||
"status.modal_default_message": "Операцията завърши успешно.",
|
||||
"status.modal_default_title": "Успех",
|
||||
"status.no_details": "Няма налични детайли",
|
||||
"status.not_configured": "Не е конфигуриран",
|
||||
"status.notification_config_missing": "Липсва конфигурация на известията",
|
||||
"status.open": "Отворен",
|
||||
"status.page_title": "Състояние на системата",
|
||||
"status.parsed_json_label": "Парсиран JSON",
|
||||
"status.provider_config_details": "Детайли за конфигурация на {name}",
|
||||
"status.provider_details": "Детайли за доставчика",
|
||||
"status.raw_llm_response": "Суров отговор на LLM",
|
||||
"status.run_extraction": "Извърши извличане",
|
||||
"status.running": "Стартиране\u001e...",
|
||||
"status.sending": "Изпращане...",
|
||||
"status.setting_label": "Настройка",
|
||||
"status.test_connection": "Тест на връзката",
|
||||
"status.test_extraction": "Тест на извличане",
|
||||
"status.test_failed": "Тестът не успя",
|
||||
"status.test_notification_failed": "Тестовото известие не успя",
|
||||
"status.test_notification_sent": "Тестовото известие е изпратено",
|
||||
"status.test_notifications": "Тестови известия",
|
||||
"status.test_provider": "Тест {name}",
|
||||
"status.test_successful": "Тестът беше успешен",
|
||||
"status.testing": "Тестване...",
|
||||
"status.token_expired": "Вашият токен е изтекъл или е невалиден. Моля, пренастройте тази връзка.",
|
||||
"status.token_valid_for": "Токенът е валиден за:",
|
||||
"status.value_label": "Стойност",
|
||||
"status.view_config": "Преглед на подробната конфигурация",
|
||||
"status.view_details": "Преглед на детайлите",
|
||||
"subscription.available_plans_heading": "Налични планове",
|
||||
"subscription.back_to_dashboard": "Обратно към таблото",
|
||||
"subscription.cancel_pending": "Отмяна на промяната",
|
||||
@@ -1083,6 +1677,51 @@
|
||||
"subscription.upgrade_info": "Надстройките влизат в сила незабавно. Намаленията са планирани за края на текущия ви фактурен период.",
|
||||
"subscription.upgrade_to_prefix": "Надстройка до",
|
||||
"subscription.usage_heading": "Използване",
|
||||
"terms.cookie_link": "Политика за бисквитки",
|
||||
"terms.heading": "Условия за ползване",
|
||||
"terms.last_updated": "Последно обновление:",
|
||||
"terms.license_link": "Информация за лиценз",
|
||||
"terms.page_title": "Условия за ползване - DocuElevate",
|
||||
"terms.privacy_link": "Политика за конфиденциалност",
|
||||
"terms.s1_heading": "1. Приемане на условията",
|
||||
"terms.s1_p1": "С достъпа или използването на DocuElevate, вие се съгласявате да бъдете обвързани от тези Условия за ползване. Ако не се съгласявате с тези условия, моля не използвайте този сервис.",
|
||||
"terms.s2_heading": "2. Описание на услугата",
|
||||
"terms.s2_p1": "DocuElevate предоставя услуги за обработка на документи, OCR, извличане на метаданни и услуги за съхранение. Запазваме правото си да променяме или прекратяваме всяка част от услугата по всяко време.",
|
||||
"terms.s3_heading": "3. Отговорности на потребителя",
|
||||
"terms.s3_li1": "Цялото съдържание, което качвате в DocuElevate",
|
||||
"terms.s3_li2": "Осигурявайки, че имате правилните права за качване и обработка на документи",
|
||||
"terms.s3_li3": "Поддържайки конфиденциалността на вашите данни за вход в акаунта",
|
||||
"terms.s3_li4": "Всяка дейност, която се случва под вашия акаунт",
|
||||
"terms.s3_p1": "Вие носите отговорност за:",
|
||||
"terms.s3_p2_and": "и",
|
||||
"terms.s3_p2_post": ".",
|
||||
"terms.s3_p2_pre": "Като използвате нашата услуга, вие също така се съгласявате с нашата",
|
||||
"terms.s4_heading": "4. Права на интелектуалната собственост",
|
||||
"terms.s4_p1": "DocuElevate уважава правата на интелектуалната собственост. Потребителите нямат право да качват съдържание, което нарушава правата на интелектуалната собственост на други.",
|
||||
"terms.s5_heading": "5. Ограничаване на отговорността",
|
||||
"terms.s5_p1": "DocuElevate предоставя услугата \"такова каквото е\" без никакви гаранции. Ние не носим отговорност за никакви пряки, непреки, случайни, специални, последващи или наказателни щети, произтичащи от вашето използване или невъзможност за използване на услугата.",
|
||||
"terms.s6_heading": "6. Приложимо право",
|
||||
"terms.s6_p1": "Тези условия подлежат на законите на Германия, без значение на разпоредбите за конфликт на закони.",
|
||||
"terms.s6_p2_post": ".",
|
||||
"terms.s6_p2_pre": "Ако имате въпроси относно тези условия, моля, свържете се с нас на",
|
||||
"terms.s6_p3_mid": ". За информация за лицензите, моля, посетете нашата",
|
||||
"terms.s6_p3_post": ".",
|
||||
"terms.s6_p3_pre": "За информация относно начина, по който използваме бисквитки, моля, вижте нашата",
|
||||
"translation.copied": "Копирано!",
|
||||
"translation.copy": "Копирай",
|
||||
"translation.default_language_version": "Версия на езика по подразбиране",
|
||||
"translation.detected_language": "Открит език",
|
||||
"translation.hide_text": "Скрий текста",
|
||||
"translation.load_translation": "Зареди превод",
|
||||
"translation.no_translation": "Няма наличен превод \u001908 той все още може да се обработва",
|
||||
"translation.select_language": "Избери език\u001908",
|
||||
"translation.select_target": "Моля, изберете целеви език.",
|
||||
"translation.show_text": "Покажи текста",
|
||||
"translation.translate_btn": "Преведи",
|
||||
"translation.translate_to": "Преведи на друг език",
|
||||
"translation.translated_to": "Преведено на",
|
||||
"translation.translating": "Превеждам\u001908",
|
||||
"translation.translation_failed": "Преводът не е успешен",
|
||||
"upload.browse_button": "Преглед на файлове",
|
||||
"upload.button_processing": "Обработва се...",
|
||||
"upload.camera_button": "Снимай / Сканирай документ",
|
||||
|
||||
@@ -41,6 +41,280 @@
|
||||
"about.story_heading": "আমাদের গল্প",
|
||||
"about.story_p1": "DocuElevate একটি লক্ষ্য নিয়ে তৈরি করা হয়েছে: সবার জন্য নথি ব্যবস্থাপনাকে সহজ এবং সোজা করে তোলা, আপনি একটি ছোট স্টার্টআপ হন বা একটি বড় উদ্যোক্তা।",
|
||||
"about.story_p2": "আমরা মেটাডেটা নিষ্কাশন এবং টেক্সট পরিশোধন үшін প্লাগেবল AI প্রদানকারীদের (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey, এবং আরও অনেক) শক্তি ব্যবহার করি, Dropbox, Nextcloud, এবং Paperless NGX-এর সাথে সংহত করি স্টোরেজ এবং ইনডেক্সিংয়ের জন্য, OCR-এর জন্য Azure Document Intelligence ব্যবহার করি, এবং ফাইল থেকে PDF রূপান্তরের জন্য Gotenberg ব্যবহার করি।",
|
||||
"admin_files.admin_only_badge": "শুধুমাত্র প্রশাসক",
|
||||
"admin_files.aria_breadcrumb": "ব্রেডক্রাম্ব",
|
||||
"admin_files.badge_delta_detected": "ডেলটা সনাক্ত হয়েছে",
|
||||
"admin_files.badge_duplicate": "ডুপ",
|
||||
"admin_files.badge_in_db": "ডিবি তে",
|
||||
"admin_files.badge_on_disk": "ডিস্কে",
|
||||
"admin_files.breadcrumb_workdir": "কর্মক্ষেত্র",
|
||||
"admin_files.btn_download": "ডাউনলোড",
|
||||
"admin_files.col_actions": "ক্রিয়াকলাপ",
|
||||
"admin_files.col_db": "ডিবি",
|
||||
"admin_files.col_health": "স্বাস্থ্য",
|
||||
"admin_files.col_id": "আইডি",
|
||||
"admin_files.col_ingested": "গ্রহণকৃত",
|
||||
"admin_files.col_local_filename": "স্থানীয়_ফাইল_নাম",
|
||||
"admin_files.col_missing_paths": "অনুপস্থিত পাথ",
|
||||
"admin_files.col_modified": "সংশোধিত",
|
||||
"admin_files.col_name": "নাম",
|
||||
"admin_files.col_original_file_path": "মূল_ফাইল_পাথ",
|
||||
"admin_files.col_original_filename": "মূল ফাইলের নাম",
|
||||
"admin_files.col_path_relative": "পাথ (কাজের ডিরেক্টরির সাথে সম্পর্কিত)",
|
||||
"admin_files.col_processed_file_path": "প্রক্রিয়াজাত_ফাইল_পাথ",
|
||||
"admin_files.col_size": "আকার",
|
||||
"admin_files.delta_detected_detail": "ডিস্কে {orphan_count} ক্ষুদ্র ফাইল(গুলি) পাওয়া গেছে যার কোন DB রেকর্ড নেই, এবং {ghost_count} DB রেকর্ড পাওয়া গেছে যার ডিস্কে অনুপস্থিত ফাইল রয়েছে।",
|
||||
"admin_files.delta_detected_title": "ডেল্টা শনাক্ত করা হয়েছে।",
|
||||
"admin_files.empty_database": "ডাটাবেসে কোন ফাইলের রেকর্ড পাওয়া যায়নি।",
|
||||
"admin_files.empty_directory": "এই ডিরেক্টরি শূন্য।",
|
||||
"admin_files.ghost_records_desc": "(DB তে, ডিস্কে ফাইল(গুলি) অনুপস্থিত)",
|
||||
"admin_files.ghost_records_heading": "ভূত রেকর্ড",
|
||||
"admin_files.heading": "ফাইল ম্যানেজার",
|
||||
"admin_files.health_missing": "অনুপস্থিত",
|
||||
"admin_files.health_ok": "ঠিক আছে",
|
||||
"admin_files.legend_file_exists": "ফাইল ডিস্কে বিদ্যমান",
|
||||
"admin_files.legend_file_missing": "ফাইল ডিস্ক থেকে অনুপস্থিত",
|
||||
"admin_files.legend_found_in_db": "DB তে পাওয়া গেছে",
|
||||
"admin_files.legend_not_in_db": "DB তে নেই (ক্ষুদ্র)",
|
||||
"admin_files.legend_path_not_set": "পাথ সেট করা হয়নি",
|
||||
"admin_files.no_delta": "কোন ডেল্টা পাওয়া যায়নি — ফাইল সিস্টেম এবং ডাটাবেস সিঙ্ক।",
|
||||
"admin_files.no_ghost_records": "কোন ভূত রেকর্ড পাওয়া যায়নি।",
|
||||
"admin_files.no_orphan_files": "কোন ক্ষুদ্র ফাইল পাওয়া যায়নি।",
|
||||
"admin_files.orphan_files_desc": "(ডিস্কে, কোন DB রেকর্ড নেই)",
|
||||
"admin_files.orphan_files_heading": "ক্ষুদ্র ফাইল",
|
||||
"admin_files.page_title": "ফাইল ম্যানেজার – প্রশাসক",
|
||||
"admin_files.status_in_db": "DB তে",
|
||||
"admin_files.status_orphan": "ক্ষুদ্র",
|
||||
"admin_files.tab_database": "ডাটাবেস রেকর্ড",
|
||||
"admin_files.tab_filesystem": "ফাইল সিস্টেম",
|
||||
"admin_files.tab_reconcile": "মিলানো",
|
||||
"admin_plans.aria_delete_plan": "{name} মুছে ফেলুন",
|
||||
"admin_plans.aria_edit_plan": "{name} সম্পাদনা করুন",
|
||||
"admin_plans.aria_feature_n": "ফিচার {n}",
|
||||
"admin_plans.aria_move_down": "{name} নিচে সরান",
|
||||
"admin_plans.aria_move_up": "{name} উপরে সরান",
|
||||
"admin_plans.aria_remove_feature_n": "ফিচার {n} মুছে ফেলুন",
|
||||
"admin_plans.btn_add_feature": "ফিচার যোগ করুন",
|
||||
"admin_plans.btn_add_plan": "পরিকল্পনা যোগ করুন",
|
||||
"admin_plans.btn_cancel": "বাতিল করুন",
|
||||
"admin_plans.btn_create": "পরিকল্পনা তৈরি করুন",
|
||||
"admin_plans.btn_delete": "মুছে ফেলুন",
|
||||
"admin_plans.btn_edit": "সম্পাদনা করুন",
|
||||
"admin_plans.btn_restore_defaults": "ডিফল্ট সেটিংস পুনরুদ্ধার করুন",
|
||||
"admin_plans.btn_restore_defaults_title": "চারটি ডিফল্ট পরিকল্পনা পুনরুদ্ধার করুন (শুধুমাত্র যদি এখনও কোনো পরিকল্পনা বিদ্যমান না থাকে)",
|
||||
"admin_plans.btn_restoring": "পুনরুদ্ধার করা হচ্ছে\u0010",
|
||||
"admin_plans.btn_save_changes": "পরিবর্তনগুলো সংরক্ষণ করুন",
|
||||
"admin_plans.btn_save_order": "অর্ডার সংরক্ষণ করুন",
|
||||
"admin_plans.btn_saving": "সংরক্ষণ করা হচ্ছে\u0010",
|
||||
"admin_plans.btn_stripe_setup": "স্ট্রাইপ সেটআপ",
|
||||
"admin_plans.btn_stripe_setup_title": "API কী কনফিগার করার জন্য স্ট্রাইপ সেটআপ উইজার্ড খুলুন এবং পরিকল্পনা সিঙ্ক করুন",
|
||||
"admin_plans.col_actions": "কার্যকলাপ",
|
||||
"admin_plans.col_active": "সক্রিয়",
|
||||
"admin_plans.col_monthly": "মাসিক",
|
||||
"admin_plans.col_monthly_limit": "মাসিক সীমা",
|
||||
"admin_plans.col_order": "অর্ডার",
|
||||
"admin_plans.col_overage_pct": "অতিরিক্ত %",
|
||||
"admin_plans.col_plan": "পরিকল্পনা",
|
||||
"admin_plans.col_yearly": "বার্ষিক",
|
||||
"admin_plans.coming_soon": "শীঘ্রই আসছে",
|
||||
"admin_plans.featured_badge": "ফিচারড",
|
||||
"admin_plans.field_active": "সক্রিয়",
|
||||
"admin_plans.field_allow_overage": "অতিরিক্ত বিলিং অনুমোদন করুন",
|
||||
"admin_plans.field_api_access": "API অ্যাক্সেস",
|
||||
"admin_plans.field_badge_text": "ব্যাজ টেক্সট",
|
||||
"admin_plans.field_buffer": "বাফার:",
|
||||
"admin_plans.field_cta_text": "CTA বোতাম টেক্সট",
|
||||
"admin_plans.field_docs_month": "ডক্স / মাস",
|
||||
"admin_plans.field_featured": "ফিচারড / হাইলাইটেড",
|
||||
"admin_plans.field_lifetime_docs": "লিফটাইম ডক্স",
|
||||
"admin_plans.field_mailboxes": "ইমেল মেইলবক্স",
|
||||
"admin_plans.field_max_file_size": "সর্বোচ্চ ফাইল আকার (এমবি)",
|
||||
"admin_plans.field_name": "নাম",
|
||||
"admin_plans.field_ocr_pages": "OCR পৃষ্ঠা / মাস",
|
||||
"admin_plans.field_overage_doc_price": "অতিরিক্ত মূল্য / ডক ($)",
|
||||
"admin_plans.field_overage_ocr_price": "অতিরিক্ত মূল্য / OCR পৃষ্ঠা ($)",
|
||||
"admin_plans.field_plan_id": "প্ল্যান ID",
|
||||
"admin_plans.field_price_monthly": "মাসিক মূল্য ($)",
|
||||
"admin_plans.field_price_yearly": "বার্ষিক মূল্য ($)",
|
||||
"admin_plans.field_sort_order": "সাজানোর ব্যবস্থা",
|
||||
"admin_plans.field_storage_dests": "স্টোরেজ গন্তব্য",
|
||||
"admin_plans.field_stripe_monthly": "স্ট্রাইপ প্রাইস ID (মাসিক)",
|
||||
"admin_plans.field_stripe_yearly": "স্ট্রাইপ প্রাইস ID (বার্ষিক)",
|
||||
"admin_plans.field_tagline": "ট্যাগলাইন",
|
||||
"admin_plans.field_trial_days": "ট্রায়াল দিন",
|
||||
"admin_plans.free_label": "বিনা মূল্যে",
|
||||
"admin_plans.heading": "প্ল্যান ডিজাইনার",
|
||||
"admin_plans.hint_features": "এই বুলেট পয়েন্টগুলি এই প্ল্যানের মূল্য নির্ধারণ পৃষ্ঠায় প্রদর্শিত হয়।",
|
||||
"admin_plans.hint_plan_id": "ছোট হাতের অক্ষর, তৈরি হওয়ার পরে পরিবর্তন করা যাবে না।",
|
||||
"admin_plans.hint_zero_unlimited": "অসীমের জন্য 0 প্রবেশ করুন।",
|
||||
"admin_plans.js_delete_confirm": "প্ল্যান \"{id}\" মুছে ফেলবেন? এটি পূর্বাবস্থায় ফিরিয়ে নেওয়া যাবে না।",
|
||||
"admin_plans.js_delete_failed": "মোছা ব্যর্থ হয়েছে",
|
||||
"admin_plans.js_failed_load": "প্ল্যান লোড করতে ব্যর্থ হয়েছে",
|
||||
"admin_plans.js_order_saved": "অর্ডার সংরক্ষিত!",
|
||||
"admin_plans.js_plan_created": "প্ল্যান তৈরি হয়েছে!",
|
||||
"admin_plans.js_plan_deleted": "প্ল্যান \"{id}\" মুছে ফেলা হয়েছে।",
|
||||
"admin_plans.js_plan_updated": "প্ল্যান আপডেট হয়েছে!",
|
||||
"admin_plans.js_reorder_failed": "তথ্য পুনরায় অর্ডার করতে ব্যর্থ হয়েছে",
|
||||
"admin_plans.js_save_failed": "সংরক্ষণ ব্যর্থ হয়েছে",
|
||||
"admin_plans.js_seed_confirm": "চারটি ডিফল্ট প্ল্যান সিড করবেন? যদি প্ল্যান ইতিমধ্যেই থাকে তাহলে এটি একটি নিষ্ক্রিয় অপারেশন।",
|
||||
"admin_plans.js_seed_failed": "সিডিং ব্যর্থ হয়েছে",
|
||||
"admin_plans.js_yearly_enter": "সঞ্চয় প্রদর্শনের জন্য বার্ষিক মূল্য প্রবেশ করুন",
|
||||
"admin_plans.js_yearly_save": "মাসিকের বিপরীতে {pct}% সঞ্চয় করুন",
|
||||
"admin_plans.loading": "প্ল্যান লোড হচ্ছে\u0000A",
|
||||
"admin_plans.modal_close_aria": "মোডাল বন্ধ করুন",
|
||||
"admin_plans.modal_create_title": "প্ল্যান যোগ করুন",
|
||||
"admin_plans.modal_edit_title_prefix": "প্ল্যান সম্পাদনা: ",
|
||||
"admin_plans.no_plans_intro": "এখনো কোনো প্ল্যান নেই। ক্লিক করুন",
|
||||
"admin_plans.no_plans_suffix": "চারটি বিল্ট-ইন প্ল্যান সিড করার জন্য।",
|
||||
"admin_plans.overage_0pct": "0% (নিশ্চিত)",
|
||||
"admin_plans.overage_100pct": "100%",
|
||||
"admin_plans.overage_50pct": "৫০%",
|
||||
"admin_plans.overage_announce": "→ ঘোষণা",
|
||||
"admin_plans.overage_buffer_body_prefix": "অতিরিক্ত ব্যাফার হলো",
|
||||
"admin_plans.overage_buffer_body_suffix": "আমরা X ডকস/মাস প্রচার করি কিন্তু শুধুমাত্র প্রয়োগ করি",
|
||||
"admin_plans.overage_buffer_formula": "X × (1 + ব্যাফার%)",
|
||||
"admin_plans.overage_buffer_invisible": "ব্যবহারকারীদের জন্য অদৃশ্য",
|
||||
"admin_plans.overage_buffer_tail": "ডকস। উদাহরণস্বরূপ, ২০% ব্যাফারের সাথে ১৫০-ডক/মাসের একটি পরিকল্পনা ১৮০ ডকসে প্রয়োগ করে। এটি নির্দিষ্ট ঘোষিত সীমাতে কঠোর কাট মুখ বন্ধ করতে রোধ করে, ব্যবহারকারীদের একটি মসৃণ সফট ল্যান্ডিং প্রদান করে।",
|
||||
"admin_plans.overage_buffer_title": "অতিরিক্ত ব্যাফার সম্পর্কে",
|
||||
"admin_plans.overage_docs": "ডকস,",
|
||||
"admin_plans.overage_docs_end": "ডকস",
|
||||
"admin_plans.overage_enforce_at": "এতে প্রয়োগ করুন",
|
||||
"admin_plans.page_title": "পরিকল্পনা ডিজাইনার — ডকুএলিভেট অ্যাডমিন",
|
||||
"admin_plans.section_basic_info": "মৌলিক তথ্য",
|
||||
"admin_plans.section_display": "প্ৰদৰ্শন",
|
||||
"admin_plans.section_features": "ফিচার তালিকা",
|
||||
"admin_plans.section_overage": "অতিরিক্ত ডিজাইনার",
|
||||
"admin_plans.section_pricing": "মূল্য নির্ধারণ",
|
||||
"admin_plans.section_stripe": "স্ট্রাইপ ইন্টেগ্রেশন",
|
||||
"admin_plans.section_volume": "ভলিউম সীমা",
|
||||
"admin_plans.status_active": "সক্রিয়",
|
||||
"admin_plans.status_inactive": "নিষ্ক্রিয়",
|
||||
"admin_plans.stripe_desc_after": "সেগুলি স্বয়ংক্রিয়ভাবে তৈরি করতে। ফ্রি পরিকল্পনার জন্য স্ট্রাইপ প্রাইস আইডি প্রয়োজন হয় না।",
|
||||
"admin_plans.stripe_desc_before": "এই পরিকল্পনার জন্য স্ট্রাইপ প্রাইস আইডি প্রবেশ করান, অথবা ব্যবহার করুন",
|
||||
"admin_plans.stripe_wizard_aria": "নতুন টাবে স্ট্রাইপ সেটআপ উইজার্ড খুলুন",
|
||||
"admin_plans.stripe_wizard_link": "স্ট্রাইপ উইজার্ড",
|
||||
"admin_plans.stripe_wizard_text": "স্ট্রাইপ সেটআপ উইজার্ড",
|
||||
"admin_plans.subheading": "পাবলিক মূল্য নির্ধারণ পৃষ্ঠায় প্রদর্শিত সাবস্ক্রিপশন পরিকল্পনা পরিচালনা করুন।",
|
||||
"admin_plans.table_aria_label": "সাবস্ক্রিপশন পরিকল্পনা",
|
||||
"admin_users.add_user_profile_btn": "ব্যবহারকারীর প্রোফাইল যুক্ত করুন",
|
||||
"admin_users.admin_only_badge": "শুধুমাত্র অ্যাডমিন",
|
||||
"admin_users.btn_password": "পাসওয়ার্ড",
|
||||
"admin_users.btn_reset": "পুনঃরিবুট করুন",
|
||||
"admin_users.col_display_name": "ডিসপ্লে নাম",
|
||||
"admin_users.col_documents": "ডকুমেন্টস",
|
||||
"admin_users.col_email": "ইমেইল",
|
||||
"admin_users.col_last_upload": "শেষ আপলোড",
|
||||
"admin_users.col_plan": "পরিকল্পনা",
|
||||
"admin_users.col_role": "ভূমিকা",
|
||||
"admin_users.col_upload_limit": "আপলোড সীমা",
|
||||
"admin_users.col_user_id": "ব্যবহারকারী আইডি",
|
||||
"admin_users.col_username": "ব্যবহারকারীর নাম",
|
||||
"admin_users.create_local_account_btn": "স্থানীয় অ্যাকাউন্ট তৈরি করুন",
|
||||
"admin_users.create_local_title": "স্থানীয় অ্যাকাউন্ট তৈরি করুন",
|
||||
"admin_users.delete_account_btn": "অ্যাকাউন্ট মুছুন",
|
||||
"admin_users.delete_local_confirm": "আপনি কি সত্যি অনুমোদন করছেন যে আপনি",
|
||||
"admin_users.delete_local_title": "স্থানীয় অ্যাকাউন্ট মুছুন",
|
||||
"admin_users.delete_local_warning": "এটি পূর্বাবস্থায় ফিরিয়ে নেওয়া যাবে না। এই ব্যবহারকারীর মালিকানাধীন ডকুমেন্টস মুছে ফেলা হবে না।",
|
||||
"admin_users.delete_profile_btn": "প্রোফাইল মুছুন",
|
||||
"admin_users.delete_profile_confirm": "আপনি কি সত্যিই নিশ্চিত যে আপনি প্রোফাইল মুছতে চান",
|
||||
"admin_users.delete_profile_title": "ব্যবহারকারীর প্রোফাইল মুছুন",
|
||||
"admin_users.delete_profile_warning": "এটি শুধুমাত্র অ্যাডমিন দ্বারা পরিচালিত প্রোফাইল রেকর্ড মুছে দেয়। এই ব্যবহারকারীর মালিকানাধীন ডকুমেন্টস মুছে ফেলা হবে না।",
|
||||
"admin_users.deleting": "মুছে ফেলা হচ্ছে\n",
|
||||
"admin_users.edit_local_title": "স্থানীয় অ্যাকাউন্ট সম্পাদনা করুন",
|
||||
"admin_users.filter_placeholder": "ব্যবহারকারী আইডি দ্বারা ফিল্টার করুন\u0000A",
|
||||
"admin_users.global_default": "গ্লোবাল ডিফল্ট",
|
||||
"admin_users.heading": "ব্যবহারকারী ব্যবস্থাপনা",
|
||||
"admin_users.js_account_created": "অ্যাকাউন্ট তৈরি হয়েছে",
|
||||
"admin_users.js_account_created_msg": "\"{username}\" এর জন্য স্থানীয় অ্যাকাউন্ট সফলভাবে তৈরি হয়েছে।",
|
||||
"admin_users.js_account_deleted_msg": "\"{username}\" এর জন্য অ্যাকাউন্ট মুছে ফেলা হয়েছে।",
|
||||
"admin_users.js_account_updated": "অ্যাকাউন্ট আপডেট হয়েছে।",
|
||||
"admin_users.js_delete_failed": "মুছতে ব্যর্থ হয়েছে",
|
||||
"admin_users.js_deleted": "মুছে ফেলা হয়েছে",
|
||||
"admin_users.js_email_not_sent": "ইমেইল পাঠানো হয়নি",
|
||||
"admin_users.js_email_sent": "ইমেইল পাঠানো হয়েছে",
|
||||
"admin_users.js_email_sent_msg": "\"{email}\" এ পাসওয়ার্ড পুনরায় সেট করার জন্য ইমেইল পাঠানো হয়েছে।",
|
||||
"admin_users.js_failed": "ব্যর্থ",
|
||||
"admin_users.js_failed_create": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ।",
|
||||
"admin_users.js_failed_load_local": "স্থানীয় ব্যবহারকারীদের লোড করতে ব্যর্থ",
|
||||
"admin_users.js_failed_load_users": "ব্যবহারকর্তাদের লোড করতে ব্যর্থ",
|
||||
"admin_users.js_failed_set_password": "পাসওয়ার্ড সেট করতে ব্যর্থ।",
|
||||
"admin_users.js_failed_update": "অ্যাকাউন্ট আপডেট করতে ব্যর্থ।",
|
||||
"admin_users.js_network_error": "নেটওয়ার্ক ত্রুটি",
|
||||
"admin_users.js_password_set": "পাসওয়ার্ড সেট হয়েছে",
|
||||
"admin_users.js_password_set_msg": "\"{username}\" এর জন্য পাসওয়ার্ড আপডেট করা হয়েছে।",
|
||||
"admin_users.js_profile_deleted": "\"{id}\" এর জন্য প্রফাইল মুছে ফেলা হয়েছে।",
|
||||
"admin_users.js_profile_saved": "\"{id}\" এর জন্য প্রফাইল সংরক্ষিত হয়েছে।",
|
||||
"admin_users.js_save_failed": "সংরক্ষণ করতে ব্যর্থ",
|
||||
"admin_users.js_saved": "সংরক্ষিত",
|
||||
"admin_users.js_smtp_not_configured": "SMTP কনফিগার করা হয়নি।",
|
||||
"admin_users.js_updated": "হালনাগাদ হয়েছে",
|
||||
"admin_users.loading_users": "ব্যবহারকারীদের লোড হচ্ছে\u0005",
|
||||
"admin_users.local_account_active": "অ্যাকাউন্ট সক্রিয়",
|
||||
"admin_users.local_accounts_heading": "স্থানীয় ব্যবহারকারী অ্যাকাউন্ট",
|
||||
"admin_users.local_accounts_subheading": "এই সার্ভারে সরাসরি তৈরি করা ইমেইল/পাসওয়ার্ড অ্যাকাউন্ট।",
|
||||
"admin_users.local_admin_privileges": "অ্যাডমিন অধিকার প্রদান করুন",
|
||||
"admin_users.local_admin_privileges_short": "অ্যাডমিন অধিকার",
|
||||
"admin_users.local_create_btn": "অ্যাকাউন্ট তৈরি করুন",
|
||||
"admin_users.local_create_one": "একটি তৈরি করুন।",
|
||||
"admin_users.local_creating": "তৈরি হচ্ছে\u0005",
|
||||
"admin_users.local_display_name_optional": "(ঐচ্ছিক)",
|
||||
"admin_users.local_loading": "লোড হচ্ছে\u0005",
|
||||
"admin_users.local_no_accounts": "এখনো কোন স্থানীয় অ্যাকাউন্ট নেই।",
|
||||
"admin_users.local_password_hint": "কমপক্ষে 8 অক্ষর।",
|
||||
"admin_users.local_saving": "সংরক্ষণ হচ্ছে\u0005",
|
||||
"admin_users.local_username_hint": "৩–৬৪ অক্ষর। শুধু লেটার, সংখ্যা, হাইফেন এবং আন্ডারস্কোর।",
|
||||
"admin_users.modal_add_title": "ব্যবহারকারী প্রফাইল যোগ করুন",
|
||||
"admin_users.modal_billing_cycle_label": "বিলিং সাইকেল",
|
||||
"admin_users.modal_billing_monthly": "মাসিক",
|
||||
"admin_users.modal_billing_yearly": "বার্ষিক",
|
||||
"admin_users.modal_block_hint": "(নতুন ডকুমেন্ট আপলোড প্রতিরোধ করে)",
|
||||
"admin_users.modal_block_label": "এই ব্যবহারকারীকে ব্লক করুন",
|
||||
"admin_users.modal_close_aria": "ডায়লগ বন্ধ করুন",
|
||||
"admin_users.modal_complimentary_hint": "(ব্যবহারকারী স্তরের সুবিধাসমূহ বজায় রাখে কিন্তু কখনই বিল হতে হয় না — অ্যাডমিন অ্যাকাউন্টের জন্য স্বয়ংক্রিয়ভাবে সেট করুন)",
|
||||
"admin_users.modal_complimentary_label": "কমপ্লিমেন্টারি পরিকল্পনা",
|
||||
"admin_users.modal_daily_limit_hint": "(গ্লোবাল ডিফল্ট ব্যবহার করতে খালি রাখুন)",
|
||||
"admin_users.modal_daily_limit_label": "দৈনিক আপলোড সীমা",
|
||||
"admin_users.modal_daily_limit_placeholder": "যেমন ৫০ (০ = সীমাহীন)",
|
||||
"admin_users.modal_display_name_label": "ডিসপ্লে নাম",
|
||||
"admin_users.modal_display_name_placeholder": "অ্যালিস স্মিথ (বিকল্প)",
|
||||
"admin_users.modal_edit_title": "ব্যবহারকারীর প্রফাইল সম্পাদনা করুন",
|
||||
"admin_users.modal_notes_label": "অ্যাডমিন নোটস",
|
||||
"admin_users.modal_notes_placeholder": "অভ্যন্তরীণ নোট যা শুধুমাত্র অ্যাডমিনদের জন্য দৃশ্যমান\u0000...",
|
||||
"admin_users.modal_period_start_hint": "বার্ষিক ক্যারি-ওভার এই তারিখ থেকে গণনা করা হয়। মাসিক বাস্তবায়নের জন্য ফাঁকা রাখুন।",
|
||||
"admin_users.modal_period_start_label": "সাবস্ক্রিপশন পিরিয়ড শুরু",
|
||||
"admin_users.modal_plan_business": "ব্যবসা $7.99/মাস (300/মাস, সীমাহীন মেইলবক্স)",
|
||||
"admin_users.modal_plan_free": "ফ্রি 25 স্থায়ী ফাইল",
|
||||
"admin_users.modal_plan_hint": "এই ব্যবহারকারীর জন্য কোটা সীমা নির্ধারণ করে। সীমাগুলি আপলোডে বাস্তবায়িত হয়।",
|
||||
"admin_users.modal_plan_label": "সাবস্ক্রিপশন পরিকল্পনা",
|
||||
"admin_users.modal_plan_professional": "পেশাদার $5.99/মাস (150/মাস, 3 মেইলবক্স)",
|
||||
"admin_users.modal_plan_starter": "স্টার্টার $2.99/মাস (50/মাস, 1 মেইলবক্স)",
|
||||
"admin_users.modal_save_changes": "পরিবর্তন সেভ করুন",
|
||||
"admin_users.modal_saving": "সেভ হচ্ছে\u0000...",
|
||||
"admin_users.modal_user_id_hint": "এটি একটি স্থিতিশীল শনাক্তকারী যা নথিতে owner_id এর সাথে মিলে।",
|
||||
"admin_users.modal_user_id_label": "ব্যবহারকারী আইডি",
|
||||
"admin_users.modal_user_id_placeholder": "user@example.com অথবা OAuth সাব",
|
||||
"admin_users.new_account_btn": "নতুন অ্যাকাউন্ট",
|
||||
"admin_users.new_password_label": "নতুন পাসওয়ার্ড",
|
||||
"admin_users.no_users_add_hint": "কিছু নথি আপলোড করুন অথবা উপরে একটি প্রফাইল যোগ করুন।",
|
||||
"admin_users.no_users_found": "কোন ব্যবহারকারী পাওয়া যায়নি।",
|
||||
"admin_users.no_users_search_hint": "একটি ভিন্ন অনুসন্ধান শব্দ চেষ্টা করুন।",
|
||||
"admin_users.page_title": "ব্যবহারকারী ব্যবস্থাপনা অ্যাডমিন ডোকুএলিভেট",
|
||||
"admin_users.pagination_page_of": "এর",
|
||||
"admin_users.per_day": "/ দিন",
|
||||
"admin_users.role_admin": "অ্যাডমিন",
|
||||
"admin_users.role_user": "ব্যবহারকারী",
|
||||
"admin_users.search_users_label": "ব্যবহারকারীদের অনুসন্ধান করুন",
|
||||
"admin_users.set_password_btn": "পাসওয়ার্ড সেট করুন",
|
||||
"admin_users.set_password_desc": "ব্যবহারকারী লগ ইন করার পরে এই পাসওয়ার্ড পরিবর্তন করবে।",
|
||||
"admin_users.set_password_desc_pre": "এর জন্য সরাসরি একটি নতুন পাসওয়ার্ড সেট করুন",
|
||||
"admin_users.set_password_title": "অস্থায়ী পাসওয়ার্ড সেট করুন",
|
||||
"admin_users.setting": "সেটিং\u0000...",
|
||||
"admin_users.status_blocked": "ব্লকড",
|
||||
"admin_users.status_unverified": "অপ証িত",
|
||||
"admin_users.subheading": "ব্যবহারকারী প্রফাইল, প্রতি ব্যবহারকারীর আপলোড সীমা এবং নথির মালিকানা পরিচালনা করুন।",
|
||||
"admin_users.total_count_users": "{count} ব্যবহারকারী",
|
||||
"admin_users.total_no_users": "কোন ব্যবহারকারী নেই",
|
||||
"admin_users.total_one_user": "1 ব্যবহারকারী",
|
||||
"api_tokens.col_created": "তৈরি করা হয়েছে",
|
||||
"api_tokens.col_last_ip": "শেষ আইপিএস",
|
||||
"api_tokens.col_last_used": "শেষ ব্যবহার",
|
||||
@@ -73,6 +347,19 @@
|
||||
"api_tokens.usage_intro_pre": "আপনার API টোকেন ব্যবহার করুন",
|
||||
"api_tokens.your_tokens": "আপনার টোকেন",
|
||||
"app.name": "DocuElevate",
|
||||
"attribution.heading": "থার্ড-পার্টি সফটওয়্যার অ্যাট্রিবিউশন",
|
||||
"attribution.intro": "DocuElevate বেশ কয়েকটি ওপেন সোর্স লাইব্রেরি এবং টুল ব্যবহার করে। আমরা ওপেন সোর্স সফটওয়্যারে তাদের অবদান জন্য এই প্রকল্পের উন্নয়নকারীদের প্রতি কৃতজ্ঞ।",
|
||||
"attribution.page_title": "DocuElevate - থার্ড-পার্টি অ্যাট্রিবিউশন",
|
||||
"attribution.paramiko_lgpl_note": "দ্রষ্টব্য: এই লাইব্রেরিটি GNU লেসার জেনারেল পাবলিক লাইসেন্স v2.1 (LGPL-2.1) এর অধীনে লাইসেন্সপ্রাপ্ত।",
|
||||
"attribution.section_docker": "ডকার ইমেজ",
|
||||
"attribution.section_frontend": "ফ্রন্টএন্ড নির্ভরতাসমূহ",
|
||||
"attribution.section_python": "পাইথন নির্ভরতাসমূহ",
|
||||
"attribution.special_lgpl_link": "এখানে",
|
||||
"attribution.special_lgpl_post": ".",
|
||||
"attribution.special_lgpl_pre": "LGPL লাইসেন্সের একটি কপি পাওয়া যাবে",
|
||||
"attribution.special_source_post": ".",
|
||||
"attribution.special_source_pre": "এই সফটওয়্যারটি Paramiko অন্তর্ভুক্ত করে, যা LGPL এর অধীনে লাইসেন্সপ্রাপ্ত। Paramiko এর সোর্স কোড পাওয়া যাবে",
|
||||
"attribution.special_title": "বিশেষ অ্যাট্রিবিউশন:",
|
||||
"audit.col_ip": "আইপি",
|
||||
"audit.col_resource": "সম্পদ",
|
||||
"audit.col_timestamp": "টাইমস্ট্যাম্প",
|
||||
@@ -252,6 +539,41 @@
|
||||
"cookie.notice_label": "কুকি নোটিশ",
|
||||
"cookie.policy_link": "কুকি নীতি",
|
||||
"cookie.privacy_link": "গোপনীয়তা নোটিশ",
|
||||
"cookie_policy.heading": "কুকি নীতি",
|
||||
"cookie_policy.last_updated": "সর্বশেষ আপডেট:",
|
||||
"cookie_policy.page_title": "কুকি নীতি - DocuElevate",
|
||||
"cookie_policy.s1_heading": "কুকি কী",
|
||||
"cookie_policy.s1_p1": "কুকি হল ছোট টেক্সট ফাইল যা আপনি একটি ওয়েবসাইট পরিদর্শন করার সময় আপনার কম্পিউটার বা মোবাইল ডিভাইসে সংরক্ষিত হয়। এগুলি ব্যাপকভাবে ওয়েবসাইটগুলি আরও দক্ষতার সাথে কাজ করার এবং ওয়েবসাইটের মালিকদের জন্য তথ্য সরবরাহ করার জন্য ব্যবহৃত হয়।",
|
||||
"cookie_policy.s2_heading": "আমরা কুকিগুলি কীভাবে ব্যবহার করি",
|
||||
"cookie_policy.s2_li1_body": "আপনি লগইন করার সময় আপনাকে চিহ্নিত করার এবং আপনি অ্যাপ্লিকেশনটি ব্যবহার করার সময় আপনার সেশন বজায় রাখার জন্য।",
|
||||
"cookie_policy.s2_li1_label": "সত্যায়ন ও সেশন পরিচালনা:",
|
||||
"cookie_policy.s2_p1_post": "নিচে উল্লেখিত উদ্দেশ্যগুলির জন্য:",
|
||||
"cookie_policy.s2_p1_pre": "DocuElevate ব্যবহার করে",
|
||||
"cookie_policy.s2_p1_strong": "শুধুমাত্র অত্যাবশ্যকীয় সেশন কুকি",
|
||||
"cookie_policy.s2_p2": "এগুলি আমাদের পরিষেবার সঠিক কার্যকারিতার জন্য বাধ্যতামূলক। এই কুকিগুলি ছাড়া, আপনাকে আপনার ব্রাউজিং সেশনের সময় বারবার লগইন করতে হবে।",
|
||||
"cookie_policy.s2_p3": "কারণ এই কুকিগুলি পরিষেবার কার্যকারিতার জন্য অত্যাবশ্যকীয়, সেগুলি EU ePrivacy নির্দেশিকা (Art. 5(3)) এবং সমতুল্য জাতীয় বাস্তবায়নের অধীনে পূর্ব-সম্মতির প্রয়োজনীয়তা থেকে অব্যাহতিপ্রাপ্ত। আমরা কোন ঐচ্ছিক, বিশ্লেষণাত্মক, বিজ্ঞাপন বা ট্র্যাকিং কুকি সেট করি না।",
|
||||
"cookie_policy.s3_col_duration": "মেয়াদকাল",
|
||||
"cookie_policy.s3_col_name": "নাম",
|
||||
"cookie_policy.s3_col_purpose": "উদ্দেশ্য",
|
||||
"cookie_policy.s3_col_type": "প্রকার",
|
||||
"cookie_policy.s3_heading": "কুকির বিস্তারিত",
|
||||
"cookie_policy.s3_row1_duration": "সেশন (ব্রাউজার বন্ধ বা লগআউট করার সময় মুছে ফেলা হয়)",
|
||||
"cookie_policy.s3_row1_purpose": "আপনার প্রমাণীকৃত সেশন বজায় রাখে; লগইন কার্যকর করার জন্য প্রয়োজনীয়।",
|
||||
"cookie_policy.s3_row1_type": "অত্যাবশ্যকীয়",
|
||||
"cookie_policy.s3_row2_duration": "স্থায়ী (ব্রাউজার লোকালস্টোরেজ)",
|
||||
"cookie_policy.s3_row2_purpose": "কুকি বিজ্ঞপ্তির প্রতি আপনার স্বীকৃতি সংরক্ষণ করে যাতে এটি বারবার প্রদর্শিত না হয় (লোকালস্টোরেজে সংরক্ষিত, কুকি নয়)।",
|
||||
"cookie_policy.s3_row2_type": "অত্যাবশ্যকীয়",
|
||||
"cookie_policy.s4_heading": "কোন থার্ড-পার্টি কুকি নেই",
|
||||
"cookie_policy.s4_p1": "DocuElevate কোন থার্ড-পার্টি কুকি, ট্র্যাকিং কুকি, বিজ্ঞাপন কুকি, বা বিশ্লেষণাত্মক কুকি ব্যবহার করে না। আমরা আপনার গোপনীয়তার সম্মান করি এবং আমাদের পরিষেবা কার্যকর করার জন্য প্রয়োজনীয় কুকিগুলি শুধুমাত্র প্রয়োগ করি।",
|
||||
"cookie_policy.s4_p2_pre": "আপনার তথ্য আমরা কীভাবে পরিচালনা করি সে সম্পর্কে আরও তথ্যের জন্য, অনুগ্রহ করে আমাদের দেখুন",
|
||||
"cookie_policy.s4_privacy_link": "গোপনীয়তা বিজ্ঞপ্তি",
|
||||
"cookie_policy.s5_heading": "কুকি পরিচালনা",
|
||||
"cookie_policy.s5_p1": "বেশিরভাগ ওয়েব ব্রাউজার আপনাকে তাদের সেটিংসের মাধ্যমে কুকি নিয়ন্ত্রণ করার অনুমতি দেয়। তবে, আমাদের সেশন কুকিগুলি ব্লক বা মুছে ফেলা হলে DocuElevate কাজ করতে পারবে না, কারণ ব্যবহারকারী প্রমাণীকরণ এই কুকিগুলির উপর নির্ভর করে।",
|
||||
"cookie_policy.s5_p2": "আপনি আপনার ব্রাউজারের ডেভেলপার টুলস (অ্যাপ্লিকেশন → লোকাল স্টোরেজ) এর মাধ্যমে যে কোনও সময় আপনার ব্রাউজারের লোকালস্টোরেজে সংরক্ষিত কুকি বিজ্ঞপ্তি গ্রহণযোগ্যতা পরিষ্কার করতে পারেন।",
|
||||
"cookie_policy.s5_p3_and": "এবং",
|
||||
"cookie_policy.s5_p3_pre": "এই কুকি নীতি আমাদের",
|
||||
"cookie_policy.s5_privacy_link": "গোপনীয়তা বিজ্ঞপ্তি",
|
||||
"cookie_policy.s5_terms_link": "সেবা শর্তাবলী",
|
||||
"credentials.col_action": "কর্ম",
|
||||
"credentials.col_credential": "প্রমাণপত্র",
|
||||
"credentials.col_source": "সূত্র",
|
||||
@@ -517,6 +839,31 @@
|
||||
"help.workflows_step_5_create": "সংরক্ষণ করুন - নতুন ডকুমেন্টগুলি স্বয়ংক্রিয়ভাবে এই পাইপলাইন মারফত প্রক্রিয়া হবে।",
|
||||
"help.workflows_typical_steps": "সাধারণ পদক্ষেপ",
|
||||
"help.workflows_what_is": "পাইপলাইন কী?",
|
||||
"imprint.business_registration_heading": "বিজনেস রেজিস্ট্রেশন",
|
||||
"imprint.business_registration_vat": "ভ্যাট শনাক্তকরণ নম্বর §27a মূল্য সংযোজন কর আইনের অধীনে:",
|
||||
"imprint.contact_heading": "যোগাযোগের তথ্য",
|
||||
"imprint.dispute_heading": "অনলাইন বিরোধ সমাধান",
|
||||
"imprint.dispute_p1": "ইউরোপীয় কমিশন অনলাইন বিরোধ সমাধানের জন্য একটি প্ল্যাটফর্ম প্রদান করে (OS):",
|
||||
"imprint.dispute_p2": "আমরা একজন ভোক্তার মধ্যস্থতা বোর্ডের সামনে বিরোধ সমাধান প্রক্রিয়ায় অংশগ্রহণের জন্য ইচ্ছুক বা বাধ্য নই।",
|
||||
"imprint.heading": "ইমプリন্ট",
|
||||
"imprint.legal_copyright": "এই ওয়েবসাইটের সব সামগ্রী কপিরাইট দ্বারা সুরক্ষিত। কপিরাইট আইনের সীমার বাইরে ব্যবহার করার জন্য সংশ্লিষ্ট লেখক বা সৃষ্টিকারকের লিখিত সম্মতি প্রয়োজন।",
|
||||
"imprint.legal_heading": "আইনি বিজ্ঞপ্তি",
|
||||
"imprint.legal_liability": "যথাযথ সামগ্রী নিয়ন্ত্রণ সত্ত্বেও, আমরা বাইরের লিঙ্কগুলির সামগ্রীর জন্য কোনও দায়িত্ব স্বীকার করি না। সংযুক্ত পৃষ্ঠাগুলির অপারেটররা তাঁদের সামগ্রীর জন্য একমাত্র দায়ী।",
|
||||
"imprint.page_title": "ইমপ্রিন্ট - DocuElevate",
|
||||
"imprint.policies_cookie_desc": "আমরা যে কুকিগুলি ব্যবহার করি সেবিষয়ে তথ্য",
|
||||
"imprint.policies_cookie_label": "কুকি নীতি",
|
||||
"imprint.policies_heading": "সম্পর্কিত নীতি",
|
||||
"imprint.policies_intro": "আমাদের পরিষেবা নিম্নলিখিত নীতির দ্বারা পরিচালিত হয়:",
|
||||
"imprint.policies_license_desc": "কীভাবে আমাদের সফ্টওয়্যার লাইসেন্স করা হয়",
|
||||
"imprint.policies_license_label": "লাইসেন্স তথ্য",
|
||||
"imprint.policies_privacy_desc": "আমরা আপনার তথ্য কীভাবে পরিচালনা করি",
|
||||
"imprint.policies_privacy_label": "গোপনীয়তা নীতি",
|
||||
"imprint.policies_terms_desc": "DocuElevate ব্যবহারের জন্য নিয়ম",
|
||||
"imprint.policies_terms_label": "সেবা শর্তাবলী",
|
||||
"imprint.provider_heading": "সেবা প্রদানকারী",
|
||||
"imprint.responsible_content_heading": "সামগ্রীর জন্য দায়িত্বশীল",
|
||||
"imprint.responsible_content_rstv": "§ 55 Abs. 2 RStV অনুসারে:",
|
||||
"imprint.subtitle": "TMG (জার্মান টেলিমিডিয়া অ্যাক্ট) এর § 5 অনুসারে তথ্য",
|
||||
"index.badge_intelligent": "বুদ্ধিমান ডকুমেন্ট প্রক্রিয়াকরণ",
|
||||
"index.button_browse_files": "ফাইল ব্রাউজ করুন",
|
||||
"index.button_upload": "আপলোড করুন",
|
||||
@@ -728,6 +1075,19 @@
|
||||
"language.tr": "তুর্কিশ",
|
||||
"language.uk": "ইউক্রেনীয়",
|
||||
"language.zh": "চীনা",
|
||||
"license.apache_description": "DocuElevate অ্যাপাচি লাইসেন্স 2.0 এর অধীনে বিতরণ করা হয়, যা একটি অনুমোদিত ওপেন-সোর্স সফ্টওয়্যার লাইসেন্স যা আপনাকে এটি ব্যবহার, পরিবর্তন, বিতরণ এবং প্রকল্পের জন্য অবদান রাখতে দেয়।",
|
||||
"license.apache_heading": "অ্যাপাচি লাইসেন্স 2.0",
|
||||
"license.heading": "লাইসেন্স তথ্য",
|
||||
"license.page_title": "লাইসেন্স তথ্য - DocuElevate",
|
||||
"license.related_about_link": "সম্পর্কিত পৃষ্ঠা",
|
||||
"license.related_and": "এবং",
|
||||
"license.related_heading": "সম্পর্কিত তথ্য",
|
||||
"license.related_p1_post": "ডোকুএলিভেট পরিষেবা ব্যবহারের তথ্যের জন্য।",
|
||||
"license.related_p1_pre": "যেহেতু এই লাইসেন্স আমাদের সফ্টওয়্যার ব্যবহারের নিয়ন্ত্রণ করে, অনুগ্রহ করে আমাদের",
|
||||
"license.related_p2_post": "।",
|
||||
"license.related_p2_pre": "ডোকুএলিভেট সম্পর্কে আরও তথ্যের জন্য, অনুগ্রহ করে যান",
|
||||
"license.related_privacy_link": "গোপনীয়তা নীতি",
|
||||
"license.related_terms_link": "সেবার শর্তাবলী",
|
||||
"nav.about": "সম্বন্ধে",
|
||||
"nav.account_menu": "অ্যাকাউন্ট মেনু",
|
||||
"nav.account_menu_for": "{name} এর জন্য অ্যাকাউন্ট মেনু",
|
||||
@@ -828,6 +1188,185 @@
|
||||
"pipelines.system_label": "সিস্টেম",
|
||||
"pipelines.system_pipeline_label": "সিস্টেম পাইপলাইন (সব ব্যবহারকারীর জন্য দৃশ্যমান)",
|
||||
"pipelines.title": "প্রক্রিয়াকরণ পাইপলাইন",
|
||||
"privacy.heading": "ডোকুএলিভেট – গোপনীয়তা বিজ্ঞপ্তি",
|
||||
"privacy.last_updated": "শেষ আপডেট:",
|
||||
"privacy.page_title": "গোপনীয়তা বিজ্ঞপ্তি - ডোকুএলিভেট",
|
||||
"privacy.s10_access_body": "আপনি আমাদের কাছে আপনার সম্পর্কে ধারণ করা ব্যক্তিগত ডেটার একটি কপি চাওয়ার অনুরোধ করতে পারেন।",
|
||||
"privacy.s10_access_label": "অ্যাক্সেসের অধিকার (আর্ট। 15):",
|
||||
"privacy.s10_complaint_body": "আপনার জাতীয় ডেটা সুরক্ষা কর্তৃপক্ষের কাছে অভিযোগ জানানোর অধিকার রয়েছে (ডিপিএ)। জার্মানিতে: Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI)। যুক্তরাজ্যে: Information Commissioner's Office (ICO)। সুইস-এ: Federal Data Protection and Information Commissioner (FDPIC)।",
|
||||
"privacy.s10_complaint_label": "অভিযোগ দায়েরের অধিকার:",
|
||||
"privacy.s10_contact": "উপরোক্ত কোনও অধিকার প্রয়োগ করতে, আমাদের যোগাযোগ करें",
|
||||
"privacy.s10_erasure_body": "আপনি যদি কোনও প্রবল আইনগত কারণে এটি রাখার প্রয়োজন না হয় তবে আপনার ব্যক্তিগত ডেটার মুছে ফেলার জন্য নির্দেশ দিতে পারেন।",
|
||||
"privacy.s10_erasure_label": "মুছে ফেলার অধিকার (আর্ট। 17):",
|
||||
"privacy.s10_heading": "10. আপনার অধিকার (ইউরোপীয় ইউনিয়ন / ইইএ / যুক্তরাজ্য / সুইজারল্যান্ড)",
|
||||
"privacy.s10_object_body": "আপনি যে কোনও সময় বৈধ আগ্রহের ভিত্তিতে প্রক্রিয়াকরণের বিরুদ্ধে আপত্তি জানাতে পারেন।",
|
||||
"privacy.s10_object_label": "আপত্তি জানানোর অধিকার (আর্ট। 21):",
|
||||
"privacy.s10_p1": "জিডিপিআর (এবং যুক্তরাজ্য জিডিপিআর / সুইস nFADP সমতুল্য) অনুযায়ী, আপনার নিম্নলিখিত অধিকার রয়েছে:",
|
||||
"privacy.s10_portability_body": "আপনি একটি গঠনমূলক, সাধারণভাবে ব্যবহৃত, মেশিন-পাঠযোগ্য ফরম্যাটে আপনার তথ্যের জন্য অনুরোধ করতে পারেন।",
|
||||
"privacy.s10_portability_label": "তথ্য পোর্টেবিলিটির অধিকার (আর্ট। 20):",
|
||||
"privacy.s10_rectification_body": "আপনি অযথাযথ বা অসম্পূর্ণ ব্যক্তিগত তথ্যের সংশোধনের জন্য অনুরোধ করতে পারেন।",
|
||||
"privacy.s10_rectification_label": "সংশোধনের অধিকার (আর্ট। 16):",
|
||||
"privacy.s10_response": "আমরা এক ক্যালেন্ডার মাসের মধ্যে উত্তর দেব (জটিল অনুরোধের জন্য আরও দুটি মাস বাড়ানো যেতে পারে)।",
|
||||
"privacy.s10_restriction_body": "আপনি কিছু পরিস্থিতিতে আপনার ডেটার প্রক্রিয়াকরণ অল্প সময়ের জন্য স্থগিত রাখার জন্য আমাদের অনুরোধ করতে পারেন।",
|
||||
"privacy.s10_restriction_label": "সীমাবদ্ধতার অধিকার (আর্ট। 18):",
|
||||
"privacy.s10_withdraw_body": "যখন প্রক্রিয়াকরণ সম্মতির ভিত্তিতে হয়, তখন আপনি পূর্ববর্তী প্রক্রিয়াকরণের আইনগততা প্রভাবিত না করে যে কোনও সময় সেই সম্মতি প্রত্যাহার করতে পারেন।",
|
||||
"privacy.s10_withdraw_label": "সম্মতি প্রত্যাহারের অধিকার:",
|
||||
"privacy.s11_categories_body": "আইডেন্টিফায়ার (নাম, ইমেইল), অ্যাকাউন্ট প্রমাণীকরণ টোকেন এবং যে ডকুমেন্ট মেটাডেটা আপনি আপলোড করার জন্য নির্বাচন করেন।",
|
||||
"privacy.s11_categories_label": "সংকলিত ব্যক্তিগত তথ্যের বিভাগ:",
|
||||
"privacy.s11_contact": "একটি যাচাইযোগ্য ভোক্তা অনুরোধ জমা দিতে, আমাদের যোগাযোগ করুন",
|
||||
"privacy.s11_correct_body": "আপনি অযথাযথ ব্যক্তিগত তথ্যের সংশোধনের জন্য অনুরোধ করতে পারেন।",
|
||||
"privacy.s11_correct_label": "সংশোধনের অধিকার:",
|
||||
"privacy.s11_delete_body": "আপনি আমাদের দ্বারা সংগৃহীত ব্যক্তিগত তথ্য মুছে ফেলার জন্য অনুরোধ করতে পারেন, নির্দিষ্ট ব্যতিক্রমের আওতায়।",
|
||||
"privacy.s11_delete_label": "মুছার অধিকার:",
|
||||
"privacy.s11_heading": "11. অতিরিক্ত অধিকার – মার্কিন যুক্তরাষ্ট্র (CCPA / CPRA)",
|
||||
"privacy.s11_know_body": "আপনি আমাদের কাছে আপনার সম্পর্কে সংগৃহীত ব্যক্তিগত তথ্যের বিভাগ এবং নির্দিষ্ট টুকরোর প্রকাশের জন্য অনুরোধ করতে পারেন।",
|
||||
"privacy.s11_know_label": "জানার অধিকার:",
|
||||
"privacy.s11_limit_body": "আমরা পরিষেবা প্রদান করতে যা প্রয়োজন তার চেয়ে বেশি সংবেদনশীল ব্যক্তিগত তথ্য ব্যবহার করি না।",
|
||||
"privacy.s11_limit_label": "সংবেদনশীল ব্যক্তিগত তথ্যের ব্যবহারের সীমাবদ্ধতা জানানোর অধিকার:",
|
||||
"privacy.s11_nondiscrim_body": "এই অধিকারের কোনটিকে ব্যবহারের জন্য আপনার প্রতি আমরা বৈষম্য করবে না।",
|
||||
"privacy.s11_nondiscrim_label": "বৈষম্য না করার অধিকার:",
|
||||
"privacy.s11_optout_body": "আমরা CCPA/CPRA দ্বারা সংজ্ঞায়িত ব্যক্তিগত তথ্য বিক্রি বা শেয়ার করি না। অপ্ট-আউটের কোন প্রক্রিয়া প্রয়োজন নেই; তবে, আপনি আমাদের সাথে যোগাযোগ করতে পারেন এটা নিশ্চিত করার জন্য।",
|
||||
"privacy.s11_optout_label": "বিক্রয়/শেয়ার থেকে অপ্ট আউট করার অধিকার:",
|
||||
"privacy.s11_p1": "যদি আপনি ক্যালিফোর্নিয়া বা মার্কিন যুক্তরাষ্ট্রের অন্য কোন রাজ্যের বাসিন্দা হন যেখানে প্রযোজ্য গোপনীয়তা আইন (যেমন ভার্জিনিয়া VCDPA, কলোরাডো CPA, কনেকটিকাট CTDPA, উটাহ UCPA) রয়েছে, তবে নিম্নলিখিত অতিরিক্ত প্রকাশ্য তথ্য প্রযোজ্য হবে:",
|
||||
"privacy.s11_purpose_body": "DocuElevate পরিষেবা প্রদান, উন্নতি এবং সুরক্ষা করা। আমরা ক্রস-কনটেক্সট আচরণগত বিজ্ঞাপনের জন্য ব্যক্তিগত তথ্য বিক্রি বা শেয়ার করি না।",
|
||||
"privacy.s11_purpose_label": "তথ্য সংগ্রহের উদ্দেশ্য:",
|
||||
"privacy.s11_response": "আমরা 45 দিনের মধ্যে প্রতিক্রিয়া জানাবো (যখন যৌক্তিকভাবে প্রয়োজন তখন অতিরিক্ত 45 দিনের মধ্যে বাড়ানো যেতে পারে)।",
|
||||
"privacy.s12_access_body": "আপনি আপনার ব্যক্তিগত তথ্য এবং এটি কিভাবে ব্যবহার বা প্রকাশিত হয়েছে সে সম্পর্কে তথ্য লাভের জন্য অনুরোধ করতে পারেন।",
|
||||
"privacy.s12_access_label": "অ্যাক্সেসের অধিকার:",
|
||||
"privacy.s12_contact": "ডাইরেক্ট গোপনীয়তা অভিযোগ আমাদের গোপনীয়তা কর্মকর্তার কাছে পাঠান",
|
||||
"privacy.s12_contact_post": ", অথবা কানাডার গোপনীয়তা কমিশনারের অফিসে।",
|
||||
"privacy.s12_correction_body": "আপনি আপনার ব্যক্তিগত তথ্যের সঠিকতা বা পূর্ণতার বিরুদ্ধে চ্যালেঞ্জ করতে পারেন এবং সংশোধনের জন্য অনুরোধ করতে পারেন।",
|
||||
"privacy.s12_correction_label": "সংশোধনের অধিকার:",
|
||||
"privacy.s12_heading": "12. অতিরিক্ত অধিকার – কানাডা (PIPEDA / কিউবেক আইন 25)",
|
||||
"privacy.s12_li1": "আমরা শুধুমাত্র আপনার জ্ঞানে এবং সম্মতির সাথে, অথবা আইন দ্বারা অনুমোদিত হিসাবে ব্যক্তিগত তথ্য সংগ্রহ, ব্যবহার এবং প্রকাশ করি।",
|
||||
"privacy.s12_p1": "যদি আপনি কানাডায় অবস্থান করছেন, তবে ব্যক্তিগত তথ্য সুরক্ষা ও ইলেকট্রনিক ডকুমেন্টস অ্যাক্ট (PIPEDA) এবং প্রযোজ্য প্রাদেশিক আইন (যেমন কিউবেক আইন 25 / বিল 64) এর অধীনে নিম্নলিখিত প্রযোজ্য হবে:",
|
||||
"privacy.s12_quebec_body": "আইন 25 এর অধীনে, আপনার অতিরিক্ত অধিকার রয়েছে যার মধ্যে রয়েছে ডেটা পোর্টেবিলিটির অধিকার (সেপ্টেম্বর 2023 এ কার্যকর) এবং যেখানে ব্যক্তিগত তথ্য অনলাইনে প্রচারিত হয় সেখান থেকে ডি-ইন্ডেক্সেশনের অধিকার।",
|
||||
"privacy.s12_quebec_label": "কিউবেকের অধিবাসীরা:",
|
||||
"privacy.s12_withdraw_body": "আইনগত বা চুক্তিগত বিধিনিষেধের sujetos, আপনি যৌক্তিক নোটিশের উপর আপনার ব্যক্তিগত তথ্যের সংগ্রহ, ব্যবহার বা প্রকাশকে প্রত্যাহার করতে পারেন।",
|
||||
"privacy.s12_withdraw_label": "সম্ম Consent প্রত্যাহার করার অধিকার:",
|
||||
"privacy.s13_brazil_body": "যদি আপনি ব্রাজিলে অবস্থান করছেন, তবে আপনার LGPD এর অধীনে নিম্নলিখিত অধিকার রয়েছে:",
|
||||
"privacy.s13_brazil_label": "ব্রাজিল (LGPD – Lei Geral de Proteção de Dados, আইন 13.709/2018):",
|
||||
"privacy.s13_contact": "যোগাযোগ:",
|
||||
"privacy.s13_heading": "13. অতিরিক্ত অধিকার – লাতিন আমেরিকা (LGPD এবং অন্যান্য)",
|
||||
"privacy.s13_li1": "প্রসেসিংয়ের অস্তিত্বের নিশ্চিতকরণ এবং আপনার তথ্যের অ্যাক্সেস।",
|
||||
"privacy.s13_li2": "অপূর্ণ, অprecise, বা পুরানো তথ্যের সংশোধন।",
|
||||
"privacy.s13_li3": "অনাবশ্যক বা অতিরিক্ত তথ্যের অ্যানোনিমাইজেশন, ব্লকিং, বা মুছে ফেলা।",
|
||||
"privacy.s13_li4": "আপনার তথ্য অন্য কোনও পরিষেবা বা পণ্য প্রদানকারীর কাছে পোর্টেবিলিটি।",
|
||||
"privacy.s13_li5": "আপনার সম্মতির সাথে প্রক্রিয়াজাত ব্যক্তিগত তথ্য মুছে ফেলা।",
|
||||
"privacy.s13_li6": "যেসব প্রতিষ্ঠান আপনার তথ্য শেয়ার করেছে তাদের সম্পর্কে তথ্য।",
|
||||
"privacy.s13_li7": "সম্মতি না দেওয়ার সম্ভাবনা এবং প্রত্যাখ্যানের ফলাফলের তথ্য।",
|
||||
"privacy.s13_li8": "সম্মতি প্রত্যাহার।",
|
||||
"privacy.s13_other_body": "আমরা আর্জেন্টিনা (PDPA), মেক্সিকো (LFPDPPP), চিলি, কলম্বিয়া (Ley 1581) এবং অন্যান্য দেশে প্রযোজ্য গোপনীয়তা আইনগুলি স্বীকৃতি দিই। এই আইনের আওতাধীন ব্যবহারকারীরা আমাদের সাথে যোগাযোগ করে তাদের জাতীয় আইন অনুসারে সমতুল্য অধিকার প্রয়োগ করতে পারে।",
|
||||
"privacy.s13_other_label": "অন্যান্য লাতিন আমেরিকান দেশ:",
|
||||
"privacy.s14_apj_body": "আমরা এই ভৌগলিক এলাকার অধিবাসীদের সংশ্লিষ্ট জাতীয় আইন অধীনে প্রদত্ত ডেটা সুরক্ষা অধিকারগুলি স্বীকৃতি দিই। আপনার অধিকার প্রয়োগ করতে আমাদের সাথে যোগাযোগ করুন।",
|
||||
"privacy.s14_apj_label": "অন্যান্য APJ মার্কেট (সিঙ্গাপুর PDPA, নিউজিল্যান্ড প্রাইভেসি আইন, ভারত DPDP আইন):",
|
||||
"privacy.s14_australia_body": "অস্ট্রেলিয়ার অধিবাসীরা তাদের ব্যক্তিগত তথ্যের অ্যাক্সেস এবং সংশোধনের জন্য অনুরোধ করতে পারেন। আমরা 30 দিনের মধ্যে অ্যাক্সেসের অনুরোধগুলির প্রতি প্রতিক্রিয়া জানাবো। অভিযোগ অস্ট্রেলিয়ার তথ্য কমিশনারের অফিসে (OAIC) দায়ের করা যেতে পারে।",
|
||||
"privacy.s14_australia_label": "অস্ট্রেলিয়া (প্রাইভেসি আইন 1988 এবং অস্ট্রেলীয় প্রাইভেসি নীতিসমূহ):",
|
||||
"privacy.s14_contact": "যোগাযোগ:",
|
||||
"privacy.s14_heading": "১৪. অতিরিক্ত অধিকার – এশিয়া-প্যাসিফিক ও জাপান",
|
||||
"privacy.s14_japan_body": "জাপানি বাসিন্দারা আমাদের দ্বারা ধারণ করা তাদের ব্যক্তিগত তথ্য উন্মোচন, সংশোধন, সংযোজন বা মুছে ফেলার, ব্যবহার স্থগিত করার, মুছে ফেলার, বা তৃতীয় পক্ষের প্রদান স্থগিত করার জন্য আবেদন করতে পারেন। তৃতীয় পক্ষের প্রকাশগুলির জন্য আপনার পূর্ব সম্মতি দরকার, আইন দ্বারা অনুমোদিত ছাড়া।",
|
||||
"privacy.s14_japan_label": "জাপান (এপিপিআই – ব্যক্তিগত তথ্য সুরক্ষা আইন):",
|
||||
"privacy.s14_korea_body": "কোরিয়ান বাসিন্দারা প্রবেশাধিকার, সংশোধন, মুছে ফেলা এবং প্রক্রিয়াকরণ স্থগিত করার জন্য আবেদন করতে পারেন। আমরা PIPA অনুযায়ী কোরিয়ান বাসিন্দাদের ব্যক্তিগত তথ্য পরিচালনা করি।",
|
||||
"privacy.s14_korea_label": "দক্ষিণ কোরিয়া (পিআইপিএ – ব্যক্তিগত তথ্য সুরক্ষা আইন):",
|
||||
"privacy.s15_contact": "যোগাযোগ:",
|
||||
"privacy.s15_heading": "১৫. অতিরিক্ত অধিকার – ইউক্রেন",
|
||||
"privacy.s15_p1": "ইউক্রেনে অবস্থিত ব্যবহারকারীরা ইউক্রেনের \"ব্যক্তিগত তথ্য সুরক্ষা\" আইনের অধীনে সুরক্ষিত। আপনার অধিকারগুলির মধ্যে আপনার ব্যক্তিগত ডেটায় প্রবেশাধিকার, সংশোধন, ব্লকিং, এবং মুছে ফেলার অধিকার অন্তর্ভুক্ত রয়েছে, পাশাপাশি প্রক্রিয়াকরণের বিরুদ্ধে আপত্তি জানানোর অধিকারও রয়েছে।",
|
||||
"privacy.s16_cookies_link": "কুকিজ নীতি",
|
||||
"privacy.s16_heading": "১৬. এই গোপনীয়তা বিজ্ঞপ্তির আপডেট",
|
||||
"privacy.s16_license_link": "লাইসেন্স তথ্য",
|
||||
"privacy.s16_p1": "আমরা সময়ে সময়ে এই বিজ্ঞপ্তিটি আমাদের অনুশীলন বা প্রযোজ্য আইনে পরিবর্তনগুলি প্রতিফলিত করার জন্য আপডেট করতে পারি। এই পৃষ্ঠার উচ্চাংশে \"শেষ আপডেট\" তারিখটি নির্দেশ করে যে বিজ্ঞপ্তিটি সর্বশেষ কবে সংশোধন করা হয়েছিল। প্রাপ্তবয়স্ক পরিবর্তন হলে, আমরা প্রযোজ্য ক্ষেত্রে ইন-অ্যাপ নোটিফিকেশন বা ইমেলের মাধ্যমে ব্যবহারকারীদের জানান দেব।",
|
||||
"privacy.s16_p2_pre": "যদি আপনার এই গোপনীয়তা বিজ্ঞপ্তি বা আপনার ব্যক্তিগত ডেটা নিয়ে কোনো প্রশ্ন বা উদ্বেগ থাকে, অনুগ্রহ করে আমাদের সাথে যোগাযোগ করুন",
|
||||
"privacy.s16_p3_pre": "অনুগ্রহ করে আমাদেরও পর্যালোচনা করতে বলুন",
|
||||
"privacy.s16_terms_link": "সেবার শর্তাবলী",
|
||||
"privacy.s1_address": "আল্টার স্টাইনওয়ে ৩, ২০৪৫৯ হামবুর্গ, জার্মানি",
|
||||
"privacy.s1_company": "ক্রিশ্চিয়ান লুইস আইটি পরামর্শ",
|
||||
"privacy.s1_contact_label": "যোগাযোগ ইমেইল:",
|
||||
"privacy.s1_heading": "১. ডেটা কন্ট্রোলার",
|
||||
"privacy.s1_p1": "ইইউ সাধারণ ডেটা সুরক্ষা নিয়ম (জিডিপিআর) এবং বিশ্বের অন্যান্য গোপনীয়তা আইনের অধীনে আপনার ব্যক্তিগত ডেটা প্রক্রিয়াকরণের জন্য দায়ী কন্ট্রোলার হল:",
|
||||
"privacy.s1_p3": "সমস্ত গোপনীয়তা সম্পর্কিত অনুরোধের জন্য (প্রবেশাধিকার, মুছে ফেলা, সংশোধন, অপ্ট-আউট, বা অভিযোগ), অনুগ্রহ করে উপরের ইমেইল ঠিকানায় আমাদের সাথে যোগাযোগ করুন। আমরা 30 দিনের মধ্যে (অথবা প্রযোজ্য আইনে নির্ধারিত সময়সীমার মধ্যে) উত্তর দেব।",
|
||||
"privacy.s2_heading": "২. এই গোপনীয়তা বিজ্ঞপ্তির পরিধি",
|
||||
"privacy.s2_p1_pre": "এই বিজ্ঞপ্তিটি ডকুইলেভেট ওয়েব অ্যাপ্লিকেশনের জন্য প্রযোজ্য, যা এখানে হোস্ট হয়েছে",
|
||||
"privacy.s2_p2": "এটি বিশ্বব্যাপী সমস্ত ব্যবহারকারীকে অন্তর্ভুক্ত করে, যার মধ্যে ইউরোপীয় ইউনিয়ন (ইইউ), ইউরোপীয় অর্থনৈতিক অঞ্চল (ইইএ), জার্মানি, যুক্তরাজ্য (ইউকে), সুইজারল্যান্ড, ইউক্রেন, United States (US), কানাডা, লাতিন আমেরিকা (লাতাম), এশিয়া-প্যাসিফিক এবং জাপান অন্তর্ভুক্ত রয়েছে। মার্কেট-নির্দিষ্ট প্রকাশগুলি নিচে নির্দিষ্ট বিভাগে প্রদান করা হয়েছে।",
|
||||
"privacy.s3_audit_body": "আমরা সেবা অখণ্ডতা এবং নিরাপত্তা নিশ্চিত করার জন্য সীমিত অডিট লগ (কর্মের প্রকার, টাইমস্ট্যাম্প, ব্যবহারকারীর পরিচয়) বজায় রাখি। এই লগগুলি নথির সামগ্রী অন্তর্ভুক্ত করে না।",
|
||||
"privacy.s3_audit_label": "অডিট লগ:",
|
||||
"privacy.s3_auth_body": "আমরা OAuth 2.0 (গুগল, ড্রপবক্স, মাইক্রোসফট/ওয়ানড্রাইভ) এবং বিকল্প স্থানীয় প্রমাণীকরণ ব্যবহার করি। OAuth-এর মাধ্যমে, আমরা আপনার নাম, ইমেইল ঠিকানা, এবং প্রোফাইল ছবির তথ্য পেতে পারি।",
|
||||
"privacy.s3_auth_label": "ব্যবহারকারী প্রমাণীকরণ:",
|
||||
"privacy.s3_doc_body": "আপনি যে নথিগুলি আপলোড করেন তা OCR (অপটিক্যাল ক্যারেক্টার রেকগনিশন), মেটাডেটা নিষ্কাশন, এবং আপনার নির্বাচিত ক্লাউড সরবরাহকারীর কাছে সংরক্ষণের জন্য প্রক্রিয়াকৃত হয়। নথির সামগ্রীটি শুধুমাত্র সেই উদ্দেশ্যে প্রক্রিয়াকৃত হয় যা আপনি শুরু করেন এবং এটি কার্যকরীভাবে প্রয়োজনীয় পাশাপাশি অতিরিক্ত নয়।",
|
||||
"privacy.s3_doc_label": "নথি প্রক্রিয়াকরণ:",
|
||||
"privacy.s3_heading": "৩. ডেটা সংগ্রহ ও উদ্দেশ্য",
|
||||
"privacy.s3_legal_body": "আমাদের প্রক্রিয়াকরণের জন্য প্রাথমিক আইনগত ভিত্তিগুলি হল:",
|
||||
"privacy.s3_legal_label": "আইনগত ভিত্তি (জিডিপিআর আর্ট. ৬):",
|
||||
"privacy.s3_li1": "(১)(বি) চুক্তির সম্পাদন: আপনি যে ডকুইলেভেট পরিষেবাটির জন্য আবেদন করেছেন তা প্রদান করতে।",
|
||||
"privacy.s3_li2": "(১)(গ) আইনগত বাধ্যবাধকতা: প্রযোজ্য আইন এবং বিধিমালার সাথে সামঞ্জস্য করতে।",
|
||||
"privacy.s3_li3": "(১)(ফ) বৈধ স্বার্থ: সেবার নিরাপত্তা নিশ্চিত করা এবং প্রতারণা রোধ করা।",
|
||||
"privacy.s4_heading": "৪. ডেটা গুণান্বিতকরণ ও উদ্দেশ্য সীমাবদ্ধতা",
|
||||
"privacy.s4_li1": "আমরা পরিষেবা পরিচালনার জন্য প্রয়োজনীয় ন্যূনতম ব্যক্তিগত তথ্য সংগ্রহ করি।",
|
||||
"privacy.s4_li2": "নথির সামগ্রীটি কঠোরভাবে আপনি যে উদ্দেশ্যে শুরু করেছেন (OCR, সংরক্ষণ, মেটাডেটা নিষ্কাশন) এর জন্য প্রক্রিয়াকৃত হয়। আমরা আপনার নথিগুলি AI মডেল প্রশিক্ষণের জন্য বা কোনো প্রাপ্তবয়স্ক উদ্দেশ্যের জন্য ব্যবহার করি না।",
|
||||
"privacy.s4_li3": "কোনও বিজ্ঞাপন, আচরণগত ট্র্যাকিং, বা প্রোফাইলিং করা হয় না।",
|
||||
"privacy.s4_li4": "কোনও ট্র্যাকিং কুকি বা বিশ্লেষণ স্ক্রিপ্ট লোড করা হয় না।",
|
||||
"privacy.s4_li5": "তৃতীয় পক্ষের AI সেবা (যেমন, OpenAI, Azure Document Intelligence) কেবল তখনই ডাকা হয় যখন আপনি ডকুমেন্ট প্রক্রিয়াকরণ শুরু করেন, এবং ডেটা ডেটা প্রক্রিয়াকরণ চুক্তির অধীনে প্রেরিত হয়।",
|
||||
"privacy.s4_p1": "DocuElevate ডেটা মাইনিমাইজেশন একটি মূল নীতির সঙ্গে ডিজাইন করা হয়েছে (GDPR Art. 5(1)(c)):",
|
||||
"privacy.s5_cookie_link": "কুকি নীতি",
|
||||
"privacy.s5_heading": "৫. কুকি ও অনুরূপ প্রযুক্তির ব্যবহার",
|
||||
"privacy.s5_p1_post": "আপনার প্রমাণীকৃত সেশন বজায় রাখার জন্য। এই কুকিগুলি সেবার কার্যকারিতার জন্য অপরিহার্য এবং EU ePrivacy Directive (Art. 5(3)) এবং সমান জাতীয় আইনগুলির অধীনে পূর্বানুমতি প্রয়োজনীয়তা থেকে মুক্ত।",
|
||||
"privacy.s5_p1_pre": "DocuElevate ব্যবহার করে",
|
||||
"privacy.s5_p1_strong": "শুধুমাত্র কঠোরভাবে প্রয়োজনীয় সেশন কুকি",
|
||||
"privacy.s5_p2_body": "বিশ্লেষণ কুকি, বিজ্ঞাপন কুকি, ট্র্যাকিং পিক্সেল, অথবা যেকোনও তৃতীয় পক্ষের কুকি যা আপনার অনুমতি প্রয়োজন।",
|
||||
"privacy.s5_p2_label": "আমরা ব্যবহার করি না:",
|
||||
"privacy.s5_p3_pre": "আমাদের দ্বারা সেট করা কুকিগুলির সম্পূর্ণ বিবরণ, তাদের নাম, দুরত্ব, এবং উদ্দেশ্য জানার জন্য, দয়া করে আমাদের যান",
|
||||
"privacy.s6_ai_body": "যখন আপনি OCR বা AI-ভিত্তিক মেটাডাটা নিষ্কাশন শুরু করেন, ডকুমেন্টের ডেটা সেই AI সেবায় প্রেরিত হয় যা আপনি বা আপনার প্রশাসক কনফিগার করেছেন। এই প্রেরণ একটি ডেটা প্রক্রিয়াকরণ চুক্তি দ্বারা পরিচালিত হয় ঐ সংশ্লিষ্ট প্রদানকারীর সাথে।",
|
||||
"privacy.s6_ai_label": "AI প্রক্রিয়াকরণ সেবা (OpenAI, Azure Document Intelligence, অন্যান্য):",
|
||||
"privacy.s6_heading": "৬. তৃতীয়-পক্ষ সেবা",
|
||||
"privacy.s6_no_sale_body": "আমরা আপনার ব্যক্তিগত তথ্য তৃতীয় পক্ষের সাথে বিজ্ঞাপন, মার্কেটিং, বা সেবার প্রদান সংক্রান্ত কোনও উদ্দেশ্যে বিক্রি, ভাড়া, বা শেয়ার করি না।",
|
||||
"privacy.s6_no_sale_label": "বিজ্ঞাপনের জন্য কোনও বিক্রয় বা শেয়ার নেই:",
|
||||
"privacy.s6_oauth_body": "যখন আপনি OAuth এর মাধ্যমে প্রমাণীকরণ করতে বেছে নেন, সংশ্লিষ্ট প্রদানকারী আপনার শংসাপত্র প্রক্রিয়া করে এবং আমাদের সাথে সীমিত প্রোফাইল তথ্য শেয়ার করতে পারে। এই প্রদানকারীরা তাদের নিজস্ব গোপনীয়তা নীতি বজায় রাখে।",
|
||||
"privacy.s6_oauth_label": "OAuth প্রদানকারী (Google, Dropbox, Microsoft):",
|
||||
"privacy.s6_storage_body": "ডকুমেন্টগুলি সেই ক্লাউড প্রদানকারীতে সংরক্ষিত হয় যা আপনি কনফিগার করেছেন। আপনার কনফিগার করা শংসাপত্রগুলি অ্যাপ্লিকেশন ডেটাবেসে এনক্রিপ্টেড আকারে সংরক্ষিত হয় এবং শুধুমাত্র আপনার অনুরোধ করা সংরক্ষণ অপারেশনগুলির জন্য ব্যবহৃত হয়।",
|
||||
"privacy.s6_storage_label": "ক্লাউড স্টোরেজ প্রদানকারী (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):",
|
||||
"privacy.s7_adequacy_body": "যেখানে ইউরোপীয় কমিশন একটি সমপর্যায়ের সুরক্ষার স্বীকৃতি দিয়েছে (যেমন, যুক্তরাজ্য, সুইজারল্যান্ড, কানাডা (বাণিজ্যিক সংস্থা), জাপান, দক্ষিণ কোরিয়া)।",
|
||||
"privacy.s7_adequacy_label": "সামঞ্জস্য সিদ্ধান্ত",
|
||||
"privacy.s7_contact": "আপনি আমাদের সাথে যোগাযোগ করে প্রাসঙ্গিক সুরক্ষা ব্যবস্থা একটি কপি চাওয়ার জন্য অনুরোধ করতে পারেন",
|
||||
"privacy.s7_heading": "৭. আন্তর্জাতিক ডেটা স্থানান্তর",
|
||||
"privacy.s7_idta_body": "ব্রেক্সিটের পর যুক্তরাজ্যের স্থানান্তরের জন্য।",
|
||||
"privacy.s7_idta_label": "যুক্তরাজ্য আন্তর্জাতিক ডেটা স্থানান্তর চুক্তি (IDTAs)",
|
||||
"privacy.s7_p1": "DocuElevate ডিফল্টরূপে ইউরোপীয় ইউনিয়ন / EEA তে হোস্ট করা হয়। যেখানে ব্যক্তিগত তথ্য EEA এর বাইরে স্থানান্তরিত হয় (যেমন OpenAI এর মতো যুক্তরাষ্ট্র ভিত্তিক AI সেবা প্রদানকারীদের কাছে), আমরা উপযুক্ত সুরক্ষা ব্যবস্থার উপর নির্ভর করি, যার মধ্যে রয়েছে:",
|
||||
"privacy.s7_scc_body": "তৃতীয় দেশগুলিতে প্রসেসর এবং নিয়ন্ত্রকদের জন্য ইউরোপীয় কমিশন (2021/914/EU) দ্বারা গৃহীত।",
|
||||
"privacy.s7_scc_label": "মানক চুক্তির ক্লজ (SCCs)",
|
||||
"privacy.s8_audit_body": "সুরক্ষা ও সম্মতি উদ্দেশ্যে 90 দিন পর্যন্ত সংরক্ষিত।",
|
||||
"privacy.s8_audit_label": "অডিট লগ:",
|
||||
"privacy.s8_contact": "আপনার অ্যাকাউন্ট এবং সকল যুক্ত ব্যক্তিগত তথ্য মুছে ফেলার জন্য অনুরোধ জানাতে আমাদের সাথে যোগাযোগ করুন",
|
||||
"privacy.s8_files_body": "সেবাটি ব্যবহারের সময়কাল পর্যন্ত সংরক্ষিত। আপনি যেকোনও সময় অ্যাপ্লিকেশনের মাধ্যমে পৃথক ফাইল মুছে ফেলতে পারেন।",
|
||||
"privacy.s8_files_label": "ফাইল রেকর্ড এবং মেটাডাটা:",
|
||||
"privacy.s8_heading": "৮. ডেটা ক্ষরণের সময়",
|
||||
"privacy.s8_oauth_body": "এনক্রিপ্টেড আকারে সংরক্ষিত এবং আপনার OAuth প্রদানকারী মাধ্যমে যেকোনো সময় প্রত্যাহারযোগ্য।",
|
||||
"privacy.s8_oauth_label": "OAuth টোকেন:",
|
||||
"privacy.s8_p1": "আমরা ব্যক্তিগত তথ্য শুধুমাত্র যতক্ষণ বরাবর কঠোরভাবে প্রয়োজনীয় DocuElevate পরিষেবা প্রদান করতে বা আইনগত বাধ্যবাধকতাগুলি পালনের জন্য রাখি:",
|
||||
"privacy.s8_session_body": "আপনি লগ আউট করার সময় বা সেশন টাইমআউটের পর মুছে ফেলা হয়।",
|
||||
"privacy.s8_session_label": "কর্ম সঞ্চয়:",
|
||||
"privacy.s9_heading": "৯. ডেটা নিরাপত্তা",
|
||||
"privacy.s9_li1": "বিশ্রামকালীন পরিচয়পত্র এবং সংবেদনশীল কনফিগারেশন এনক্রিপশন.",
|
||||
"privacy.s9_li2": "সমস্ত যোগাযোগের জন্য পরিবহন স্তরের নিরাপত্তা (TLS/HTTPS).",
|
||||
"privacy.s9_li3": "ব্যক্তিগত ডেটাতে প্রবেশাধিকার সীমিত করার জন্য ভূমিকা ভিত্তিক প্রবেশাধিকার নিয়ন্ত্রণ.",
|
||||
"privacy.s9_li4": "নিয়মিত নিরাপত্তা নিরীক্ষা এবং নির্ভরতা ঝুঁকি স্ক্যানিং.",
|
||||
"privacy.s9_li5": "সমস্ত রাজ্য-পরিবর্তনকারী অনুরোধে CSRF সুরক্ষা.",
|
||||
"privacy.s9_p1": "আমরা আপনার ব্যক্তিগত ডেটা সুরক্ষিত করতে উপযুক্ত প্রযুক্তিগত এবং সাংগঠনিক ব্যবস্থা (TOMs) বাস্তবায়ন করি, যার মধ্যে রয়েছে:",
|
||||
"privacy.toc_1": "ডেটা কন্ট্রোলার",
|
||||
"privacy.toc_10": "আপনার অধিকার (ইইউ / ইএইএ / যুক্তরাজ্য / সুইজারল্যান্ড)",
|
||||
"privacy.toc_11": "অতিরিক্ত অধিকার – যুক্তরাষ্ট্র (CCPA/CPRA)",
|
||||
"privacy.toc_12": "অতিরিক্ত অধিকার – কানাডা (PIPEDA / আইন ২৫)",
|
||||
"privacy.toc_13": "অতিরিক্ত অধিকার – লাতিন আমেরিকা (LGPD এবং অন্যান্য)",
|
||||
"privacy.toc_14": "অতিরিক্ত অধিকার – এশিয়া-প্রশান্ত মহাসাগর ও জাপান",
|
||||
"privacy.toc_15": "অতিরিক্ত অধিকার – ইউক্রেন",
|
||||
"privacy.toc_16": "এই গোপনীয়তা নোটিশের আপডেটস",
|
||||
"privacy.toc_2": "এই গোপনীয়তা নোটিশের পরিধি",
|
||||
"privacy.toc_3": "ডেটা সংগ্রহ ও উদ্দেশ্য",
|
||||
"privacy.toc_4": "ডেটার নিয়ন্ত্রণ ও উদ্দেশ্যের সীমাবদ্ধতা",
|
||||
"privacy.toc_5": "কুকিজ এবং অনুরূপ প্রযুক্তির ব্যবহার",
|
||||
"privacy.toc_6": "তৃতীয় পক্ষের পরিষেবাসমূহ",
|
||||
"privacy.toc_7": "আন্তর্জাতিক ডেটা স্থানান্তর",
|
||||
"privacy.toc_8": "ডেটা সংরক্ষণ",
|
||||
"privacy.toc_9": "ডেটা নিরাপত্তা",
|
||||
"privacy.toc_heading": "বিষয়বস্তু",
|
||||
"profile.avatar_alt": "আপনার প্রোফাইল ছবি",
|
||||
"profile.avatar_heading": "প্রোফাইল ছবি",
|
||||
"profile.avatar_remove": "কাস্টম অ্যাভাটার মুছুন",
|
||||
@@ -838,6 +1377,9 @@
|
||||
"profile.contact_email_label": "যোগাযোগ / বিজ্ঞপ্তি ই-মেইল",
|
||||
"profile.contact_email_placeholder": "you@example.com",
|
||||
"profile.current_password": "বর্তমান পাসওয়ার্ড",
|
||||
"profile.default_document_language_auto": "সিস্টেম ডিফল্ট ব্যবহার করুন",
|
||||
"profile.default_document_language_hint": "অন্যান্য ভাষায় ডোকুমেন্ট স্বয়ংক্রিয়ভাবে এই ভাষায় অনুবাদ করা হয়। সিস্টেম ডিফল্ট (ইংরেজি) ব্যবহার করতে খালি ছেড়ে দিন।",
|
||||
"profile.default_document_language_label": "ডিফল্ট ডোকুমেন্ট ভাষা",
|
||||
"profile.dismiss": "বাতিল করুন",
|
||||
"profile.display_name_hint": "আপনার অ্যাকাউন্ট ইউজারনেম বা ই-মেইল ব্যবহারের জন্য ফাঁকা রাখুন।",
|
||||
"profile.display_name_label": "ডিসপ্লে নাম",
|
||||
@@ -1040,14 +1582,66 @@
|
||||
"similarity.subtitle": "উচ্চ সাংকেতিক সাদৃশ্য সহ ডকুমেন্টের যুগল, স্কোর অনুযায়ী র্যাঙ্ক করা হয়েছে।",
|
||||
"similarity.trigger_aria": "সব ফাইলের জন্য এম্বেডিং গণনা শুরু করুন যেগুলোর এম্বেডিং отсутствует",
|
||||
"similarity.trigger_now": "এখন এটি শুরু করুন",
|
||||
"status.active": "সক্রিয়",
|
||||
"status.ai_empty_response": "(শূণ্য)",
|
||||
"status.ai_extraction_desc": "নিচে একটি নথির বাৎসরিক পাঠ্য বিষয়বস্তু পেস্ট করুন এবং কনফিগার করা AI সরবরাহকর্তার মাধ্যমে এটি চালান যাতে কাঁচা প্রতিক্রিয়া, নিষ্কাশন JSON, এবং ট্যাগগুলি পরিদর্শন করা যায়.",
|
||||
"status.ai_extraction_failed": "AI নিষ্কাশন ব্যর্থ হয়েছে",
|
||||
"status.ai_extraction_label": "নথি পাঠ্য",
|
||||
"status.ai_extraction_placeholder": "এখানে আপনার নথির বাৎসরিক পাঠ্য বিষয়বস্তু পেস্ট করুন\n",
|
||||
"status.ai_extraction_title": "AI নিষ্কাশনের পরীক্ষা",
|
||||
"status.app_version": "অ্যাপ সংস্করণ",
|
||||
"status.as_account": "রূপে",
|
||||
"status.auth_required": "প্রমাণীকরণ প্রয়োজন",
|
||||
"status.build_date": "নির্মাণ তারিখ",
|
||||
"status.config_settings": "কনফিগারেশন সেটিংস",
|
||||
"status.config_settings_desc": "আরও বিস্তারিত কনফিগারেশন সেটিংস এবং পরিবেশের পরিবর্তনশীলের জন্য, সেটিংস পৃষ্ঠা পরীক্ষা করুন.",
|
||||
"status.configure_now": "এখন কনফিগার করুন",
|
||||
"status.configured": "কনফিগার করা হয়েছে",
|
||||
"status.connection_error": "সংযোগের ত্রুটি",
|
||||
"status.connection_test_failed": "সংযোগ পরীক্ষায় ব্যর্থ হয়েছে",
|
||||
"status.connection_test_successful": "সংযোগ পরীক্ষায় সফল হয়েছে",
|
||||
"status.container_id": "কন্টেইনার আইডি",
|
||||
"status.container_started": "কন্টেনার শুরু হয়েছে",
|
||||
"status.dashboard_subtitle": "এই ড্যাশবোর্ডটি সমস্ত কনফিগার করা সংযোগ এবং লক্ষ্যগুলির স্থিতি প্রদর্শন করে।",
|
||||
"status.debug_mode": "ডিবাগ মোড",
|
||||
"status.error_running_extraction": "এক্সট্র্যাকশন চালানোর সময় ত্রুটি: ",
|
||||
"status.error_testing_connection": "সংযোগ পরীক্ষার সময় ত্রুটি: ",
|
||||
"status.error_testing_notifications": "বিজ্ঞপ্তি পরীক্ষার সময় ত্রুটি: ",
|
||||
"status.extracted_tags": "এক্সট্র্যাক্ট করা ট্যাগ",
|
||||
"status.git_commit": "গিট কমিট",
|
||||
"status.inactive": "অকার্যকর",
|
||||
"status.json_parse_issue": "JSON পার্স সমস্যা: ",
|
||||
"status.last_check": "শেষ পরীক্ষা",
|
||||
"status.manage": "পরিচালনা করুন",
|
||||
"status.modal_default_message": "অপারেশন সফলভাবে সম্পন্ন হয়েছে।",
|
||||
"status.modal_default_title": "সাফল্য",
|
||||
"status.no_details": "কোন ডিটেইলস উপলব্ধ নেই",
|
||||
"status.not_configured": "কনফিগার করা হয়নি",
|
||||
"status.notification_config_missing": "বিজ্ঞপ্তি কনফিগারেশন অনুপস্থিত",
|
||||
"status.open": "খোলা",
|
||||
"status.page_title": "সিস্টেম স্থিতি",
|
||||
"status.parsed_json_label": "পার্স করা JSON",
|
||||
"status.provider_config_details": "{name} কনফিগারেশন বিস্তারিত",
|
||||
"status.provider_details": "প্রোভাইডার বিস্তারিত",
|
||||
"status.raw_llm_response": "কাঁচা LLM প্রতিক্রিয়া",
|
||||
"status.run_extraction": "এক্সট্র্যাকশন চালান",
|
||||
"status.running": "চলছে\u001c",
|
||||
"status.sending": "পাঠানো হচ্ছে...",
|
||||
"status.setting_label": "সেটিং",
|
||||
"status.test_connection": "সংযোগ পরীক্ষা করুন",
|
||||
"status.test_extraction": "এক্সট্র্যাকশন পরীক্ষা করুন",
|
||||
"status.test_failed": "পরীক্ষা ব্যর্থ হয়েছে",
|
||||
"status.test_notification_failed": "পরীক্ষার বিজ্ঞপ্তি ব্যর্থ হয়েছে",
|
||||
"status.test_notification_sent": "পরীক্ষার বিজ্ঞপ্তি পাঠানো হয়েছে",
|
||||
"status.test_notifications": "পরীক্ষার বিজ্ঞপ্তি",
|
||||
"status.test_provider": "পরীক্ষা {name}",
|
||||
"status.test_successful": "পরীক্ষা সফল হয়েছে",
|
||||
"status.testing": "পরীক্ষা চলছে...",
|
||||
"status.token_expired": "আপনার টোকেন মেয়াদ শেষ হয়ে গেছে বা অবৈধ। দয়া করে এই সংযোগটি পুনরায় কনফিগার করুন।",
|
||||
"status.token_valid_for": "টোকেনের বৈধ সময়কাল:",
|
||||
"status.value_label": "মান",
|
||||
"status.view_config": "সাংবাদিক কনফিগারেশন দেখুন",
|
||||
"status.view_details": "বিস্তারিত দেখুন",
|
||||
"subscription.available_plans_heading": "উপলব্ধ পরিকল্পনা",
|
||||
"subscription.back_to_dashboard": "ড্যাশবোর্ডে ফিরে যান",
|
||||
"subscription.cancel_pending": "পরিবর্তন বাতিল করুন",
|
||||
@@ -1083,6 +1677,51 @@
|
||||
"subscription.upgrade_info": "আপগ্রেডগুলি তাত্ক্ষণিকভাবে কার্যকর হয়। হ্রাসগুলি আপনার বর্তমান বিলিং পিরিয়ডের শেষে নির্ধারিত হয়।",
|
||||
"subscription.upgrade_to_prefix": "আপগ্রেড করুন",
|
||||
"subscription.usage_heading": "ব্যবহার",
|
||||
"terms.cookie_link": "কুকি নীতি",
|
||||
"terms.heading": "সেবা শর্তাবলী",
|
||||
"terms.last_updated": "শেষ আপডেট:",
|
||||
"terms.license_link": "লাইসেন্স তথ্য",
|
||||
"terms.page_title": "সেবা শর্তাবলী - DocuElevate",
|
||||
"terms.privacy_link": "গোপনীয়তা নীতি",
|
||||
"terms.s1_heading": "1. শর্তাবলীর গ্রহণ",
|
||||
"terms.s1_p1": "DocuElevate-এ প্রবেশ বা ব্যবহার করে, আপনি এই সেবা শর্তাবলীর অধীনে বাঁধা হতে সম্মত হন। যদি আপনি এই শর্তাবলীতে সম্মত না হন, দয়া করে এই সেবাটি ব্যবহার করবেন না।",
|
||||
"terms.s2_heading": "2. সেবার বর্ণনা",
|
||||
"terms.s2_p1": "DocuElevate দলিল প্রক্রিয়াকরণ, OCR, মেটাডেটা এক্সট্র্যাকশন, এবং স্টোরেজ পরিষেবাগুলি প্রদান করে। আমরা যেকোনো সময় সেবার যেকোনো দিক পরিবর্তন বা বন্ধ করার অধিকার সংরক্ষণ করি।",
|
||||
"terms.s3_heading": "3. ব্যবহারকারীর দায়িত্বসমূহ",
|
||||
"terms.s3_li1": "আপনার কর্তৃক DocuElevate-এ আপলোড করা সকল বিষয়বস্তু",
|
||||
"terms.s3_li2": "দলিল আপলোড এবং প্রক্রিয়া করার সঠিক অধিকার রয়েছে তা নিশ্চিত করা",
|
||||
"terms.s3_li3": "আপনার অ্যাকাউন্টের প্রমাণপত্রের গোপনীয়তা রক্ষা করা",
|
||||
"terms.s3_li4": "আপনার অ্যাকাউন্টের অধীনে ঘটে যেকোনো কার্যক্রম",
|
||||
"terms.s3_p1": "আপনি দায়ী:",
|
||||
"terms.s3_p2_and": "এবং",
|
||||
"terms.s3_p2_post": ".",
|
||||
"terms.s3_p2_pre": "আমাদের সেবা ব্যবহার করে, আপনি আমাদেরও সম্মত হন",
|
||||
"terms.s4_heading": "4. মেধাসত্ত্ব অধিকার",
|
||||
"terms.s4_p1": "DocuElevate মেধাসত্ত্ব অধিকারকে সম্মান করে। ব্যবহারকারীরা অন্যদের মেধাসত্ত্ব অধিকার লঙ্ঘন করে এমন বিষয়বস্তু আপলোড করতে পারবে না।",
|
||||
"terms.s5_heading": "5. দায়িত্বর সীমাবদ্ধতা",
|
||||
"terms.s5_p1": "DocuElevate সেবাটি \"যেভাবে আছে\" তা প্রদান করে, কোন প্রকারের গ্যারান্টি ছাড়াই। আপনার সেবা ব্যবহারের কারণে অথবা ব্যবহারের অক্ষমতার কারণে কোনও সরাসরি, পরোক্ষ, সান্দ্র, বিশেষ, পরিণামস্বরূপ, অথবা শাস্তিমূলক ক্ষতির জন্য আমরা দায়ী হব না।",
|
||||
"terms.s6_heading": "6. প্রযোজ্য আইন",
|
||||
"terms.s6_p1": "এই শর্তগুলি জার্মানির আইন দ্বারা পরিচালিত হবে, যার সংঘাত আইন বিতর্ক ছাড়াই।",
|
||||
"terms.s6_p2_post": ".",
|
||||
"terms.s6_p2_pre": "আপনার যদি এই শর্তগুলি সম্পর্কে কোনও প্রশ্ন থাকে, দয়া করে আমাদের সাথে যোগাযোগ করুন",
|
||||
"terms.s6_p3_mid": ". লাইসেন্স তথ্যের জন্য, দয়া করে আমাদেরকে দেখুন",
|
||||
"terms.s6_p3_post": ".",
|
||||
"terms.s6_p3_pre": "কুকি ব্যবহারের তথ্যের জন্য, আমাদের",
|
||||
"translation.copied": "কপি করা হয়েছে!",
|
||||
"translation.copy": "কপি করুন",
|
||||
"translation.default_language_version": "ডিফল্ট ভাষার সংস্করণ",
|
||||
"translation.detected_language": "আপনজনিত ভাষা",
|
||||
"translation.hide_text": "পাঠ্য লুকান",
|
||||
"translation.load_translation": "অনুবাদ লোড করুন",
|
||||
"translation.no_translation": "এখনো কোন অনুবাদ উপলব্ধ নেই — এটি এখনও প্রক্রিয়াধীন হতে পারে",
|
||||
"translation.select_language": "ভাষা নির্বাচন করুন\u0014",
|
||||
"translation.select_target": "দয়া করে একটি লক্ষ্য ভাষা নির্বাচন করুন।",
|
||||
"translation.show_text": "পাঠ্য দেখান",
|
||||
"translation.translate_btn": "অনুবাদ করুন",
|
||||
"translation.translate_to": "অন্য ভাষায় অনুবাদ করুন",
|
||||
"translation.translated_to": "এর অনুবাদ করা হয়েছে",
|
||||
"translation.translating": "অনুবাদ করা হচ্ছে\u0014",
|
||||
"translation.translation_failed": "অনুবাদ ব্যর্থ হয়েছে",
|
||||
"upload.browse_button": "ফাইল ব্রাউজ করুন",
|
||||
"upload.button_processing": "প্রক্রিয়াকরণ...",
|
||||
"upload.camera_button": "ছবি তুলুন / ডকুমেন্ট স্ক্যান করুন",
|
||||
|
||||
@@ -41,6 +41,280 @@
|
||||
"about.story_heading": "La Nostra Història",
|
||||
"about.story_p1": "DocuElevate es va crear amb un únic objectiu: simplificar i optimitzar la gestió de documentació per a tothom, ja sigui una petita startup o una gran empresa.",
|
||||
"about.story_p2": "Aprofitem el poder dels proveïdors d'IA modulars (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey, i més) per a l'extracció de metadades i la refinament de textos, integrem sense problemes amb Dropbox, Nextcloud, i Paperless NGX per a l'emmagatzematge i indexació, aprofitem Azure Document Intelligence per a OCR, i fins i tot usem Gotenberg per a conversions de fitxer a PDF.",
|
||||
"admin_files.admin_only_badge": "Només Administradors",
|
||||
"admin_files.aria_breadcrumb": "Miga de pa",
|
||||
"admin_files.badge_delta_detected": "Delta detectat",
|
||||
"admin_files.badge_duplicate": "dup",
|
||||
"admin_files.badge_in_db": "a la BD",
|
||||
"admin_files.badge_on_disk": "a disc",
|
||||
"admin_files.breadcrumb_workdir": "directori de treball",
|
||||
"admin_files.btn_download": "Descarregar",
|
||||
"admin_files.col_actions": "Accions",
|
||||
"admin_files.col_db": "BD",
|
||||
"admin_files.col_health": "Salut",
|
||||
"admin_files.col_id": "ID",
|
||||
"admin_files.col_ingested": "Ingerit",
|
||||
"admin_files.col_local_filename": "nom_fitxer_local",
|
||||
"admin_files.col_missing_paths": "Rutes mancants",
|
||||
"admin_files.col_modified": "Modificat",
|
||||
"admin_files.col_name": "Nom",
|
||||
"admin_files.col_original_file_path": "ruta_fitxer_original",
|
||||
"admin_files.col_original_filename": "Nom del fitxer original",
|
||||
"admin_files.col_path_relative": "Ruta (relativa al directori de treball)",
|
||||
"admin_files.col_processed_file_path": "ruta_fitxer_processat",
|
||||
"admin_files.col_size": "Mida",
|
||||
"admin_files.delta_detected_detail": "S'han trobat {orphan_count} fitxer(s) orfe(s) al disc sense registre a la base de dades, i {ghost_count} registre(s) de la base de dades amb fitxers mancants al disc.",
|
||||
"admin_files.delta_detected_title": "Delta detectat.",
|
||||
"admin_files.empty_database": "No s'han trobat registres de fitxers a la base de dades.",
|
||||
"admin_files.empty_directory": "Aquest directori està buit.",
|
||||
"admin_files.ghost_records_desc": "(a la BD, fitxer(s) mancants al disc)",
|
||||
"admin_files.ghost_records_heading": "Registres fantasma",
|
||||
"admin_files.heading": "Gestor de fitxers",
|
||||
"admin_files.health_missing": "Mancant",
|
||||
"admin_files.health_ok": "D'acord",
|
||||
"admin_files.legend_file_exists": "El fitxer existeix al disc",
|
||||
"admin_files.legend_file_missing": "Fitxer mancant del disc",
|
||||
"admin_files.legend_found_in_db": "Trobat a la BD",
|
||||
"admin_files.legend_not_in_db": "No a la BD (orfe)",
|
||||
"admin_files.legend_path_not_set": "Ruta no establerta",
|
||||
"admin_files.no_delta": "No s'ha trobat delta — el sistema de fitxers i la base de dades estan sincronitzats.",
|
||||
"admin_files.no_ghost_records": "No s'han trobat registres fantasma.",
|
||||
"admin_files.no_orphan_files": "No s'han trobat fitxers orfes.",
|
||||
"admin_files.orphan_files_desc": "(al disc, sense registre a la BD)",
|
||||
"admin_files.orphan_files_heading": "Fitxers orfes",
|
||||
"admin_files.page_title": "Gestor de fitxers – Admin",
|
||||
"admin_files.status_in_db": "A la BD",
|
||||
"admin_files.status_orphan": "Orfe",
|
||||
"admin_files.tab_database": "Registres de la base de dades",
|
||||
"admin_files.tab_filesystem": "Sistema de fitxers",
|
||||
"admin_files.tab_reconcile": "Reconciliar",
|
||||
"admin_plans.aria_delete_plan": "Eliminar {name}",
|
||||
"admin_plans.aria_edit_plan": "Editar {name}",
|
||||
"admin_plans.aria_feature_n": "Funció {n}",
|
||||
"admin_plans.aria_move_down": "Mou {name} cap avall",
|
||||
"admin_plans.aria_move_up": "Mou {name} cap amunt",
|
||||
"admin_plans.aria_remove_feature_n": "Eliminar la funció {n}",
|
||||
"admin_plans.btn_add_feature": "Afegir Funció",
|
||||
"admin_plans.btn_add_plan": "Afegir Pla",
|
||||
"admin_plans.btn_cancel": "Cancel·lar",
|
||||
"admin_plans.btn_create": "Crear Pla",
|
||||
"admin_plans.btn_delete": "Eliminar",
|
||||
"admin_plans.btn_edit": "Editar",
|
||||
"admin_plans.btn_restore_defaults": "Restaura les Opcions Predeterminades",
|
||||
"admin_plans.btn_restore_defaults_title": "Restaura els quatre plans predeterminats (només si no hi ha cap pla existent)",
|
||||
"admin_plans.btn_restoring": "Restaurant\u00130",
|
||||
"admin_plans.btn_save_changes": "Desar Canvis",
|
||||
"admin_plans.btn_save_order": "Desar Ordre",
|
||||
"admin_plans.btn_saving": "Desant\u00130",
|
||||
"admin_plans.btn_stripe_setup": "Configuració de Stripe",
|
||||
"admin_plans.btn_stripe_setup_title": "Obre l'Assistant de Configuració de Stripe per configurar les claus API i sincronitzar plans",
|
||||
"admin_plans.col_actions": "Accions",
|
||||
"admin_plans.col_active": "Actiu",
|
||||
"admin_plans.col_monthly": "Mensual",
|
||||
"admin_plans.col_monthly_limit": "Límits Mensuals",
|
||||
"admin_plans.col_order": "Ordre",
|
||||
"admin_plans.col_overage_pct": "Percentatge de Sobrant %",
|
||||
"admin_plans.col_plan": "Pla",
|
||||
"admin_plans.col_yearly": "Anual",
|
||||
"admin_plans.coming_soon": "Properament",
|
||||
"admin_plans.featured_badge": "Destacat",
|
||||
"admin_plans.field_active": "Actiu",
|
||||
"admin_plans.field_allow_overage": "Permetre Facturació de Sobrants",
|
||||
"admin_plans.field_api_access": "Accés API",
|
||||
"admin_plans.field_badge_text": "Text de la Insignia",
|
||||
"admin_plans.field_buffer": "Buit:",
|
||||
"admin_plans.field_cta_text": "Text del Botó CTA",
|
||||
"admin_plans.field_docs_month": "Docs / Mes",
|
||||
"admin_plans.field_featured": "Destacat / Ressaltat",
|
||||
"admin_plans.field_lifetime_docs": "Docs Perpetu",
|
||||
"admin_plans.field_mailboxes": "Bústies de Correu",
|
||||
"admin_plans.field_max_file_size": "Mida màxima del fitxer (MB)",
|
||||
"admin_plans.field_name": "Nom",
|
||||
"admin_plans.field_ocr_pages": "Pàgines OCR / Mes",
|
||||
"admin_plans.field_overage_doc_price": "Preu d'excés / doc ($)",
|
||||
"admin_plans.field_overage_ocr_price": "Preu d'excés / pàgina OCR ($)",
|
||||
"admin_plans.field_plan_id": "ID del pla",
|
||||
"admin_plans.field_price_monthly": "Preu mensual ($)",
|
||||
"admin_plans.field_price_yearly": "Preu anual ($)",
|
||||
"admin_plans.field_sort_order": "Ordre de classificació",
|
||||
"admin_plans.field_storage_dests": "Destinacions d'emmagatzematge",
|
||||
"admin_plans.field_stripe_monthly": "ID de preu de Stripe (mensual)",
|
||||
"admin_plans.field_stripe_yearly": "ID de preu de Stripe (anual)",
|
||||
"admin_plans.field_tagline": "Eslogan",
|
||||
"admin_plans.field_trial_days": "Dies de prova",
|
||||
"admin_plans.free_label": "Gratuït",
|
||||
"admin_plans.heading": "Dissenyador de Plans",
|
||||
"admin_plans.hint_features": "Aquests punts apareixen a la targeta de la pàgina de preus per a aquest pla.",
|
||||
"admin_plans.hint_plan_id": "Slug en minúscules, no es pot canviar després de la creació.",
|
||||
"admin_plans.hint_zero_unlimited": "Introdueix 0 per a il·limitat.",
|
||||
"admin_plans.js_delete_confirm": "Eliminar el pla \"{id}\"? Això no es pot desfer.",
|
||||
"admin_plans.js_delete_failed": "L'eliminació ha fallat",
|
||||
"admin_plans.js_failed_load": "No s'ha pogut carregar els plans",
|
||||
"admin_plans.js_order_saved": "Ordre guardat!",
|
||||
"admin_plans.js_plan_created": "Pla creat!",
|
||||
"admin_plans.js_plan_deleted": "Pla \"{id}\" eliminat.",
|
||||
"admin_plans.js_plan_updated": "Pla actualitzat!",
|
||||
"admin_plans.js_reorder_failed": "Reordenació fallida",
|
||||
"admin_plans.js_save_failed": "Desa fallida",
|
||||
"admin_plans.js_seed_confirm": "Sembrar els quatre plans per defecte? Això és una operació nul·la si els plans ja existeixen.",
|
||||
"admin_plans.js_seed_failed": "Sembrada fallida",
|
||||
"admin_plans.js_yearly_enter": "Introdueix el preu anual per mostrar estalvis",
|
||||
"admin_plans.js_yearly_save": "Estalvia {pct}% en comparació amb el mensual",
|
||||
"admin_plans.loading": "Carregant plans\u001e",
|
||||
"admin_plans.modal_close_aria": "Tancar modal",
|
||||
"admin_plans.modal_create_title": "Afegir Pla",
|
||||
"admin_plans.modal_edit_title_prefix": "Editar Pla: ",
|
||||
"admin_plans.no_plans_intro": "Encara no hi ha plans. Fes clic",
|
||||
"admin_plans.no_plans_suffix": "per sembrar els quatre plans integrats.",
|
||||
"admin_plans.overage_0pct": "0% (exacte)",
|
||||
"admin_plans.overage_100pct": "100%",
|
||||
"admin_plans.overage_50pct": "50%",
|
||||
"admin_plans.overage_announce": "\u00100 anunciar",
|
||||
"admin_plans.overage_buffer_body_prefix": "El buffer de sobrepasament és",
|
||||
"admin_plans.overage_buffer_body_suffix": "Anunciï X docs/mes però només fes complir a",
|
||||
"admin_plans.overage_buffer_formula": "X \u00157 (1 + buffer%)",
|
||||
"admin_plans.overage_buffer_invisible": "invisible per als usuaris",
|
||||
"admin_plans.overage_buffer_tail": "docs. Per exemple, un pla de 150-doc/mes amb un buffer del 20% s'aplica a 180 docs. Això evita interrupcions brusques al límit exacte anunciat, donant als usuaris una transició suau.",
|
||||
"admin_plans.overage_buffer_title": "Sobre el Buffer de Sobrepasament",
|
||||
"admin_plans.overage_docs": "docs,",
|
||||
"admin_plans.overage_docs_end": "docs",
|
||||
"admin_plans.overage_enforce_at": "aplicar a",
|
||||
"admin_plans.page_title": "Dissenyador de Plans \u00151 DocuElevate Admin",
|
||||
"admin_plans.section_basic_info": "Informació Bàsica",
|
||||
"admin_plans.section_display": "Display",
|
||||
"admin_plans.section_features": "Llista de Funcions",
|
||||
"admin_plans.section_overage": "Dissenyador de Sobrepasament",
|
||||
"admin_plans.section_pricing": "Preus",
|
||||
"admin_plans.section_stripe": "Integració Stripe",
|
||||
"admin_plans.section_volume": "Límits de Volum",
|
||||
"admin_plans.status_active": "Actiu",
|
||||
"admin_plans.status_inactive": "Inactiu",
|
||||
"admin_plans.stripe_desc_after": "per auto-crear-los. Els plans gratuïts no necessiten IDs de Preu Stripe.",
|
||||
"admin_plans.stripe_desc_before": "Introduïu els IDs de Preu Stripe per a aquest pla, o feu servir el",
|
||||
"admin_plans.stripe_wizard_aria": "Obre l'Assistència de Configuració de Stripe en una nova pestanya",
|
||||
"admin_plans.stripe_wizard_link": "Assistència Stripe",
|
||||
"admin_plans.stripe_wizard_text": "Assistència de Configuració de Stripe",
|
||||
"admin_plans.subheading": "Gestionar plans de subscripció mostrats a la pàgina pública de preus.",
|
||||
"admin_plans.table_aria_label": "Plans de subscripció",
|
||||
"admin_users.add_user_profile_btn": "Afegir perfil d'usuari",
|
||||
"admin_users.admin_only_badge": "Només per administradors",
|
||||
"admin_users.btn_password": "Contrasenya",
|
||||
"admin_users.btn_reset": "Restablir",
|
||||
"admin_users.col_display_name": "Nom a mostrar",
|
||||
"admin_users.col_documents": "Documents",
|
||||
"admin_users.col_email": "Correu electrònic",
|
||||
"admin_users.col_last_upload": "Última pujada",
|
||||
"admin_users.col_plan": "Pla",
|
||||
"admin_users.col_role": "Rol",
|
||||
"admin_users.col_upload_limit": "Límit de pujada",
|
||||
"admin_users.col_user_id": "ID d'usuari",
|
||||
"admin_users.col_username": "Nom d'usuari",
|
||||
"admin_users.create_local_account_btn": "Crear compte local",
|
||||
"admin_users.create_local_title": "Crear compte local",
|
||||
"admin_users.delete_account_btn": "Eliminar compte",
|
||||
"admin_users.delete_local_confirm": "Esteu segur que voleu eliminar el compte per",
|
||||
"admin_users.delete_local_title": "Eliminar compte local",
|
||||
"admin_users.delete_local_warning": "Això no es pot desfer. Els documents que pertanyen a aquest usuari no es suprimiran.",
|
||||
"admin_users.delete_profile_btn": "Eliminar perfil",
|
||||
"admin_users.delete_profile_confirm": "Esteu segur que voleu eliminar el perfil per",
|
||||
"admin_users.delete_profile_title": "Eliminar perfil d'usuari",
|
||||
"admin_users.delete_profile_warning": "Això només elimina el registre de perfil gestionat per l'administrador. Els documents que pertanyen a aquest usuari no es suprimiran.",
|
||||
"admin_users.deleting": "Eliminant\u0002026",
|
||||
"admin_users.edit_local_title": "Editar compte local",
|
||||
"admin_users.filter_placeholder": "Filtrar per ID d'usuari\u0002026",
|
||||
"admin_users.global_default": "per defecte global",
|
||||
"admin_users.heading": "Gestió d'usuari",
|
||||
"admin_users.js_account_created": "Compte creat",
|
||||
"admin_users.js_account_created_msg": "El compte local per a \"{username}\" s'ha creat correctament.",
|
||||
"admin_users.js_account_deleted_msg": "El compte per a \"{username}\" s'ha eliminat.",
|
||||
"admin_users.js_account_updated": "El compte s'ha actualitzat.",
|
||||
"admin_users.js_delete_failed": "La supressió ha fallat",
|
||||
"admin_users.js_deleted": "Eliminat",
|
||||
"admin_users.js_email_not_sent": "Correu electrònic no enviat",
|
||||
"admin_users.js_email_sent": "Correu electrònic enviat",
|
||||
"admin_users.js_email_sent_msg": "Correu electrònic de restabliment de contrasenya enviat a \"{email}\".",
|
||||
"admin_users.js_failed": "Ha fallat",
|
||||
"admin_users.js_failed_create": "No s'ha pogut crear el compte.",
|
||||
"admin_users.js_failed_load_local": "No s'han pogut carregar els usuaris locals",
|
||||
"admin_users.js_failed_load_users": "Error en carregar usuaris",
|
||||
"admin_users.js_failed_set_password": "Error en establir la contrasenya.",
|
||||
"admin_users.js_failed_update": "Error en actualitzar l'usuari.",
|
||||
"admin_users.js_network_error": "Error de xarxa",
|
||||
"admin_users.js_password_set": "Contrasenya establerta",
|
||||
"admin_users.js_password_set_msg": "La contrasenya de \"{username}\" s'ha actualitzat.",
|
||||
"admin_users.js_profile_deleted": "El perfil de \"{id}\" s'ha eliminat.",
|
||||
"admin_users.js_profile_saved": "El perfil de \"{id}\" s'ha desat.",
|
||||
"admin_users.js_save_failed": "Error en desar",
|
||||
"admin_users.js_saved": "Desat",
|
||||
"admin_users.js_smtp_not_configured": "SMTP no està configurat.",
|
||||
"admin_users.js_updated": "Actualitzat",
|
||||
"admin_users.loading_users": "Carregant usuaris\t",
|
||||
"admin_users.local_account_active": "Compte actiu",
|
||||
"admin_users.local_accounts_heading": "Comptes d'usuari locals",
|
||||
"admin_users.local_accounts_subheading": "Comptes d'email/contrasenya creats directament en aquest servidor.",
|
||||
"admin_users.local_admin_privileges": "Atorgar privilegis d'administrador",
|
||||
"admin_users.local_admin_privileges_short": "Privilegis d'administrador",
|
||||
"admin_users.local_create_btn": "Crear Compte",
|
||||
"admin_users.local_create_one": "Crea un.",
|
||||
"admin_users.local_creating": "Creant\t",
|
||||
"admin_users.local_display_name_optional": "(opcional)",
|
||||
"admin_users.local_loading": "Carregant\t",
|
||||
"admin_users.local_no_accounts": "Encara no hi ha comptes locals.",
|
||||
"admin_users.local_password_hint": "Mínim 8 caràcters.",
|
||||
"admin_users.local_saving": "Desant\t",
|
||||
"admin_users.local_username_hint": "3\u001clam 64 caràcters. Lletres, números, guions i guions baixos només.",
|
||||
"admin_users.modal_add_title": "Afegir Perfil d'Usuari",
|
||||
"admin_users.modal_billing_cycle_label": "Cicle de Facturació",
|
||||
"admin_users.modal_billing_monthly": "Mensual",
|
||||
"admin_users.modal_billing_yearly": "Anual",
|
||||
"admin_users.modal_block_hint": "(preveu la càrrega de nous documents)",
|
||||
"admin_users.modal_block_label": "Bloqueja aquest usuari",
|
||||
"admin_users.modal_close_aria": "Tanca el diàleg",
|
||||
"admin_users.modal_complimentary_hint": "(l'usuari conserva els avantatges del nivell però no se li factura mai — establert automàticament per a comptes d'administrador)",
|
||||
"admin_users.modal_complimentary_label": "Pla de cortesia",
|
||||
"admin_users.modal_daily_limit_hint": "(deixa en blanc per utilitzar el valor predeterminat global)",
|
||||
"admin_users.modal_daily_limit_label": "Límit de Càrrega Diària",
|
||||
"admin_users.modal_daily_limit_placeholder": "p. ex. 50 (0 = il·limitat)",
|
||||
"admin_users.modal_display_name_label": "Nom a mostrar",
|
||||
"admin_users.modal_display_name_placeholder": "Alice Smith (opcional)",
|
||||
"admin_users.modal_edit_title": "Editar perfil d'usuari",
|
||||
"admin_users.modal_notes_label": "Notes d'Admin",
|
||||
"admin_users.modal_notes_placeholder": "Notes internes visibles només per a administradors\u000206",
|
||||
"admin_users.modal_period_start_hint": "El període anual de transferència es calcula a partir d'aquesta data. Deixa en blanc per a la imposició mensual.",
|
||||
"admin_users.modal_period_start_label": "Inici del període de subscripció",
|
||||
"admin_users.modal_plan_business": "Negoci \u0015B $7.99/mes (300/mes, bústies il·limitades)",
|
||||
"admin_users.modal_plan_free": "Gratuït \u0015B 25 fitxers per sempre",
|
||||
"admin_users.modal_plan_hint": "Estableix els límits de quota per a aquest usuari. Els límits s'imposen en l'upload.",
|
||||
"admin_users.modal_plan_label": "Pla de subscripció",
|
||||
"admin_users.modal_plan_professional": "Professional \u0015B $5.99/mes (150/mes, 3 bústies)",
|
||||
"admin_users.modal_plan_starter": "Inici \u0015B $2.99/mes (50/mes, 1 bústia)",
|
||||
"admin_users.modal_save_changes": "Desar canvis",
|
||||
"admin_users.modal_saving": "Desant\u000206",
|
||||
"admin_users.modal_user_id_hint": "L'identificador estable que coincideix amb owner_id en documents.",
|
||||
"admin_users.modal_user_id_label": "ID d'usuari",
|
||||
"admin_users.modal_user_id_placeholder": "usuari@exemple.com o OAuth sub",
|
||||
"admin_users.new_account_btn": "Nou compte",
|
||||
"admin_users.new_password_label": "Nova contrasenya",
|
||||
"admin_users.no_users_add_hint": "Puja alguns documents o afegeix un perfil a dalt.",
|
||||
"admin_users.no_users_found": "No s'han trobat usuaris.",
|
||||
"admin_users.no_users_search_hint": "Prova un terme de cerca diferent.",
|
||||
"admin_users.page_title": "Gestió d'Usuaris \u0015B Admin \u0015B DocuElevate",
|
||||
"admin_users.pagination_page_of": "de",
|
||||
"admin_users.per_day": "/ dia",
|
||||
"admin_users.role_admin": "Admin",
|
||||
"admin_users.role_user": "Usuari",
|
||||
"admin_users.search_users_label": "Cerca usuaris",
|
||||
"admin_users.set_password_btn": "Establir contrasenya",
|
||||
"admin_users.set_password_desc": "L'usuari hauria de canviar aquesta contrasenya després d'iniciar sessió.",
|
||||
"admin_users.set_password_desc_pre": "Estableix una nova contrasenya directament per a",
|
||||
"admin_users.set_password_title": "Estableix contrasenya temporal",
|
||||
"admin_users.setting": "Configurant\u000206",
|
||||
"admin_users.status_blocked": "Blocada",
|
||||
"admin_users.status_unverified": "No verificat",
|
||||
"admin_users.subheading": "Gestiona perfils d'usuari, límits d'upload per usuari i propietat de documents.",
|
||||
"admin_users.total_count_users": "{count} usuaris",
|
||||
"admin_users.total_no_users": "Sense usuaris",
|
||||
"admin_users.total_one_user": "1 usuari",
|
||||
"api_tokens.col_created": "Creat",
|
||||
"api_tokens.col_last_ip": "Última IP",
|
||||
"api_tokens.col_last_used": "Últim usat",
|
||||
@@ -73,6 +347,19 @@
|
||||
"api_tokens.usage_intro_pre": "Utilitza el teu token API en el",
|
||||
"api_tokens.your_tokens": "Els teus tokens",
|
||||
"app.name": "DocuElevate",
|
||||
"attribution.heading": "Attributions de Programari de Tercers",
|
||||
"attribution.intro": "DocuElevate utilitza diverses biblioteques i eines de codi obert. Estem agraïts als desenvolupadors d'aquests projectes per les seves contribucions al programari de codi obert.",
|
||||
"attribution.page_title": "DocuElevate - Attributions de Tercers",
|
||||
"attribution.paramiko_lgpl_note": "Nota: Aquesta biblioteca està llicenciada sota la Llicència Pública General Menor GNU v2.1 (LGPL-2.1)",
|
||||
"attribution.section_docker": "Imatges de Docker",
|
||||
"attribution.section_frontend": "Dependències de Frontend",
|
||||
"attribution.section_python": "Dependències de Python",
|
||||
"attribution.special_lgpl_link": "aquí",
|
||||
"attribution.special_lgpl_post": ".",
|
||||
"attribution.special_lgpl_pre": "Es pot trobar una còpia de la llicència LGPL",
|
||||
"attribution.special_source_post": ".",
|
||||
"attribution.special_source_pre": "Aquest programari inclou Paramiko, que està llicenciat sota LGPL. El codi font de Paramiko està disponible a",
|
||||
"attribution.special_title": "Attribution Especial:",
|
||||
"audit.col_ip": "IP",
|
||||
"audit.col_resource": "Recurso",
|
||||
"audit.col_timestamp": "Carpeta de temps",
|
||||
@@ -252,6 +539,41 @@
|
||||
"cookie.notice_label": "Avís de cookies",
|
||||
"cookie.policy_link": "Política de cookies",
|
||||
"cookie.privacy_link": "Avís de privadesa",
|
||||
"cookie_policy.heading": "Política de Cookies",
|
||||
"cookie_policy.last_updated": "Última Actualització:",
|
||||
"cookie_policy.page_title": "Política de Cookies - DocuElevate",
|
||||
"cookie_policy.s1_heading": "Què Són les Cookies",
|
||||
"cookie_policy.s1_p1": "Les cookies són petits fitxers de text que es guarden a l'ordinador o dispositiu mòbil quan visiteu un lloc web. S'utilitzen àmpliament per fer que els llocs web funcionin de manera més eficient i proporcionar informació als propietaris del lloc web.",
|
||||
"cookie_policy.s2_heading": "Com Fem Servir les Cookies",
|
||||
"cookie_policy.s2_li1_body": "Per identificar-vos quan inicieu sessió i mantenir la vostra sessió mentre feu servir l'aplicació.",
|
||||
"cookie_policy.s2_li1_label": "Autenticació i Gestió de Sessions:",
|
||||
"cookie_policy.s2_p1_post": "per la següent finalitat:",
|
||||
"cookie_policy.s2_p1_pre": "DocuElevate utilitza",
|
||||
"cookie_policy.s2_p1_strong": "només cookies de sessió estrictament necessàries",
|
||||
"cookie_policy.s2_p2": "Aquestes cookies són obligatòries per al bon funcionament del nostre servei. Sense aquestes cookies, hauries d'iniciar sessió repetidament durant la teva sessió de navegació.",
|
||||
"cookie_policy.s2_p3": "A causa que aquestes cookies són estrictament necessàries perquè el servei funcioni, estan exemptes dels requisits de consentiment previ segons la Directiva de Privadesa Electrònica de la UE (Art. 5(3)) i les seves implementacions nacionals equivalents. No establim cap cookie opcional, d'anàlisi, publicitària o de seguiment.",
|
||||
"cookie_policy.s3_col_duration": "Durada",
|
||||
"cookie_policy.s3_col_name": "Nom",
|
||||
"cookie_policy.s3_col_purpose": "Finalitat",
|
||||
"cookie_policy.s3_col_type": "Tipus",
|
||||
"cookie_policy.s3_heading": "Detalls de les Cookies",
|
||||
"cookie_policy.s3_row1_duration": "Sessió (eliminada en tancar el navegador o tancar sessió)",
|
||||
"cookie_policy.s3_row1_purpose": "Mantenir la vostra sessió autenticada; necessari per al funcionament de l'inici de sessió.",
|
||||
"cookie_policy.s3_row1_type": "Estricament Necessari",
|
||||
"cookie_policy.s3_row2_duration": "Persistents (localStorage del navegador)",
|
||||
"cookie_policy.s3_row2_purpose": "Emmagatzema el vostre reconeixement de l'avís de cookies perquè no es mostri repetidament (emmagatzemat a localStorage, no a una cookie).",
|
||||
"cookie_policy.s3_row2_type": "Estricament Necessari",
|
||||
"cookie_policy.s4_heading": "Sense Cookies de Tercers",
|
||||
"cookie_policy.s4_p1": "DocuElevate no utilitza cap cookie de tercers, cookies de seguiment, cookies publicitàries o cookies d'anàlisi. Respectem la vostra privadesa i només implementem les cookies mínimes necessàries perquè el nostre servei funcioni.",
|
||||
"cookie_policy.s4_p2_pre": "Per a més informació sobre com tractem les vostres dades, si us plau, consulteu la nostra",
|
||||
"cookie_policy.s4_privacy_link": "Avís de privacitat",
|
||||
"cookie_policy.s5_heading": "Gestió de galetes",
|
||||
"cookie_policy.s5_p1": "La majoria dels navegadors web permeten controlar les galetes a través de la seva configuració. No obstant això, bloquejar o eliminar les nostres galetes de sessió evitarà que DocuElevate funcioni, ja que l'autenticació d'usuari depèn d'aquestes galetes.",
|
||||
"cookie_policy.s5_p2": "També podeu esborrar la reconeixença de l'avís de galetes emmagatzemada a la memòria local del vostre navegador en qualsevol moment mitjançant les eines de desenvolupador del vostre navegador (Aplicació \u0000BB Emmagatzematge Local).",
|
||||
"cookie_policy.s5_p3_and": "i",
|
||||
"cookie_policy.s5_p3_pre": "Aquesta Política de Galetes forma part i s'incorpora a la nostra",
|
||||
"cookie_policy.s5_privacy_link": "Avís de privacitat",
|
||||
"cookie_policy.s5_terms_link": "Termes de servei",
|
||||
"credentials.col_action": "Acció",
|
||||
"credentials.col_credential": "Credencial",
|
||||
"credentials.col_source": "Font",
|
||||
@@ -517,6 +839,31 @@
|
||||
"help.workflows_step_5_create": "Desa – els documents nous es processaran automàticament a través d'aquest canalitzador.",
|
||||
"help.workflows_typical_steps": "Passos Típics",
|
||||
"help.workflows_what_is": "Què és un Canalitzador?",
|
||||
"imprint.business_registration_heading": "Registre d'empresa",
|
||||
"imprint.business_registration_vat": "Número d'identificació d'IVA d'acord amb \u0000A727a de la Llei sobre l'IVA:",
|
||||
"imprint.contact_heading": "Informació de contacte",
|
||||
"imprint.dispute_heading": "Resolució de conflictes en línia",
|
||||
"imprint.dispute_p1": "La Comissió Europea proporciona una plataforma per a la resolució de conflictes en línia (OS):",
|
||||
"imprint.dispute_p2": "No estem disposats ni obligats a participar en processos de resolució de conflictes davant d'un consell d'arbitratge de consumidors.",
|
||||
"imprint.heading": "Impressió",
|
||||
"imprint.legal_copyright": "Tot el contingut d'aquest lloc web està protegit per drets d'autor. Qualsevol ús fora dels límits de la legislació de drets d'autor requereix el consentiment per escrit de l'autor o creador respectiu.",
|
||||
"imprint.legal_heading": "Avís legal",
|
||||
"imprint.legal_liability": "Malgrat un control acurat del contingut, no assumim cap responsabilitat pel contingut d'enllaços externs. Els operadors de les pàgines vinculades són els únics responsables del seu contingut.",
|
||||
"imprint.page_title": "Impressió - DocuElevate",
|
||||
"imprint.policies_cookie_desc": "Informació sobre les galetes que utilitzem",
|
||||
"imprint.policies_cookie_label": "Política de galetes",
|
||||
"imprint.policies_heading": "Polítiques relacionades",
|
||||
"imprint.policies_intro": "El nostre servei està regit per les següents polítiques:",
|
||||
"imprint.policies_license_desc": "Com està llicenciat el nostre programari",
|
||||
"imprint.policies_license_label": "Informació sobre la llicència",
|
||||
"imprint.policies_privacy_desc": "Com gestionem les vostres dades",
|
||||
"imprint.policies_privacy_label": "Política de privacitat",
|
||||
"imprint.policies_terms_desc": "Regles per utilitzar DocuElevate",
|
||||
"imprint.policies_terms_label": "Termes de servei",
|
||||
"imprint.provider_heading": "Proveïdor de serveis",
|
||||
"imprint.responsible_content_heading": "Responsable del contingut",
|
||||
"imprint.responsible_content_rstv": "D'acord amb \u0000A755 Abs. 2 RStV:",
|
||||
"imprint.subtitle": "Informació d'acord amb \u0000A75 TMG (Llei alemanya sobre mitjans telemàtics)",
|
||||
"index.badge_intelligent": "Processament de Documents Intel·ligents",
|
||||
"index.button_browse_files": "Navegar per Fitxers",
|
||||
"index.button_upload": "Pujar",
|
||||
@@ -728,6 +1075,19 @@
|
||||
"language.tr": "Turc",
|
||||
"language.uk": "Ucraïnès",
|
||||
"language.zh": "Xinès",
|
||||
"license.apache_description": "DocuElevate es distribueix sota la Llicència Apache 2.0, que és una llicència de programari d'codi obert permissiva que us permet utilitzar, modificar, distribuir i contribuir al projecte.",
|
||||
"license.apache_heading": "Llicència Apache 2.0",
|
||||
"license.heading": "Informació sobre la llicència",
|
||||
"license.page_title": "Informació sobre la llicència - DocuElevate",
|
||||
"license.related_about_link": "Pàgina d'informació",
|
||||
"license.related_and": "i",
|
||||
"license.related_heading": "Informació relacionada",
|
||||
"license.related_p1_post": "per a informació sobre l'ús del servei DocuElevate.",
|
||||
"license.related_p1_pre": "Mentre aquesta llicència rega l'ús del nostre programari, si us plau, revisa també els nostres",
|
||||
"license.related_p2_post": ".",
|
||||
"license.related_p2_pre": "Per a més informació sobre DocuElevate, si us plau, visita el",
|
||||
"license.related_privacy_link": "Política de Privacitat",
|
||||
"license.related_terms_link": "Termes de Servei",
|
||||
"nav.about": "Sobre",
|
||||
"nav.account_menu": "Menú del compte",
|
||||
"nav.account_menu_for": "Menú del compte per a {name}",
|
||||
@@ -828,6 +1188,185 @@
|
||||
"pipelines.system_label": "Sistema",
|
||||
"pipelines.system_pipeline_label": "Pipeline del sistema (visible per a tots els usuaris)",
|
||||
"pipelines.title": "Processament de Pipelines",
|
||||
"privacy.heading": "DocuElevate – Avís de Privacitat",
|
||||
"privacy.last_updated": "Última Actualització:",
|
||||
"privacy.page_title": "Avís de Privacitat - DocuElevate",
|
||||
"privacy.s10_access_body": "Podeu sol·licitar una còpia de les dades personals que tenim sobre vosaltres.",
|
||||
"privacy.s10_access_label": "Dret d'Accés (Art. 15):",
|
||||
"privacy.s10_complaint_body": "Teniu el dret a presentar una queixa davant l'autoritat nacional de protecció de dades (DPA). A Alemanya: Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI). Al Regne Unit: Information Commissioner's Office (ICO). A Suïssa: Federal Data Protection and Information Commissioner (FDPIC).",
|
||||
"privacy.s10_complaint_label": "Dret a Presentar una Queixa:",
|
||||
"privacy.s10_contact": "Per exercir qualsevol dels drets anteriors, poseu-vos en contacte amb nosaltres a",
|
||||
"privacy.s10_erasure_body": "Podeu sol·licitar l'esborrat de les vostres dades personals quan no hi hagi una raó legítima que ens obligui a conservar-les.",
|
||||
"privacy.s10_erasure_label": "Dret a l'Esborrat (Art. 17):",
|
||||
"privacy.s10_heading": "10. Els Vostres Drets (UE / EEE / Regne Unit / Suïssa)",
|
||||
"privacy.s10_object_body": "Podeu oposar-vos al tractament basat en interessos legítims en qualsevol moment.",
|
||||
"privacy.s10_object_label": "Dret a Oposar-se (Art. 21):",
|
||||
"privacy.s10_p1": "Segons el GDPR (i el GDPR del Regne Unit / l'equivalent nFADP de Suïssa), teniu els següents drets:",
|
||||
"privacy.s10_portability_body": "Podeu sol·licitar les vostres dades en un format estructurat, d'ús comú i llegible per màquina.",
|
||||
"privacy.s10_portability_label": "Dret a la Portabilitat de Dades (Art. 20):",
|
||||
"privacy.s10_rectification_body": "Podeu sol·licitar la correcció de dades personals inexactes o incompletes.",
|
||||
"privacy.s10_rectification_label": "Dret a la Rectificació (Art. 16):",
|
||||
"privacy.s10_response": "Respondrem dins d'un mes calendari (ampliable per dos mesos més per a sol·licituds complexes).",
|
||||
"privacy.s10_restriction_body": "Podeu sol·licitar que aturem temporalment el tractament de les vostres dades en certes circumstàncies.",
|
||||
"privacy.s10_restriction_label": "Dret a la Restricció (Art. 18):",
|
||||
"privacy.s10_withdraw_body": "Quan el tractament es basi en consentiment, podeu retirar aquest consentiment en qualsevol moment sense afectar la legalitat del tractament anterior.",
|
||||
"privacy.s10_withdraw_label": "Dret a Retirar el Consentiment:",
|
||||
"privacy.s11_categories_body": "Identificadors (nom, correu electrònic), tokens d'autenticació del compte i metadades de documents que trieu pujar.",
|
||||
"privacy.s11_categories_label": "Categories d'informació personal recollida:",
|
||||
"privacy.s11_contact": "Per enviar una sol·licitud de consumidor verificable, poseu-vos en contacte amb nosaltres a",
|
||||
"privacy.s11_correct_body": "Podeu sol·licitar la correcció d'informació personal inexacta.",
|
||||
"privacy.s11_correct_label": "Dret a Corregir:",
|
||||
"privacy.s11_delete_body": "Podeu sol·licitar l'esborrat de la informació personal que hem recopilat, subjecte a certes excepcions.",
|
||||
"privacy.s11_delete_label": "Dret a Esborrar:",
|
||||
"privacy.s11_heading": "11. Drets Addicionals – Estats Units (CCPA / CPRA)",
|
||||
"privacy.s11_know_body": "Podeu sol·licitar la divulgació de les categories i peces específiques d'informació personal que hem recopilat sobre vosaltres.",
|
||||
"privacy.s11_know_label": "Dret a Saber:",
|
||||
"privacy.s11_limit_body": "No fem servir informació personal sensible més enllà del que és necessari per proporcionar el servei.",
|
||||
"privacy.s11_limit_label": "Dret a limitar l'ús d'informació personal sensible:",
|
||||
"privacy.s11_nondiscrim_body": "No us discriminarem per exercir cap d'aquests drets.",
|
||||
"privacy.s11_nondiscrim_label": "No Discriminació:",
|
||||
"privacy.s11_optout_body": "No venem ni compartim informació personal tal com es defineix en el CCPA/CPRA. No es requereix cap mecanisme d'exclusió; tanmateix, podeu contactar amb nosaltres per confirmar-ho.",
|
||||
"privacy.s11_optout_label": "Dret a excloure's de la venda / compartició:",
|
||||
"privacy.s11_p1": "Si sou resident a Califòrnia o en un altre estat dels EUA amb legislació de privadesa aplicable (incloent-hi la VCDPA de Virgínia, la CPA de Colorado, la CTDPA de Connecticut, l'UCPA de Utah), s'apliquen les següents divulgacions addicionals:",
|
||||
"privacy.s11_purpose_body": "Proporcionar, millorar i assegurar el servei DocuElevate. No venem ni compartim informació personal per a publicitat comportamental de context creuat.",
|
||||
"privacy.s11_purpose_label": "Finalitat de la recollida:",
|
||||
"privacy.s11_response": "Respondrem en un termini de 45 dies (ampliable per 45 dies addicionals quan sigui raonablement necessari).",
|
||||
"privacy.s12_access_body": "Podeu sol·licitar accés a la vostra informació personal i a informació sobre com ha estat utilitzada o divulgada.",
|
||||
"privacy.s12_access_label": "Dret d'Accés:",
|
||||
"privacy.s12_contact": "Direu les queixes de privadesa al nostre Responsable de Privadesa a",
|
||||
"privacy.s12_contact_post": ", o a l'Oficina del Comissionat de Privadesa del Canadà.",
|
||||
"privacy.s12_correction_body": "Podeu impugnar l'exactitud o la completitud de la vostra informació personal i sol·licitar una correcció.",
|
||||
"privacy.s12_correction_label": "Dret a la Correcció:",
|
||||
"privacy.s12_heading": "12. Drets Addicionals – Canadà (PIPEDA / Llei 25 de Quebec)",
|
||||
"privacy.s12_li1": "Recollim, utilitzem i divulguem informació personal només amb el vostre coneixement i consentiment, o segons ho permeti la llei.",
|
||||
"privacy.s12_p1": "Si esteu ubicat a Canadà, s'aplica el següent segons la Llei de Protecció de la Informació Personal i Documents Electrònics (PIPEDA) i la legislació provincial aplicable (incloent la Llei 25 de Quebec / Llei 64):",
|
||||
"privacy.s12_quebec_body": "Segons la Llei 25, teniu drets addicionals, incloent el dret a la portabilitat de dades (efectiu setembre de 2023) i el dret a la desindexació quan la informació personal es difongui en línia.",
|
||||
"privacy.s12_quebec_label": "Residents de Quebec:",
|
||||
"privacy.s12_withdraw_body": "Sota restriccions legals o contracte, podeu retirar el consentiment a la recollida, ús o divulgació de la vostra informació personal amb un avís raonable.",
|
||||
"privacy.s12_withdraw_label": "Dret a Retirar el Consentiment:",
|
||||
"privacy.s13_brazil_body": "Si esteu ubicat a Brasil, teniu els següents drets segons la LGPD:",
|
||||
"privacy.s13_brazil_label": "Brasil (LGPD – Llei General de Protecció de Dades, Llei 13.709/2018):",
|
||||
"privacy.s13_contact": "Contacte:",
|
||||
"privacy.s13_heading": "13. Drets Addicionals – Amèrica Llatina (LGPD i Altres)",
|
||||
"privacy.s13_li1": "Confirmació de l'existència del procesament i accés als vostres dades.",
|
||||
"privacy.s13_li2": "Correcció de dades incompletes, inexactes o desactualitzades.",
|
||||
"privacy.s13_li3": "Anonimització, bloqueig o supressió de dades innecessàries o excessives.",
|
||||
"privacy.s13_li4": "Portabilitat de les vostres dades a un altre proveïdor de serveis o productes.",
|
||||
"privacy.s13_li5": "Supressió de dades personals processades amb el vostre consentiment.",
|
||||
"privacy.s13_li6": "Informació sobre les entitats amb les quals s'han compartit les vostres dades.",
|
||||
"privacy.s13_li7": "Informació sobre la possibilitat de no consentir i les conseqüències del rebuig.",
|
||||
"privacy.s13_li8": "Revocació del consentiment.",
|
||||
"privacy.s13_other_body": "També reconeixem les lleis de privadesa aplicables a Argentina (PDPA), Mèxic (LFPDPPP), Xile, Colòmbia (Llei 1581) i altres. Els usuaris en aquestes jurisdiccions poden exercir drets equivalents com s'especifica en la seva llei nacional contactant-nos.",
|
||||
"privacy.s13_other_label": "Altres Països Llatinoamericans:",
|
||||
"privacy.s14_apj_body": "Reconeguem els drets de protecció de dades atorgats als residents d'aquestes jurisdiccions segons les seves respectives lleis nacionals. Contacteu amb nosaltres per exercir els vostres drets.",
|
||||
"privacy.s14_apj_label": "Altres mercats APJ (PDPA de Singapur, Llei de Privadesa de Nova Zelanda, Llei DPDP d'Índia):",
|
||||
"privacy.s14_australia_body": "Els residents australians poden sol·licitar accés i correcció de la seva informació personal. Respondrem a les sol·licituds d'accés en un termini de 30 dies. Es poden presentar queixes a l'Oficina del Comissionat d'Informació d'Austràlia (OAIC).",
|
||||
"privacy.s14_australia_label": "Austràlia (Llei de Privadesa de 1988 i Principis de Privadesa Australians):",
|
||||
"privacy.s14_contact": "Contacte:",
|
||||
"privacy.s14_heading": "14. Drets addicionals – Àsia-Pacífic i Japó",
|
||||
"privacy.s14_japan_body": "Els residents japonesos poden demanar la divulgació, correcció, addició o eliminació, suspensió d'ús, esborrat o suspensió de la provisió d'informació personal que tenim sobre ells. Les divulgacions a tercers requereixen el seu consentiment previ, excepte quan ho permeti la llei.",
|
||||
"privacy.s14_japan_label": "Japó (APPI – Llei sobre la Protecció de la Informació Personal):",
|
||||
"privacy.s14_korea_body": "Els residents coreans poden sol·licitar accés, correcció, eliminació i suspensió del processament. Tractem la informació personal dels residents coreans d'acord amb la PIPA.",
|
||||
"privacy.s14_korea_label": "Corea del Sud (PIPA – Llei de Protecció de la Informació Personal):",
|
||||
"privacy.s15_contact": "Contacte:",
|
||||
"privacy.s15_heading": "15. Drets addicionals – Ucraïna",
|
||||
"privacy.s15_p1": "Els usuaris ubicats a Ucraïna estan protegits per la Llei d'Ucraïna \"Sobre la Protecció de Dades Personals\" (No. 2297-VI). Els seus drets inclouen l'accés, la correcció, el bloqueig i l'eliminació de les seves dades personals, així com el dret a oposar-se al processament.",
|
||||
"privacy.s16_cookies_link": "Política de Cookies",
|
||||
"privacy.s16_heading": "16. Actualitzacions a aquest Avís de Privacitat",
|
||||
"privacy.s16_license_link": "Informació sobre la Llicència",
|
||||
"privacy.s16_p1": "Podem actualitzar aquest avís de tant en tant per reflectir canvis en les nostres pràctiques o lleis aplicables. La data de \"Última Actualització\" a la part superior d'aquesta pàgina indica quan s'ha revisat per última vegada l'avís. Quan els canvis són materials, notificarem els usuaris mitjançant notificacions dins de l'aplicació o correu electrònic quan sigui oportú.",
|
||||
"privacy.s16_p2_pre": "Si té alguna pregunta o preocupació sobre aquest Avís de Privacitat o les seves dades personals, si us plau, contacti'ns a",
|
||||
"privacy.s16_p3_pre": "Si us plau, revisi també els nostres",
|
||||
"privacy.s16_terms_link": "Termes del Servei",
|
||||
"privacy.s1_address": "Alter Steinweg 3, 20459 Hamburg, Alemanya",
|
||||
"privacy.s1_company": "Christian Louis IT Beratung",
|
||||
"privacy.s1_contact_label": "Correu electrònic de contacte:",
|
||||
"privacy.s1_heading": "1. Responsable del tractament de dades",
|
||||
"privacy.s1_p1": "El responsable del tractament de les seves dades personals d'acord amb el Reglament General de Protecció de Dades de la UE (GDPR) i lleis de privadesa equivalents a nivell mundial és:",
|
||||
"privacy.s1_p3": "Per a totes les sol·licituds relacionades amb la privadesa (accés, eliminació, rectificació, oposició o queixes), si us plau, contacti'ns a l'adreça de correu electrònic dalt. Responem dins de 30 dies (o el període prescrit per la llei aplicable).",
|
||||
"privacy.s2_heading": "2. Àmbit d'aquest Avís de Privacitat",
|
||||
"privacy.s2_p1_pre": "Aquest avís s'aplica a l'aplicació web DocuElevate, allotjada a",
|
||||
"privacy.s2_p2": "Cobreix tots els usuaris a nivell global, incloent aquells a la Unió Europea (UE), Espai Econòmic Europeu (EEE), Alemanya, Regne Unit (RU), Suïssa, Ucraïna, Estats Units (US), Canadà, Amèrica Llatina (Latam), Àsia-Pacífic i Japó. Les divulgacions específiques del mercat es proporcionen en seccions dedicades a continuació.",
|
||||
"privacy.s3_audit_body": "Mantenim registres d'auditoria limitats (tipus d'acció, caràcter temporal, identificador d'usuari) per assegurar la integritat i seguretat del servei. Aquests registres no inclouen el contingut del document.",
|
||||
"privacy.s3_audit_label": "Registres d'Auditoria:",
|
||||
"privacy.s3_auth_body": "Fem servir OAuth 2.0 (Google, Dropbox, Microsoft/OneDrive) i autenticació local opcional. A través d'OAuth, podem rebre el seu nom, adreça de correu electrònic i imatge de perfil.",
|
||||
"privacy.s3_auth_label": "Autenticació d'Usuari:",
|
||||
"privacy.s3_doc_body": "Els documents que carregueu es processen per OCR (reconeixement òptic de caràcters), extracció de metadades i emmagatzematge al proveïdor de núvol que trieu. El contingut del document es processa només per a la finalitat que inicieu i no es desa més enllà del que sigui operativament necessari.",
|
||||
"privacy.s3_doc_label": "Processament de Documents:",
|
||||
"privacy.s3_heading": "3. Recollida de Dades i Finalitats",
|
||||
"privacy.s3_legal_body": "Les nostres principals bases legals per al processament són:",
|
||||
"privacy.s3_legal_label": "Base Legal (GDPR Art. 6):",
|
||||
"privacy.s3_li1": "(1)(b) Execució d'un contracte: per proporcionar el servei DocuElevate que heu sol·licitat.",
|
||||
"privacy.s3_li2": "(1)(c) Obligatorietat legal: per complir amb les lleis i regulacions aplicables.",
|
||||
"privacy.s3_li3": "(1)(f) Interessos legítims: assegurar la seguretat del servei i prevenir el frau.",
|
||||
"privacy.s4_heading": "4. Minimització de Dades i Limitació de Finalitats",
|
||||
"privacy.s4_li1": "Recollim només les dades personals mínimes necessàries per operar el servei.",
|
||||
"privacy.s4_li2": "El contingut del document es processa estrictament per a la finalitat que inicieu (OCR, emmagatzematge, extracció de metadades). No utilitzem els seus documents per entrenar models d'IA ni per cap altra finalitat secundària.",
|
||||
"privacy.s4_li3": "No s'executen publicitat, seguiment comportamental ni perfils.",
|
||||
"privacy.s4_li4": "No s'inclouen cookies de seguiment ni scripts d'analítica.",
|
||||
"privacy.s4_li5": "Els serveis d'IA de tercers (per exemple, OpenAI, Azure Document Intelligence) s'invoquen només quan iniciï el processament de documents, i les dades es transmeten sota acords de processament de dades.",
|
||||
"privacy.s4_p1": "DocuElevate està dissenyat amb la minimització de dades com a principi fonamental (GDPR Art. 5(1)(c)):",
|
||||
"privacy.s5_cookie_link": "Política de Cookies",
|
||||
"privacy.s5_heading": "5. Ús de Cookies i Tecnologies Similars",
|
||||
"privacy.s5_p1_post": "per mantenir la seva sessió autenticada. Aquestes cookies són essencials perquè el servei funcioni i estan exemptes dels requisits de consentiment previ segons la Directiva de Privadesa Electrònica de la UE (Art. 5(3)) i lleis nacionals equivalents.",
|
||||
"privacy.s5_p1_pre": "DocuElevate utilitza",
|
||||
"privacy.s5_p1_strong": "només cookies de sessió estrictament necessàries",
|
||||
"privacy.s5_p2_body": "cookies d'analítica, cookies publicitàries, píxels de seguiment o qualsevol cookie de tercers que requeriria el seu consentiment.",
|
||||
"privacy.s5_p2_label": "No fem servir:",
|
||||
"privacy.s5_p3_pre": "Per a detalls complets sobre les cookies que establim, els seus noms, durada i propòsit, si us plau, visiteu la nostra",
|
||||
"privacy.s6_ai_body": "Quan inicia l'OCR o l'extracció de metadades basada en IA, les dades del document es transmeten al servei d'IA que vostè o el seu administrador han configurat. Aquesta transmissió es regula per un acord de processament de dades amb el proveïdor respectiu.",
|
||||
"privacy.s6_ai_label": "Serveis de Processament d'IA (OpenAI, Azure Document Intelligence, altres):",
|
||||
"privacy.s6_heading": "6. Serveis de Tercers",
|
||||
"privacy.s6_no_sale_body": "No venem, lloguem ni compartim les seves dades personals amb tercers per publicitat, màrqueting o qualsevol propòsit no relacionat amb la prestació del servei.",
|
||||
"privacy.s6_no_sale_label": "Sense Venda ni Compartició per Publicitat:",
|
||||
"privacy.s6_oauth_body": "Quan escull autenticar-se a través d'OAuth, el proveïdor respectiu processa les seves credencials i pot compartir informació de perfil limitada amb nosaltres. Aquests proveïdors mantenen les seves pròpies polítiques de privadesa.",
|
||||
"privacy.s6_oauth_label": "Proveïdors d'OAuth (Google, Dropbox, Microsoft):",
|
||||
"privacy.s6_storage_body": "Els documents es desant a l'hospitalitat del proveïdor de núvol que vostè configura. Les seves credencials configurades s'emmagatzemen encriptades a la base de dades de l'aplicació i s'utilitzen exclusivament per realitzar les operacions d'emmagatzematge que vostè sol·licita.",
|
||||
"privacy.s6_storage_label": "Proveïdors d'Emmagatzematge al Núvol (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):",
|
||||
"privacy.s7_adequacy_body": "on la Comissió Europea ha reconegut un nivell equivalent de protecció (per exemple, Regne Unit, Suïssa, Canadà (organitzacions comercials), Japó, Corea del Sud).",
|
||||
"privacy.s7_adequacy_label": "Decisions d'Adquidesa",
|
||||
"privacy.s7_contact": "Pot sol·licitar una còpia de les salvaguardes rellevants contactant-nos a",
|
||||
"privacy.s7_heading": "7. Transfers Internacionals de Dades",
|
||||
"privacy.s7_idta_body": "per a transfers del Regne Unit després del Brexit.",
|
||||
"privacy.s7_idta_label": "Acords de Transferència Internacional de Dades del Regne Unit (IDTAs)",
|
||||
"privacy.s7_p1": "DocuElevate s'allotja a la Unió Europea / EEE per defecte. Quan les dades personals es transfereixen fora de l'EEE (per exemple, a proveïdors de serveis d'IA basats als EUA com OpenAI), ens basem en salvaguardes adequades que inclouen:",
|
||||
"privacy.s7_scc_body": "adoptades per la Comissió Europea (2021/914/EU) per a transferències a processadors i controladors en països tercers.",
|
||||
"privacy.s7_scc_label": "Clàusules Contractuals Estàndard (SCCs)",
|
||||
"privacy.s8_audit_body": "Conservades durant fins a 90 dies per a seguretat i compliment.",
|
||||
"privacy.s8_audit_label": "Registres d'Auditoria:",
|
||||
"privacy.s8_contact": "Per sol·licitar l'eliminació del seu compte i de totes les dades personals associades, si us plau, contacti'ns a",
|
||||
"privacy.s8_files_body": "Conservades durant la durada del seu ús del servei. Pot eliminar fitxers individuals en qualsevol moment a través de l'aplicació.",
|
||||
"privacy.s8_files_label": "Registres de fitxers i metadades:",
|
||||
"privacy.s8_heading": "8. Retenció de Dades",
|
||||
"privacy.s8_oauth_body": "Emmagatzemades en forma encriptada i revocables en qualsevol moment a través del seu proveïdor d'OAuth.",
|
||||
"privacy.s8_oauth_label": "Tokens d'OAuth:",
|
||||
"privacy.s8_p1": "Conservem dades personals només durant el temps estrictament necessari per oferir el servei DocuElevate o per complir amb les obligacions legals:",
|
||||
"privacy.s8_session_body": "Eliminades quan es desconnecta o després del temps d'espera de la sessió.",
|
||||
"privacy.s8_session_label": "Dades de sessió:",
|
||||
"privacy.s9_heading": "9. Seguretat de les dades",
|
||||
"privacy.s9_li1": "Encriptació de credencials i configuració sensible en repòs.",
|
||||
"privacy.s9_li2": "Transport Layer Security (TLS/HTTPS) per a totes les comunicacions.",
|
||||
"privacy.s9_li3": "Controls d'accés basats en rols que limiten l'accés a dades personals.",
|
||||
"privacy.s9_li4": "Auditories de seguretat regulars i escaneig de vulnerabilitats de dependències.",
|
||||
"privacy.s9_li5": "Protecció CSRF en totes les sol·licituds que canvien l'estat.",
|
||||
"privacy.s9_p1": "Implementem mesures tècniques i organitzatives apropiades (TOMs) per protegir les seves dades personals, incloent:",
|
||||
"privacy.toc_1": "Controlador de dades",
|
||||
"privacy.toc_10": "Els seus drets (UE / EEE / Regne Unit / Suïssa)",
|
||||
"privacy.toc_11": "Drets addicionals – Estats Units (CCPA/CPRA)",
|
||||
"privacy.toc_12": "Drets addicionals – Canadà (PIPEDA / Llei 25)",
|
||||
"privacy.toc_13": "Drets addicionals – Amèrica Llatina (LGPD i altres)",
|
||||
"privacy.toc_14": "Drets addicionals – Àsia-Pacífic i Japó",
|
||||
"privacy.toc_15": "Drets addicionals – Ucraïna",
|
||||
"privacy.toc_16": "Actualitzacions d'aquest Avís de Privadesa",
|
||||
"privacy.toc_2": "Àmbit d'aquest Avís de Privadesa",
|
||||
"privacy.toc_3": "Recollida de dades i finalitats",
|
||||
"privacy.toc_4": "Minimització de dades i limitació de la finalitat",
|
||||
"privacy.toc_5": "Ús de Cookies i Tecnologies Similars",
|
||||
"privacy.toc_6": "Serveis de Tercers",
|
||||
"privacy.toc_7": "Transfers de Dades Internacionals",
|
||||
"privacy.toc_8": "Retenció de Dades",
|
||||
"privacy.toc_9": "Seguretat de les Dades",
|
||||
"privacy.toc_heading": "Continguts",
|
||||
"profile.avatar_alt": "La teva imatge de perfil",
|
||||
"profile.avatar_heading": "Imatge de perfil",
|
||||
"profile.avatar_remove": "Eliminar avatar personalitzat",
|
||||
@@ -838,6 +1377,9 @@
|
||||
"profile.contact_email_label": "Correu electrònic de contacte / notificació",
|
||||
"profile.contact_email_placeholder": "tu@example.com",
|
||||
"profile.current_password": "Contrasenya actual",
|
||||
"profile.default_document_language_auto": "Utilitza el predeterminat del sistema",
|
||||
"profile.default_document_language_hint": "Els documents en altres llengües es tradueixen automàticament a aquesta llengua. Deixa en blanc per utilitzar el predeterminat del sistema (anglès).",
|
||||
"profile.default_document_language_label": "Llengua del Document Predeterminat",
|
||||
"profile.dismiss": "Descartar",
|
||||
"profile.display_name_hint": "Deixa en blanc per utilitzar el nom d'usuari o correu electrònic del teu compte.",
|
||||
"profile.display_name_label": "Nom a mostrar",
|
||||
@@ -1040,14 +1582,66 @@
|
||||
"similarity.subtitle": "Parells de documents amb alta similitud semàntica, ordenats per puntuació.",
|
||||
"similarity.trigger_aria": "Activar el càlcul d'embutició per a tots els fitxers que falten embuticions",
|
||||
"similarity.trigger_now": "activa-ho ara",
|
||||
"status.active": "Actiu",
|
||||
"status.ai_empty_response": "(bufa)",
|
||||
"status.ai_extraction_desc": "Enganxeu el contingut en text pla d'un document a continuació i_executeu-lo a través del proveïdor d'IA configurat per inspeccionar la resposta bruta, JSON extret i etiquetes.",
|
||||
"status.ai_extraction_failed": "Extracció d'IA Fallida",
|
||||
"status.ai_extraction_label": "Text del Document",
|
||||
"status.ai_extraction_placeholder": "Enganxeu aquí el contingut en text pla del vostre document…",
|
||||
"status.ai_extraction_title": "Prova d'Extracció d'IA",
|
||||
"status.app_version": "Versió de l'App",
|
||||
"status.as_account": "com",
|
||||
"status.auth_required": "Autenticació Requerida",
|
||||
"status.build_date": "Data de Construcció",
|
||||
"status.config_settings": "Configuració de Paràmetres",
|
||||
"status.config_settings_desc": "Per a configuracions de paràmetres més detallades i variables d'entorn, consulteu la pàgina de configuració.",
|
||||
"status.configure_now": "Configura Ara",
|
||||
"status.configured": "Configurat",
|
||||
"status.connection_error": "Error de Connexió",
|
||||
"status.connection_test_failed": "Prova de Connexió Fallida",
|
||||
"status.connection_test_successful": "Prova de connexió exitosa",
|
||||
"status.container_id": "ID del contenidor",
|
||||
"status.container_started": "Contenidor iniciat",
|
||||
"status.dashboard_subtitle": "Aquest tauler mostra l'estat de totes les integracions i objectius configurats.",
|
||||
"status.debug_mode": "Mode de depuració",
|
||||
"status.error_running_extraction": "Error en executar l'extracció: ",
|
||||
"status.error_testing_connection": "Error en provar la connexió: ",
|
||||
"status.error_testing_notifications": "Error en provar les notificacions: ",
|
||||
"status.extracted_tags": "Etiquetes extretes",
|
||||
"status.git_commit": "Compromís de Git",
|
||||
"status.inactive": "Inactiu",
|
||||
"status.json_parse_issue": "Problema en analitzar JSON: ",
|
||||
"status.last_check": "Última Comprovació",
|
||||
"status.manage": "Gestionar",
|
||||
"status.modal_default_message": "Operació completada amb èxit.",
|
||||
"status.modal_default_title": "Èxit",
|
||||
"status.no_details": "No hi ha detalls disponibles",
|
||||
"status.not_configured": "No configurat",
|
||||
"status.notification_config_missing": "Manca la configuració de notificacions",
|
||||
"status.open": "Obrir",
|
||||
"status.page_title": "Estat del Sistema",
|
||||
"status.parsed_json_label": "JSON analitzat",
|
||||
"status.provider_config_details": "Detalls de la configuració de {name}",
|
||||
"status.provider_details": "Detalls del proveïdor",
|
||||
"status.raw_llm_response": "Resposta LLM en brut",
|
||||
"status.run_extraction": "Executar extracció",
|
||||
"status.running": "Executant\u00150",
|
||||
"status.sending": "Enviant...",
|
||||
"status.setting_label": "Configuració",
|
||||
"status.test_connection": "Provar connexió",
|
||||
"status.test_extraction": "Provar extracció",
|
||||
"status.test_failed": "Prova fallida",
|
||||
"status.test_notification_failed": "La notificació de prova ha fallat",
|
||||
"status.test_notification_sent": "Notificació de prova enviada",
|
||||
"status.test_notifications": "Provar notificacions",
|
||||
"status.test_provider": "Provar {name}",
|
||||
"status.test_successful": "Prova exitosa",
|
||||
"status.testing": "Provant...",
|
||||
"status.token_expired": "El teu token ha expirat o és invàlid. Si us plau, torna a configurar aquesta connexió.",
|
||||
"status.token_valid_for": "Token vàlid durant:",
|
||||
"status.value_label": "Valor",
|
||||
"status.view_config": "Veure configuració detallada",
|
||||
"status.view_details": "Veure detalls",
|
||||
"subscription.available_plans_heading": "Plans disponibles",
|
||||
"subscription.back_to_dashboard": "Tornar al tauler de control",
|
||||
"subscription.cancel_pending": "Cancel\u0002d canvi",
|
||||
@@ -1083,6 +1677,51 @@
|
||||
"subscription.upgrade_info": "Les actualitzacions entren en vigor de manera immediata. Les disminucions es programen per al final del teu període de facturació actual.",
|
||||
"subscription.upgrade_to_prefix": "Actualitzar a",
|
||||
"subscription.usage_heading": "Ús",
|
||||
"terms.cookie_link": "Política de cookies",
|
||||
"terms.heading": "Termes del servei",
|
||||
"terms.last_updated": "Última actualització:",
|
||||
"terms.license_link": "Informació de la llicència",
|
||||
"terms.page_title": "Termes del servei - DocuElevate",
|
||||
"terms.privacy_link": "Política de privacitat",
|
||||
"terms.s1_heading": "1. Acceptació dels termes",
|
||||
"terms.s1_p1": "En accedir o utilitzar DocuElevate, accepteu estar subjecte a aquests Termes del servei. Si no accepteu aquests termes, si us plau, no utilitzeu aquest servei.",
|
||||
"terms.s2_heading": "2. Descripció del servei",
|
||||
"terms.s2_p1": "DocuElevate proporciona serveis de processament de documents, OCR, extracció de metadades i emmagatzematge. Ens reservem el dret de modificar o donar de baixa qualsevol aspecte del servei en qualsevol moment.",
|
||||
"terms.s3_heading": "3. Responsabilitats de l'usuari",
|
||||
"terms.s3_li1": "Tot el contingut que carregueu a DocuElevate",
|
||||
"terms.s3_li2": "Assegurant-vos que teniu els drets adequats per carregar i processar documents",
|
||||
"terms.s3_li3": "Mantenint la confidencialitat de les vostres credencials del compte",
|
||||
"terms.s3_li4": "Qualsevol activitat que es produeixi sota el vostre compte",
|
||||
"terms.s3_p1": "Sou responsables de:",
|
||||
"terms.s3_p2_and": "i",
|
||||
"terms.s3_p2_post": ".",
|
||||
"terms.s3_p2_pre": "En utilitzar el nostre servei, també accepteu la nostra",
|
||||
"terms.s4_heading": "4. Drets de propietat intel·lectual",
|
||||
"terms.s4_p1": "DocuElevate respecta els drets de propietat intel·lectual. Els usuaris no poden carregar contingut que infringeixi els drets de propietat intel·lectual d'altres.",
|
||||
"terms.s5_heading": "5. Limitació de responsabilitat",
|
||||
"terms.s5_p1": "DocuElevate proporciona el servei \"tal com és\" sense garanties de cap tipus. No serem responsables de danys directes, indirectes, incidentals, especials, conseqüencials o punitives derivades de l'ús o la incapacitat d'utilitzar el servei.",
|
||||
"terms.s6_heading": "6. Lleis aplicables",
|
||||
"terms.s6_p1": "Aquests termes es regiran per les lleis d'Alemanya, sense tenir en compte les seves disposicions sobre conflictes de llei.",
|
||||
"terms.s6_p2_post": ".",
|
||||
"terms.s6_p2_pre": "Si teniu alguna pregunta sobre aquests termes, si us plau, contacteu amb nosaltres a",
|
||||
"terms.s6_p3_mid": ". Per a informació sobre llicències, si us plau, consulteu la nostra",
|
||||
"terms.s6_p3_post": ".",
|
||||
"terms.s6_p3_pre": "Per a informació sobre com fem servir cookies, si us plau, vegeu la nostra",
|
||||
"translation.copied": "Copiat!",
|
||||
"translation.copy": "Copia",
|
||||
"translation.default_language_version": "Versió de Llengua Predeterminada",
|
||||
"translation.detected_language": "Llengua detectada",
|
||||
"translation.hide_text": "Amaga text",
|
||||
"translation.load_translation": "Carrega la traducció",
|
||||
"translation.no_translation": "No hi ha traducció disponible encara \u00135 pot estar processant-se",
|
||||
"translation.select_language": "Selecciona idioma\u00135",
|
||||
"translation.select_target": "Si us plau, selecciona un idioma de destinació.",
|
||||
"translation.show_text": "Mostrar text",
|
||||
"translation.translate_btn": "Tradueix",
|
||||
"translation.translate_to": "Tradueix a un altre idioma",
|
||||
"translation.translated_to": "Traduit a",
|
||||
"translation.translating": "Traduint\u00135",
|
||||
"translation.translation_failed": "La traducció ha fallat",
|
||||
"upload.browse_button": "Navegar Fitxers",
|
||||
"upload.button_processing": "Processant...",
|
||||
"upload.camera_button": "Fer Foto / Escanejar Document",
|
||||
|
||||
@@ -41,6 +41,280 @@
|
||||
"about.story_heading": "Náš příběh",
|
||||
"about.story_p1": "DocuElevate byl vytvořen s jedním cílem: zjednodušit a zefektivnit správu dokumentů pro každého, ať už jste malý startup nebo velký podnik.",
|
||||
"about.story_p2": "Využíváme sílu plug-and-play AI poskytovatelů (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey a další) pro extrakci metadat a vylepšení textu, bezproblémově integrujeme s Dropboxem, Nextcloudem a Paperless NGX pro ukládání a indexaci, využíváme Azure Document Intelligence pro OCR a dokonce používáme Gotenberg pro konverze souborů na PDF.",
|
||||
"admin_files.admin_only_badge": "Pouze pro administrátory",
|
||||
"admin_files.aria_breadcrumb": "Navigační lišta",
|
||||
"admin_files.badge_delta_detected": "Změna zjištěna",
|
||||
"admin_files.badge_duplicate": "dup",
|
||||
"admin_files.badge_in_db": "v DB",
|
||||
"admin_files.badge_on_disk": "na disku",
|
||||
"admin_files.breadcrumb_workdir": "pracovní adresář",
|
||||
"admin_files.btn_download": "Stáhnout",
|
||||
"admin_files.col_actions": "Akce",
|
||||
"admin_files.col_db": "DB",
|
||||
"admin_files.col_health": "Zdraví",
|
||||
"admin_files.col_id": "ID",
|
||||
"admin_files.col_ingested": "Zpracováno",
|
||||
"admin_files.col_local_filename": "místní_název_souboru",
|
||||
"admin_files.col_missing_paths": "Chybné cesty",
|
||||
"admin_files.col_modified": "Upraveno",
|
||||
"admin_files.col_name": "Název",
|
||||
"admin_files.col_original_file_path": "původní_cesta_k_souboru",
|
||||
"admin_files.col_original_filename": "Původní název souboru",
|
||||
"admin_files.col_path_relative": "Cesta (relativně k pracovnímu adresáři)",
|
||||
"admin_files.col_processed_file_path": "zpracovaná_cesta_k_souboru",
|
||||
"admin_files.col_size": "Velikost",
|
||||
"admin_files.delta_detected_detail": "Na disku bylo nalezeno {orphan_count} osamělých souborů bez záznamu v databázi a {ghost_count} záznamů v databázi se chybějícími soubory na disku.",
|
||||
"admin_files.delta_detected_title": "Delta zjištěna.",
|
||||
"admin_files.empty_database": "V databázi nebyly nalezeny žádné záznamy o souborech.",
|
||||
"admin_files.empty_directory": "Tento adresář je prázdný.",
|
||||
"admin_files.ghost_records_desc": "(v DB, soubor(y) chybí na disku)",
|
||||
"admin_files.ghost_records_heading": "Duchovní záznamy",
|
||||
"admin_files.heading": "Správce souborů",
|
||||
"admin_files.health_missing": "Chybí",
|
||||
"admin_files.health_ok": "OK",
|
||||
"admin_files.legend_file_exists": "Soubor existuje na disku",
|
||||
"admin_files.legend_file_missing": "Soubor chybí z disku",
|
||||
"admin_files.legend_found_in_db": "Nalezeno v DB",
|
||||
"admin_files.legend_not_in_db": "Není v DB (osamělý)",
|
||||
"admin_files.legend_path_not_set": "Cesta není nastavena",
|
||||
"admin_files.no_delta": "Žádná delta nebyla nalezena — souborový systém a databáze jsou synchronizovány.",
|
||||
"admin_files.no_ghost_records": "Nebyl nalezen žádný duchovní záznam.",
|
||||
"admin_files.no_orphan_files": "Nebyl nalezen žádný osamělý soubor.",
|
||||
"admin_files.orphan_files_desc": "(na disku, žádný záznam v DB)",
|
||||
"admin_files.orphan_files_heading": "Osamělé soubory",
|
||||
"admin_files.page_title": "Správce souborů – Admin",
|
||||
"admin_files.status_in_db": "V DB",
|
||||
"admin_files.status_orphan": "Osamělý",
|
||||
"admin_files.tab_database": "Záznamy databázové",
|
||||
"admin_files.tab_filesystem": "Souborový systém",
|
||||
"admin_files.tab_reconcile": "Srovnat",
|
||||
"admin_plans.aria_delete_plan": "Smazat {name}",
|
||||
"admin_plans.aria_edit_plan": "Upravit {name}",
|
||||
"admin_plans.aria_feature_n": "Funkce {n}",
|
||||
"admin_plans.aria_move_down": "Posunout {name} dolů",
|
||||
"admin_plans.aria_move_up": "Posunout {name} nahoru",
|
||||
"admin_plans.aria_remove_feature_n": "Odstranit funkci {n}",
|
||||
"admin_plans.btn_add_feature": "Přidat funkci",
|
||||
"admin_plans.btn_add_plan": "Přidat plán",
|
||||
"admin_plans.btn_cancel": "Zrušit",
|
||||
"admin_plans.btn_create": "Vytvořit plán",
|
||||
"admin_plans.btn_delete": "Smazat",
|
||||
"admin_plans.btn_edit": "Upravit",
|
||||
"admin_plans.btn_restore_defaults": "Obnovit výchozí hodnoty",
|
||||
"admin_plans.btn_restore_defaults_title": "Obnovit všechny čtyři výchozí plány (pouze pokud zatím neexistují žádné plány)",
|
||||
"admin_plans.btn_restoring": "Obnovuji\u0000\u0000\u0000\u0000...",
|
||||
"admin_plans.btn_save_changes": "Uložit změny",
|
||||
"admin_plans.btn_save_order": "Uložit pořadí",
|
||||
"admin_plans.btn_saving": "Uložuji\u0000\u0000\u0000\u0000...",
|
||||
"admin_plans.btn_stripe_setup": "Nastavení Stripe",
|
||||
"admin_plans.btn_stripe_setup_title": "Otevřít průvodce nastavením Stripe pro konfiguraci API klíčů a synchronizaci plánů",
|
||||
"admin_plans.col_actions": "Akce",
|
||||
"admin_plans.col_active": "Aktivní",
|
||||
"admin_plans.col_monthly": "Měsíčně",
|
||||
"admin_plans.col_monthly_limit": "Měsíční limit",
|
||||
"admin_plans.col_order": "Pořadí",
|
||||
"admin_plans.col_overage_pct": "Překročení %",
|
||||
"admin_plans.col_plan": "Plán",
|
||||
"admin_plans.col_yearly": "Ročně",
|
||||
"admin_plans.coming_soon": "Brzy příjde",
|
||||
"admin_plans.featured_badge": "Vybrané",
|
||||
"admin_plans.field_active": "Aktivní",
|
||||
"admin_plans.field_allow_overage": "Povolit účet za překročení",
|
||||
"admin_plans.field_api_access": "Přístup k API",
|
||||
"admin_plans.field_badge_text": "Text odznaku",
|
||||
"admin_plans.field_buffer": "Rezerva:",
|
||||
"admin_plans.field_cta_text": "Text tlačítka CTA",
|
||||
"admin_plans.field_docs_month": "Dokumenty / měsíc",
|
||||
"admin_plans.field_featured": "Vybrané / Zmíněné",
|
||||
"admin_plans.field_lifetime_docs": "Dokumenty na celý život",
|
||||
"admin_plans.field_mailboxes": "Emailové schránky",
|
||||
"admin_plans.field_max_file_size": "Maximální velikost souboru (MB)",
|
||||
"admin_plans.field_name": "Název",
|
||||
"admin_plans.field_ocr_pages": "OCR Stránky / měsíc",
|
||||
"admin_plans.field_overage_doc_price": "Cena za přečerpání / dok ($)",
|
||||
"admin_plans.field_overage_ocr_price": "Cena za přečerpání / OCR stránku ($)",
|
||||
"admin_plans.field_plan_id": "ID plánu",
|
||||
"admin_plans.field_price_monthly": "Měsíční cena ($)",
|
||||
"admin_plans.field_price_yearly": "Roční cena ($)",
|
||||
"admin_plans.field_sort_order": "Pořadí",
|
||||
"admin_plans.field_storage_dests": "Úložiště",
|
||||
"admin_plans.field_stripe_monthly": "ID ceny Stripe (měsíčně)",
|
||||
"admin_plans.field_stripe_yearly": "ID ceny Stripe (ročně)",
|
||||
"admin_plans.field_tagline": "Slogan",
|
||||
"admin_plans.field_trial_days": "Zkušební dny",
|
||||
"admin_plans.free_label": "Zdarma",
|
||||
"admin_plans.heading": "Návrhář plánů",
|
||||
"admin_plans.hint_features": "Tyto odrážky se objevují na kartě cenové stránky pro tento plán.",
|
||||
"admin_plans.hint_plan_id": "Malými písmeny, nelze změnit po vytvoření.",
|
||||
"admin_plans.hint_zero_unlimited": "Zadejte 0 pro neomezené.",
|
||||
"admin_plans.js_delete_confirm": "Smazat plán \"{id}\"? To nelze vrátit zpět.",
|
||||
"admin_plans.js_delete_failed": "Smazání se nezdařilo",
|
||||
"admin_plans.js_failed_load": "Načtení plánů se nezdařilo",
|
||||
"admin_plans.js_order_saved": "Objednávka uložena!",
|
||||
"admin_plans.js_plan_created": "Plán vytvořen!",
|
||||
"admin_plans.js_plan_deleted": "Plán \"{id}\" smazán.",
|
||||
"admin_plans.js_plan_updated": "Plán aktualizován!",
|
||||
"admin_plans.js_reorder_failed": "Znovu objednat se nezdařilo",
|
||||
"admin_plans.js_save_failed": "Uložení se nezdařilo",
|
||||
"admin_plans.js_seed_confirm": "Osejte čtyři výchozí plány? To je no-op, pokud plány již existují.",
|
||||
"admin_plans.js_seed_failed": "Osejování se nezdařilo",
|
||||
"admin_plans.js_yearly_enter": "Zadejte roční cenu pro zobrazení úspor",
|
||||
"admin_plans.js_yearly_save": "Ušetřete {pct}% oproti měsíčnímu",
|
||||
"admin_plans.loading": "Načítání plánů\u001c",
|
||||
"admin_plans.modal_close_aria": "Zavřít modul",
|
||||
"admin_plans.modal_create_title": "Přidat plán",
|
||||
"admin_plans.modal_edit_title_prefix": "Upravit plán: ",
|
||||
"admin_plans.no_plans_intro": "Zatím žádné plány. Klikněte",
|
||||
"admin_plans.no_plans_suffix": "pro osev čtyř vestavěných plánů.",
|
||||
"admin_plans.overage_0pct": "0% (přesně)",
|
||||
"admin_plans.overage_100pct": "100%",
|
||||
"admin_plans.overage_50pct": "50%",
|
||||
"admin_plans.overage_announce": "\u00134; oznámit",
|
||||
"admin_plans.overage_buffer_body_prefix": "Překročení limitu je",
|
||||
"admin_plans.overage_buffer_body_suffix": "Inzerujeme X dokumentů/měsíc, ale vynucujeme pouze na",
|
||||
"admin_plans.overage_buffer_formula": "X \u0000d7 (1 + buffer%)",
|
||||
"admin_plans.overage_buffer_invisible": "neviditelné pro uživatele",
|
||||
"admin_plans.overage_buffer_tail": "dokumenty. Například plán na 150 dokumentů/měsíc s 20% rezervou se vynucuje na 180 dokumentech. To zabraňuje náhlým přerušení na přesně oznámeném limitu a poskytuje uživatelům jemný přistání.",
|
||||
"admin_plans.overage_buffer_title": "O rezervačním překročení",
|
||||
"admin_plans.overage_docs": "dokumentů,",
|
||||
"admin_plans.overage_docs_end": "dokumentů",
|
||||
"admin_plans.overage_enforce_at": "vynutit na",
|
||||
"admin_plans.page_title": "Návrhář plánu \u0005\u001415 DokumentyDocuElevate Admin",
|
||||
"admin_plans.section_basic_info": "Základní informace",
|
||||
"admin_plans.section_display": "Zobrazení",
|
||||
"admin_plans.section_features": "Seznam funkcí",
|
||||
"admin_plans.section_overage": "Návrhář překročení",
|
||||
"admin_plans.section_pricing": "Ceny",
|
||||
"admin_plans.section_stripe": "Integrace Stripe",
|
||||
"admin_plans.section_volume": "Objemové limity",
|
||||
"admin_plans.status_active": "Aktivní",
|
||||
"admin_plans.status_inactive": "Neaktivní",
|
||||
"admin_plans.stripe_desc_after": "pro automatické vytvoření. Bezplatné plány nepotřebují ID cen Stripe.",
|
||||
"admin_plans.stripe_desc_before": "Zadejte ID cen Stripe pro tento plán, nebo použijte",
|
||||
"admin_plans.stripe_wizard_aria": "Otevřít průvodce nastavením Stripe v novém okně",
|
||||
"admin_plans.stripe_wizard_link": "Průvodce Stripe",
|
||||
"admin_plans.stripe_wizard_text": "Průvodce nastavením Stripe",
|
||||
"admin_plans.subheading": "Spravujte předplatné plány zobrazené na veřejné stránce cen.",
|
||||
"admin_plans.table_aria_label": "Předplatné plány",
|
||||
"admin_users.add_user_profile_btn": "Přidat profil uživatele",
|
||||
"admin_users.admin_only_badge": "Pouze pro administrátory",
|
||||
"admin_users.btn_password": "Heslo",
|
||||
"admin_users.btn_reset": "Obnovit",
|
||||
"admin_users.col_display_name": "Zobrazované jméno",
|
||||
"admin_users.col_documents": "Dokumenty",
|
||||
"admin_users.col_email": "E-mail",
|
||||
"admin_users.col_last_upload": "Poslední nahrání",
|
||||
"admin_users.col_plan": "Plán",
|
||||
"admin_users.col_role": "Role",
|
||||
"admin_users.col_upload_limit": "Limit nahrávání",
|
||||
"admin_users.col_user_id": "ID uživatele",
|
||||
"admin_users.col_username": "Uživatelské jméno",
|
||||
"admin_users.create_local_account_btn": "Vytvořit místní účet",
|
||||
"admin_users.create_local_title": "Vytvořit místní účet",
|
||||
"admin_users.delete_account_btn": "Smazat účet",
|
||||
"admin_users.delete_local_confirm": "Opravu chcete smazat účet pro",
|
||||
"admin_users.delete_local_title": "Smazat místní účet",
|
||||
"admin_users.delete_local_warning": "Toto nelze vrátit zpět. Dokumenty vlastněné tímto uživatelem nejsou smazány.",
|
||||
"admin_users.delete_profile_btn": "Smazat profil",
|
||||
"admin_users.delete_profile_confirm": "Opravu chcete smazat profil pro",
|
||||
"admin_users.delete_profile_title": "Smazat profil uživatele",
|
||||
"admin_users.delete_profile_warning": "Toto pouze odstraní záznam o profilu řízeném administrátorem. Dokumenty vlastněné tímto uživatelem nejsou smazány.",
|
||||
"admin_users.deleting": "Mažu\n",
|
||||
"admin_users.edit_local_title": "Upravit místní účet",
|
||||
"admin_users.filter_placeholder": "Filtr podle ID uživatele\n",
|
||||
"admin_users.global_default": "globální výchozí",
|
||||
"admin_users.heading": "Správa uživatelů",
|
||||
"admin_users.js_account_created": "Účet vytvořen",
|
||||
"admin_users.js_account_created_msg": "Místní účet pro \"{username}\" byl úspěšně vytvořen.",
|
||||
"admin_users.js_account_deleted_msg": "Účet pro \"{username}\" byl odstraněn.",
|
||||
"admin_users.js_account_updated": "Účet byl aktualizován.",
|
||||
"admin_users.js_delete_failed": "Odstranění selhalo",
|
||||
"admin_users.js_deleted": "Smazáno",
|
||||
"admin_users.js_email_not_sent": "E-mail nebyl odeslán",
|
||||
"admin_users.js_email_sent": "E-mail odeslán",
|
||||
"admin_users.js_email_sent_msg": "E-mail pro obnovení hesla byl odeslán na \"{email}\".",
|
||||
"admin_users.js_failed": "Selhalo",
|
||||
"admin_users.js_failed_create": "Nepodařilo se vytvořit účet.",
|
||||
"admin_users.js_failed_load_local": "Nepodařilo se načíst místní uživatele",
|
||||
"admin_users.js_failed_load_users": "Nepodařilo se načíst uživatele",
|
||||
"admin_users.js_failed_set_password": "Nepodařilo se nastavit heslo.",
|
||||
"admin_users.js_failed_update": "Nepodařilo se aktualizovat účet.",
|
||||
"admin_users.js_network_error": "Chyba sítě",
|
||||
"admin_users.js_password_set": "Heslo nastaveno",
|
||||
"admin_users.js_password_set_msg": "Heslo pro \"{username}\" bylo aktualizováno.",
|
||||
"admin_users.js_profile_deleted": "Profil pro \"{id}\" byl odstraněn.",
|
||||
"admin_users.js_profile_saved": "Profil pro \"{id}\" byl uložen.",
|
||||
"admin_users.js_save_failed": "Uložení se nezdařilo",
|
||||
"admin_users.js_saved": "Uloženo",
|
||||
"admin_users.js_smtp_not_configured": "SMTP není nakonfigurováno.",
|
||||
"admin_users.js_updated": "Aktualizováno",
|
||||
"admin_users.loading_users": "Načítání uživatelů\u000206",
|
||||
"admin_users.local_account_active": "Účet aktivní",
|
||||
"admin_users.local_accounts_heading": "Místní uživatelské účty",
|
||||
"admin_users.local_accounts_subheading": "Účty s e-mailem/heslem vytvořené přímo na tomto serveru.",
|
||||
"admin_users.local_admin_privileges": "Poskytnout administrativní privilegium",
|
||||
"admin_users.local_admin_privileges_short": "Administrativní privilegium",
|
||||
"admin_users.local_create_btn": "Vytvořit účet",
|
||||
"admin_users.local_create_one": "Vytvořit jeden.",
|
||||
"admin_users.local_creating": "Vytváření\u000206",
|
||||
"admin_users.local_display_name_optional": "(volitelné)",
|
||||
"admin_users.local_loading": "Načítání\u000206",
|
||||
"admin_users.local_no_accounts": "Dosud žádné místní účty.",
|
||||
"admin_users.local_password_hint": "Minimálně 8 znaků.",
|
||||
"admin_users.local_saving": "Ukládání\u000206",
|
||||
"admin_users.local_username_hint": "3\u000204 znaky. Pouze písmena, čísla, pomlčky a podtržítka.",
|
||||
"admin_users.modal_add_title": "Přidat uživatelský profil",
|
||||
"admin_users.modal_billing_cycle_label": "Frekvence účtování",
|
||||
"admin_users.modal_billing_monthly": "Měsíčně",
|
||||
"admin_users.modal_billing_yearly": "Ročně",
|
||||
"admin_users.modal_block_hint": "(brání nahrávání nových dokumentů)",
|
||||
"admin_users.modal_block_label": "Zablokovat tohoto uživatele",
|
||||
"admin_users.modal_close_aria": "Zavřít dialog",
|
||||
"admin_users.modal_complimentary_hint": "(uživatel si zachovává výhody úrovně, ale nikdy není účtován — automaticky nastaveno pro administrativní účty)",
|
||||
"admin_users.modal_complimentary_label": "Bezplatný plán",
|
||||
"admin_users.modal_daily_limit_hint": "(nechte prázdné pro použití globálního výchozího nastavení)",
|
||||
"admin_users.modal_daily_limit_label": "Denní limit nahrávání",
|
||||
"admin_users.modal_daily_limit_placeholder": "např. 50 (0 = neomezeno)",
|
||||
"admin_users.modal_display_name_label": "Zobrazované jméno",
|
||||
"admin_users.modal_display_name_placeholder": "Alice Smith (volitelné)",
|
||||
"admin_users.modal_edit_title": "Upravit profil uživatele",
|
||||
"admin_users.modal_notes_label": "Admin poznámky",
|
||||
"admin_users.modal_notes_placeholder": "Interní poznámky viditelné pouze pro administrátory\u0000",
|
||||
"admin_users.modal_period_start_hint": "Roční přenos se počítá od tohoto data. Nechte prázdné pro měsíční vynucení.",
|
||||
"admin_users.modal_period_start_label": "Začátek předplatného",
|
||||
"admin_users.modal_plan_business": "Podnikání \u0000 $7.99/měsíc (300/měsíc, neomezené schránky)",
|
||||
"admin_users.modal_plan_free": "Zdarma \u0000 25 doživotních souborů",
|
||||
"admin_users.modal_plan_hint": "Nastavuje kvótní limity pro tohoto uživatele. Limity se uplatňují při nahrávání.",
|
||||
"admin_users.modal_plan_label": "Plán předplatného",
|
||||
"admin_users.modal_plan_professional": "Profesionální \u0000 $5.99/měsíc (150/měsíc, 3 schránky)",
|
||||
"admin_users.modal_plan_starter": "Startovní \u0000 $2.99/měsíc (50/měsíc, 1 schránka)",
|
||||
"admin_users.modal_save_changes": "Uložit změny",
|
||||
"admin_users.modal_saving": "Ukládání\u0000",
|
||||
"admin_users.modal_user_id_hint": "Stabilní identifikátor, který odpovídá owner_id v dokumentech.",
|
||||
"admin_users.modal_user_id_label": "ID uživatele",
|
||||
"admin_users.modal_user_id_placeholder": "user@example.com nebo OAuth sub",
|
||||
"admin_users.new_account_btn": "Nový účet",
|
||||
"admin_users.new_password_label": "Nové heslo",
|
||||
"admin_users.no_users_add_hint": "Nahrajte nějaké dokumenty nebo přidejte profil výše.",
|
||||
"admin_users.no_users_found": "Nenašli se žádní uživatelé.",
|
||||
"admin_users.no_users_search_hint": "Zkuste jiný vyhledávací výraz.",
|
||||
"admin_users.page_title": "Správa uživatelů \u0000 Administrátor \u0000 DocuElevate",
|
||||
"admin_users.pagination_page_of": "z",
|
||||
"admin_users.per_day": "/ den",
|
||||
"admin_users.role_admin": "Administrátor",
|
||||
"admin_users.role_user": "Uživatel",
|
||||
"admin_users.search_users_label": "Hledat uživatele",
|
||||
"admin_users.set_password_btn": "Nastavit heslo",
|
||||
"admin_users.set_password_desc": "Uživatel by měl toto heslo změnit po přihlášení.",
|
||||
"admin_users.set_password_desc_pre": "Nastavit nové heslo přímo pro",
|
||||
"admin_users.set_password_title": "Nastavit dočasné heslo",
|
||||
"admin_users.setting": "Nastavení\u0000",
|
||||
"admin_users.status_blocked": "Blokováno",
|
||||
"admin_users.status_unverified": "Neověřeno",
|
||||
"admin_users.subheading": "Spravujte profily uživatelů, limity nahrávání na uživatele a vlastnictví dokumentů.",
|
||||
"admin_users.total_count_users": "{count} uživatelé",
|
||||
"admin_users.total_no_users": "Žádní uživatelé",
|
||||
"admin_users.total_one_user": "1 uživatel",
|
||||
"api_tokens.col_created": "Vytvořeno",
|
||||
"api_tokens.col_last_ip": "Poslední IP",
|
||||
"api_tokens.col_last_used": "Naposledy použito",
|
||||
@@ -73,6 +347,19 @@
|
||||
"api_tokens.usage_intro_pre": "Použijte svůj API token v",
|
||||
"api_tokens.your_tokens": "Vaše tokeny",
|
||||
"app.name": "DocuElevate",
|
||||
"attribution.heading": "Atribuce softwaru třetích stran",
|
||||
"attribution.intro": "DocuElevate používá několik knihoven a nástrojů s open-source. Jsme vděční vývojářům těchto projektů za jejich příspěvky do open-source softwaru.",
|
||||
"attribution.page_title": "DocuElevate - Atribuce třetích stran",
|
||||
"attribution.paramiko_lgpl_note": "Poznámka: Tato knihovna je licencována pod GNU Lesser General Public License v2.1 (LGPL-2.1)",
|
||||
"attribution.section_docker": "Docker Obrázky",
|
||||
"attribution.section_frontend": "Závislosti Frontendu",
|
||||
"attribution.section_python": "Závislosti Pythonu",
|
||||
"attribution.special_lgpl_link": "zde",
|
||||
"attribution.special_lgpl_post": ".",
|
||||
"attribution.special_lgpl_pre": "Kopie LGPL licence je k nalezení",
|
||||
"attribution.special_source_post": ".",
|
||||
"attribution.special_source_pre": "Tento software obsahuje Paramiko, které je licencováno pod LGPL. Zdrojový kód pro Paramiko je k dispozici na",
|
||||
"attribution.special_title": "Speciální atribuce:",
|
||||
"audit.col_ip": "IP",
|
||||
"audit.col_resource": "Zdroje",
|
||||
"audit.col_timestamp": "Časová značka",
|
||||
@@ -252,6 +539,41 @@
|
||||
"cookie.notice_label": "Oznámení o souborech cookie",
|
||||
"cookie.policy_link": "Zásady cookie",
|
||||
"cookie.privacy_link": "Oznámení o ochraně soukromí",
|
||||
"cookie_policy.heading": "Zásady používání souborů cookie",
|
||||
"cookie_policy.last_updated": "Naposledy aktualizováno:",
|
||||
"cookie_policy.page_title": "Zásady používání souborů cookie - DocuElevate",
|
||||
"cookie_policy.s1_heading": "Co jsou cookies",
|
||||
"cookie_policy.s1_p1": "Cookies jsou malé textové soubory, které se ukládají na váš počítač nebo mobilní zařízení, když navštívíte webovou stránku. Jsou široce používány k tomu, aby webové stránky pracovaly efektivněji a poskytovaly informace majitelům webových stránek.",
|
||||
"cookie_policy.s2_heading": "Jak používáme cookies",
|
||||
"cookie_policy.s2_li1_body": "K identifikaci při přihlášení a udržení vaší relace, zatímco používáte aplikaci.",
|
||||
"cookie_policy.s2_li1_label": "Autentizace a správa relací:",
|
||||
"cookie_policy.s2_p1_post": "pro následující účel:",
|
||||
"cookie_policy.s2_p1_pre": "DocuElevate používá",
|
||||
"cookie_policy.s2_p1_strong": "pouze striktně nezbytné relace cookies",
|
||||
"cookie_policy.s2_p2": "Tyto cookies jsou povinné pro řádné fungování naší služby. Bez těchto cookies byste museli během procházení relace znovu a znovu přihlašovat.",
|
||||
"cookie_policy.s2_p3": "Protože jsou tyto cookies striktně nezbytné pro fungování služby, jsou osvobozeny od požadavků na předchozí souhlas podle směrnice EU o ochraně soukromí (čl. 5(3)) a ekvivalentních národních implementací. Nenastavujeme žádné volitelné, analytické, reklamní ani sledovací cookies.",
|
||||
"cookie_policy.s3_col_duration": "Doba",
|
||||
"cookie_policy.s3_col_name": "Název",
|
||||
"cookie_policy.s3_col_purpose": "Účel",
|
||||
"cookie_policy.s3_col_type": "Typ",
|
||||
"cookie_policy.s3_heading": "Podrobnosti o cookies",
|
||||
"cookie_policy.s3_row1_duration": "Relace (smazáno po zavření prohlížeče nebo odhlášení)",
|
||||
"cookie_policy.s3_row1_purpose": "Udržuje vaši autentizovanou relaci; nezbytné pro fungování přihlášení.",
|
||||
"cookie_policy.s3_row1_type": "Striktně nezbytné",
|
||||
"cookie_policy.s3_row2_duration": "Persistenční (místní úložiště prohlížeče)",
|
||||
"cookie_policy.s3_row2_purpose": "Ukládá vaše potvrzení oznámení o souborech cookie, takže se znovu nezobrazí (uloženo v místním úložišti, nikoli v cookie).",
|
||||
"cookie_policy.s3_row2_type": "Striktně nezbytné",
|
||||
"cookie_policy.s4_heading": "Žádné cookies třetích stran",
|
||||
"cookie_policy.s4_p1": "DocuElevate nepoužívá žádné cookies třetích stran, sledovací cookies, reklamní cookies ani analytické cookies. Respektujeme vaše soukromí a implementujeme pouze minimální cookies nezbytné pro fungování naší služby.",
|
||||
"cookie_policy.s4_p2_pre": "Pro více informací o tom, jak zpracováváme vaše data, prosím, podívejte se na naše",
|
||||
"cookie_policy.s4_privacy_link": "Oznámení o ochraně soukromí",
|
||||
"cookie_policy.s5_heading": "Správa cookies",
|
||||
"cookie_policy.s5_p1": "Většina webových prohlížečů vám umožňuje ovládat cookies prostřednictvím nastavení. Nicméně blokování nebo odstranění našich relacních cookies zabrání správné funkci DocuElevate, protože uživatelská autentizace závisí na těchto cookies.",
|
||||
"cookie_policy.s5_p2": "Také můžete kdykoli vymazat potvrzení o oznámení cookies uložené v místním úložišti vašeho prohlížeče prostřednictvím nástrojů pro vývojáře vašeho prohlížeče (Aplikace \u001f860; Místní úložiště).",
|
||||
"cookie_policy.s5_p3_and": "a",
|
||||
"cookie_policy.s5_p3_pre": "Tato politika cookies je součástí a je zahrnuta do našeho",
|
||||
"cookie_policy.s5_privacy_link": "Oznámení o ochraně soukromí",
|
||||
"cookie_policy.s5_terms_link": "Podmínky služby",
|
||||
"credentials.col_action": "Akce",
|
||||
"credentials.col_credential": "Uvěření",
|
||||
"credentials.col_source": "Zdroj",
|
||||
@@ -517,6 +839,31 @@
|
||||
"help.workflows_step_5_create": "Uložit – nové dokumenty budou touto pipeline automaticky zpracovány.",
|
||||
"help.workflows_typical_steps": "Typické kroky",
|
||||
"help.workflows_what_is": "Co je Pipeline?",
|
||||
"imprint.business_registration_heading": "Registrace podnikání",
|
||||
"imprint.business_registration_vat": "Identifikační číslo DPH podle \u001a27a zákona o dani z přidané hodnoty:",
|
||||
"imprint.contact_heading": "Kontaktní informace",
|
||||
"imprint.dispute_heading": "Online řešení sporů",
|
||||
"imprint.dispute_p1": "Evropská komise poskytuje platformu pro online řešení sporů (OS):",
|
||||
"imprint.dispute_p2": "Nejsme ochotni ani povinni se účastnit řízení o řešení sporů před spotřebitelskou rozhodčí komisí.",
|
||||
"imprint.heading": "Imprint",
|
||||
"imprint.legal_copyright": "Veškerý obsah na této webové stránce je chráněn autorskými právy. Jakékoli použití mimo rámec autorského práva vyžaduje písemný souhlas příslušného autora nebo tvůrce.",
|
||||
"imprint.legal_heading": "Právní oznámení",
|
||||
"imprint.legal_liability": "I přes pečlivou kontrolu obsahu nepřebíráme žádnou odpovědnost za obsah externích odkazů. Provozovatelé propojených stránek jsou výhradně zodpovědní za jejich obsah.",
|
||||
"imprint.page_title": "Imprint - DocuElevate",
|
||||
"imprint.policies_cookie_desc": "Informace o cookies, které používáme",
|
||||
"imprint.policies_cookie_label": "Politika cookies",
|
||||
"imprint.policies_heading": "Související politiky",
|
||||
"imprint.policies_intro": "Naše služba se řídí následujícími politikami:",
|
||||
"imprint.policies_license_desc": "Jak je naše software licencováno",
|
||||
"imprint.policies_license_label": "Informace o licenci",
|
||||
"imprint.policies_privacy_desc": "Jak zacházíme s vašimi daty",
|
||||
"imprint.policies_privacy_label": "Politika ochrany soukromí",
|
||||
"imprint.policies_terms_desc": "Pravidla pro používání DocuElevate",
|
||||
"imprint.policies_terms_label": "Podmínky služby",
|
||||
"imprint.provider_heading": "Poskytovatel služby",
|
||||
"imprint.responsible_content_heading": "Odpovědnost za obsah",
|
||||
"imprint.responsible_content_rstv": "Podle \u001a 55 Abs. 2 RStV:",
|
||||
"imprint.subtitle": "Informace podle \u001a 5 TMG (Německý zákon o telemédiích)",
|
||||
"index.badge_intelligent": "Inteligentní zpracování dokumentů",
|
||||
"index.button_browse_files": "Procházet soubory",
|
||||
"index.button_upload": "Nahrát",
|
||||
@@ -728,6 +1075,19 @@
|
||||
"language.tr": "Turečtina",
|
||||
"language.uk": "Ukrajinština",
|
||||
"language.zh": "Čínština",
|
||||
"license.apache_description": "DocuElevate je distribuováno pod licencí Apache 2.0, což je permisivní open-source software licence, která vám umožňuje používat, upravovat, distribuovat a přispívat do projektu.",
|
||||
"license.apache_heading": "Licence Apache 2.0",
|
||||
"license.heading": "Informace o licenci",
|
||||
"license.page_title": "Informace o licenci - DocuElevate",
|
||||
"license.related_about_link": "O stránce",
|
||||
"license.related_and": "a",
|
||||
"license.related_heading": "Související informace",
|
||||
"license.related_p1_post": "pro informace o používání služby DocuElevate.",
|
||||
"license.related_p1_pre": "Pokud tato licence upravuje používání našeho softwaru, prosím také si přečtěte naši",
|
||||
"license.related_p2_post": ".",
|
||||
"license.related_p2_pre": "Pro více informací o DocuElevate navštivte",
|
||||
"license.related_privacy_link": "Zásady ochrany osobních údajů",
|
||||
"license.related_terms_link": "Podmínky služby",
|
||||
"nav.about": "O nás",
|
||||
"nav.account_menu": "Účet",
|
||||
"nav.account_menu_for": "Účet pro {name}",
|
||||
@@ -828,6 +1188,185 @@
|
||||
"pipelines.system_label": "Systém",
|
||||
"pipelines.system_pipeline_label": "Systémová pipeline (viditelná pro všechny uživatele)",
|
||||
"pipelines.title": "Zpracování pipeline",
|
||||
"privacy.heading": "DocuElevate – Oznámení o ochraně soukromí",
|
||||
"privacy.last_updated": "Naposledy aktualizováno:",
|
||||
"privacy.page_title": "Oznámení o ochraně soukromí - DocuElevate",
|
||||
"privacy.s10_access_body": "Můžete požádat o kopii osobních údajů, které o vás máme.",
|
||||
"privacy.s10_access_label": "Právo na přístup (Art. 15):",
|
||||
"privacy.s10_complaint_body": "Máte právo podat stížnost u vašeho národního úřadu pro ochranu osobních údajů (DPA). V Německu: Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI). Ve Velké Británii: Úřad komisaře pro informace (ICO). Ve Švýcarsku: Federální úřad pro ochranu osobních údajů a informace (FDPIC).",
|
||||
"privacy.s10_complaint_label": "Právo podat stížnost:",
|
||||
"privacy.s10_contact": "Pro uplatnění některého z výše uvedených práv nás kontaktujte na",
|
||||
"privacy.s10_erasure_body": "Můžete požádat o vymazání vašich osobních údajů, pokud neexistuje překážející legitimní důvod, proč je uchovávat.",
|
||||
"privacy.s10_erasure_label": "Právo na vymazání (Art. 17):",
|
||||
"privacy.s10_heading": "10. Vaše práva (EU / EEA / UK / Švýcarsko)",
|
||||
"privacy.s10_object_body": "Můžete kdykoli vznést námitku proti zpracování na základě oprávněných zájmů.",
|
||||
"privacy.s10_object_label": "Právo vznést námitku (Art. 21):",
|
||||
"privacy.s10_p1": "Na základě GDPR (a UK GDPR / Swiss nFADP) máte následující práva:",
|
||||
"privacy.s10_portability_body": "Můžete požádat o vaše údaje ve strukturovaném, běžně používaném, strojově čitelném formátu.",
|
||||
"privacy.s10_portability_label": "Právo na přenositelnost údajů (Art. 20):",
|
||||
"privacy.s10_rectification_body": "Můžete požádat o opravu nepřesných nebo neúplných osobních údajů.",
|
||||
"privacy.s10_rectification_label": "Právo na opravu (Art. 16):",
|
||||
"privacy.s10_response": "Odpovíme do jednoho kalendářního měsíce (přípustné prodloužení o další dva měsíce pro složité žádosti).",
|
||||
"privacy.s10_restriction_body": "Můžete požádat, abychom dočasně pozastavili zpracování vašich údajů za určitých okolností.",
|
||||
"privacy.s10_restriction_label": "Právo na omezení (Art. 18):",
|
||||
"privacy.s10_withdraw_body": "Pokud je zpracování založeno na souhlasu, můžete tento souhlas kdykoli odvolat, aniž byste tím ovlivnili zákonnost předchozího zpracování.",
|
||||
"privacy.s10_withdraw_label": "Právo odvolat souhlas:",
|
||||
"privacy.s11_categories_body": "Identifikátory (jméno, e-mail), autentizační tokeny účtů a metadata dokumentů, které se rozhodnete nahrát.",
|
||||
"privacy.s11_categories_label": "Kategorie shromážděných osobních údajů:",
|
||||
"privacy.s11_contact": "Pro podání ověřitelné žádosti o informace nás kontaktujte na",
|
||||
"privacy.s11_correct_body": "Můžete požádat o opravu nepřesných osobních informací.",
|
||||
"privacy.s11_correct_label": "Právo na opravu:",
|
||||
"privacy.s11_delete_body": "Můžete požádat o vymazání osobních údajů, které jsme shromáždili, s výjimkou některých případů.",
|
||||
"privacy.s11_delete_label": "Právo na smazání:",
|
||||
"privacy.s11_heading": "11. Další práva – Spojené státy (CCPA / CPRA)",
|
||||
"privacy.s11_know_body": "Můžete požádat o zpřístupnění kategorií a konkrétních částí osobních údajů, které jsme o vás shromáždili.",
|
||||
"privacy.s11_know_label": "Právo na informace:",
|
||||
"privacy.s11_limit_body": "Nepoužíváme citlivé osobní informace nad rámec toho, co je nezbytné k poskytování služby.",
|
||||
"privacy.s11_limit_label": "Právo omezit použití citlivých osobních údajů:",
|
||||
"privacy.s11_nondiscrim_body": "Nebudeme vůči vám discriminovat za uplatnění kteréhokoli z těchto práv.",
|
||||
"privacy.s11_nondiscrim_label": "Nediskriminace:",
|
||||
"privacy.s11_optout_body": "Neprodáváme ani nesdílíme osobní údaje, jak je definováno CCPA/CPRA. Není vyžadován žádný mechanismus pro opt-out; nicméně, můžete nás kontaktovat, abyste to potvrdili.",
|
||||
"privacy.s11_optout_label": "Právo na odhlášení z prodeje / sdílení:",
|
||||
"privacy.s11_p1": "Pokud jste obyvatel Kalifornie nebo jiného státu USA s příslušnými zákony o ochraně soukromí (včetně Virginia VCDPA, Colorado CPA, Connecticut CTDPA, Utah UCPA), následující další sdělení se vztahují:",
|
||||
"privacy.s11_purpose_body": "Poskytování, zlepšování a zabezpečení služby DocuElevate. Neprodáváme ani nesdílíme osobní údaje pro reklamu založenou na chování napříč kontexty.",
|
||||
"privacy.s11_purpose_label": "Účel sběru:",
|
||||
"privacy.s11_response": "Na vaši žádost odpovíme do 45 dnů (lze prodloužit o dalších 45 dnů, pokud je to rozumně nezbytné).",
|
||||
"privacy.s12_access_body": "Můžete požádat o přístup k vašim osobním údajům a informacím o tom, jak byly použity nebo zveřejněny.",
|
||||
"privacy.s12_access_label": "Právo na přístup:",
|
||||
"privacy.s12_contact": "Přímé stížnosti ohledně ochrany soukromí směřujte k našemu pracovníkovi pro ochranu soukromí na",
|
||||
"privacy.s12_contact_post": ", nebo na Kancelář komisaře pro ochranu soukromí Kanady.",
|
||||
"privacy.s12_correction_body": "Můžete zpochybnit přesnost nebo úplnost svých osobních údajů a požádat o opravu.",
|
||||
"privacy.s12_correction_label": "Právo na opravu:",
|
||||
"privacy.s12_heading": "12. Další práva – Kanada (PIPEDA / Québecská zákon 25)",
|
||||
"privacy.s12_li1": "Osobní údaje shromažďujeme, používáme a zveřejňujeme pouze se vaším vědomím a souhlasem, nebo jak to umožňuje zákon.",
|
||||
"privacy.s12_p1": "Pokud se nacházíte v Kanadě, následující platí podle Zákona o ochraně osobních údajů a elektronických dokumentů (PIPEDA) a příslušných provinčních zákonů (včetně Québecského zákona 25 / Bill 64):",
|
||||
"privacy.s12_quebec_body": "Podle zákona 25 máte další práva, včetně práva na přenositelnost údajů (účinné od září 2023) a práva na deindexaci, kdy jsou osobní údaje šířeny online.",
|
||||
"privacy.s12_quebec_label": "Obyvatelé Québecu:",
|
||||
"privacy.s12_withdraw_body": "S ohledem na právní nebo smluvní omezení můžete odejmout souhlas se shromažďováním, použitím nebo zveřejněním svých osobních údajů s rozumným předstihem.",
|
||||
"privacy.s12_withdraw_label": "Právo na odvolání souhlasu:",
|
||||
"privacy.s13_brazil_body": "Pokud se nacházíte v Brazílii, máte následující práva podle LGPD:",
|
||||
"privacy.s13_brazil_label": "Brazílie (LGPD – Zákon o ochraně osobních údajů, zákon 13.709/2018):",
|
||||
"privacy.s13_contact": "Kontaktní informace:",
|
||||
"privacy.s13_heading": "13. Další práva – Latinská Amerika (LGPD a další)",
|
||||
"privacy.s13_li1": "Potvrzení o existenci zpracování a přístup k vašim údajům.",
|
||||
"privacy.s13_li2": "Oprava neúplných, nepřesných nebo zastaralých údajů.",
|
||||
"privacy.s13_li3": "Anonymizace, blokování nebo odstranění nepotřebných nebo nadbytečných údajů.",
|
||||
"privacy.s13_li4": "Přenositelnost vašich údajů k jinému poskytovateli služby nebo produktu.",
|
||||
"privacy.s13_li5": "Odstranění osobních údajů zpracovaných se souhlasem.",
|
||||
"privacy.s13_li6": "Informace o subjektech, se kterými byly vaše údaje sdíleny.",
|
||||
"privacy.s13_li7": "Informace o možnosti nesouhlasit a důsledcích odmítnutí.",
|
||||
"privacy.s13_li8": "Odvolání souhlasu.",
|
||||
"privacy.s13_other_body": "Také uznáváme příslušné zákony o ochraně soukromí v Argentině (PDPA), Mexiku (LFPDPPP), Chile, Kolumbii (Ley 1581) a dalších. Uživatelé v těchto jurisdikcích mohou uplatnit ekvivalentní práva, jak je uvedeno v jejich národním právu, kontaktováním nás.",
|
||||
"privacy.s13_other_label": "Jiné země Latinské Ameriky:",
|
||||
"privacy.s14_apj_body": "Uznáváme práva na ochranu údajů, která mají obyvatelé těchto jurisdikcí podle jejich právních předpisů. Kontaktujte nás pro uplatnění svých práv.",
|
||||
"privacy.s14_apj_label": "Další trhy APJ (Singapore PDPA, Nový Zéland Privacy Act, India DPDP Act):",
|
||||
"privacy.s14_australia_body": "Obyvatelé Austrálie mohou požádat o přístup k a opravu svých osobních údajů. Na žádosti o přístup odpovíme do 30 dnů. Stížnosti mohou být podány Kanceláři australského komisaře pro informace (OAIC).",
|
||||
"privacy.s14_australia_label": "Austrálie (Zákon o ochraně soukromí 1988 a australské zásady ochrany soukromí):",
|
||||
"privacy.s14_contact": "Kontakt:",
|
||||
"privacy.s14_heading": "14. Další práva – Asie-Pacifik a Japonsko",
|
||||
"privacy.s14_japan_body": "Japonští obyvatelé mohou požádat o zpřístupnění, opravu, doplnění nebo vymazání, pozastavení použití, vymazání nebo pozastavení poskytování svých osobních údajů, které máme v držení. Poskytování třetím stranám vyžaduje váš předchozí souhlas, pokud není povoleno zákonem.",
|
||||
"privacy.s14_japan_label": "Japonsko (APPI – Zákon o ochraně osobních údajů):",
|
||||
"privacy.s14_korea_body": "Korejští obyvatelé mohou požádat o přístup, opravu, vymazání a pozastavení zpracování. Osobní údaje korejských obyvatel zpracováváme v souladu se zákonem PIPA.",
|
||||
"privacy.s14_korea_label": "Jižní Korea (PIPA – Zákon o ochraně osobních údajů):",
|
||||
"privacy.s15_contact": "Kontakt:",
|
||||
"privacy.s15_heading": "15. Další práva – Ukrajina",
|
||||
"privacy.s15_p1": "Uživatelé nacházející se na Ukrajině jsou chráněni zákonem Ukrajiny \"O ochraně osobních údajů\" (č. 2297-VI). Vaše práva zahrnují přístup k, opravu, blokaci a vymazání vašich osobních údajů, stejně jako právo vznést námitku proti zpracování.",
|
||||
"privacy.s16_cookies_link": "Podmínky používání souborů cookie",
|
||||
"privacy.s16_heading": "16. Aktualizace této oznámení o ochraně osobních údajů",
|
||||
"privacy.s16_license_link": "Informace o licenci",
|
||||
"privacy.s16_p1": "Toto oznámení můžeme občas aktualizovat, abychom odráželi změny v našich praktikách nebo platných zákonech. Datum \"Poslední aktualizace\" v horní části této stránky označuje, kdy bylo oznámení naposledy revidováno. Kde jsou změny podstatné, budeme uživatele informovat prostřednictvím oznámení v aplikaci nebo e-mailem, pokud to bude vhodné.",
|
||||
"privacy.s16_p2_pre": "Pokud máte jakékoli dotazy nebo obavy ohledně této oznámení o ochraně osobních údajů nebo vašich osobních údajů, prosím kontaktujte nás na",
|
||||
"privacy.s16_p3_pre": "Prosím také zkontrolujte naše",
|
||||
"privacy.s16_terms_link": "Podmínky služby",
|
||||
"privacy.s1_address": "Alter Steinweg 3, 20459 Hamburg, Německo",
|
||||
"privacy.s1_company": "Christian Louis IT Beratung",
|
||||
"privacy.s1_contact_label": "Kontaktní e-mail:",
|
||||
"privacy.s1_heading": "1. Správce údajů",
|
||||
"privacy.s1_p1": "Správce, který je odpovědný za zpracování vašich osobních údajů podle Obecného nařízení o ochraně osobních údajů (GDPR) a ekvivalentních zákonů o ochraně soukromí po celém světě, je:",
|
||||
"privacy.s1_p3": "Pro všechny žádosti týkající se ochrany soukromí (přístup, vymazání, oprava, odhlášení nebo stížnosti) prosím kontaktujte nás na výše uvedené e-mailové adrese. Odpovíme do 30 dnů (nebo v období stanoveném platným právem).",
|
||||
"privacy.s2_heading": "2. Rozsah tohoto oznámení o ochraně osobních údajů",
|
||||
"privacy.s2_p1_pre": "Toto oznámení se vztahuje na webovou aplikaci DocuElevate, hostovanou na",
|
||||
"privacy.s2_p2": "Pokrývá všechny uživatele na celém světě, včetně těch v Evropské unii (EU), Evropském hospodářském prostoru (EEA), Německu, Spojeném království (UK), Švýcarsku, Ukrajině, Spojených státech (US), Kanadě, Latinské Americe (Latam), Asii-Pacifik a Japonsku. Specifická oznámení pro jednotlivé trhy jsou uvedena v dedikovaných sekcích níže.",
|
||||
"privacy.s3_audit_body": "Udržujeme omezené protokoly auditu (typ akce, časové razítko, identifikátor uživatele), abychom zajistili integritu a bezpečnost služby. Tyto protokoly neobsahují obsah dokumentů.",
|
||||
"privacy.s3_audit_label": "Protokoly auditu:",
|
||||
"privacy.s3_auth_body": "Používáme OAuth 2.0 (Google, Dropbox, Microsoft/OneDrive) a volitelnou místní autentifikaci. Prostřednictvím OAuth můžeme obdržet vaše jméno, e-mailovou adresu a profilový obrázek.",
|
||||
"privacy.s3_auth_label": "Ověření uživatelů:",
|
||||
"privacy.s3_doc_body": "Dokumenty, které nahrajete, jsou zpracovávány pro OCR (optické rozpoznávání znaků), extrakci metadat a ukládání k vašemu zvolenému poskytovateli cloudu. Obsah dokumentu je zpracováván pouze pro účel, který iniciujete, a není uchováván déle, než je operativně nezbytné.",
|
||||
"privacy.s3_doc_label": "Zpracování dokumentů:",
|
||||
"privacy.s3_heading": "3. Sbírání údajů a účely",
|
||||
"privacy.s3_legal_body": "Naše primární právní základy pro zpracování jsou:",
|
||||
"privacy.s3_legal_label": "Právní základ (GDPR čl. 6):",
|
||||
"privacy.s3_li1": "(1)(b) Plnění smlouvy: poskytování služby DocuElevate, kterou jste požádali.",
|
||||
"privacy.s3_li2": "(1)(c) Právní povinnost: splnění platných zákonů a předpisů.",
|
||||
"privacy.s3_li3": "(1)(f) Oprávněné zájmy: zajištění bezpečnosti služby a prevence podvodu.",
|
||||
"privacy.s4_heading": "4. Minimalizace údajů a omezení účelu",
|
||||
"privacy.s4_li1": "Shromažďujeme pouze minimální osobní údaje potřebné k provozování služby.",
|
||||
"privacy.s4_li2": "Obsah dokumentu je zpracováván výhradně pro účel, který iniciujete (OCR, ukládání, extrakce metadat). Vaše dokumenty nepoužíváme k trénování AI modelů nebo k žádnému sekundárnímu účelu.",
|
||||
"privacy.s4_li3": "Žádná reklama, behaviorální sledování ani profilování se neprovádí.",
|
||||
"privacy.s4_li4": "Žádné sledovací cookies ani analytické skripty nejsou načítány.",
|
||||
"privacy.s4_li5": "Služby AI třetích stran (např. OpenAI, Azure Document Intelligence) se vyvolávají pouze tehdy, když zahájíte zpracování dokumentu, a data jsou přenášena na základě dohod o zpracování dat.",
|
||||
"privacy.s4_p1": "DocuElevate je navrženo s minimalizací dat jako s jádrovým principem (GDPR čl. 5(1)(c)):",
|
||||
"privacy.s5_cookie_link": "Zásady používání cookies",
|
||||
"privacy.s5_heading": "5. Použití cookies a podobných technologií",
|
||||
"privacy.s5_p1_post": "pro udržení vaší autentizované relace. Tyto cookies jsou nezbytné pro funkčnost služby a jsou vyjmuty z požadavků na předchozí souhlas podle směrnice EU ePrivacy (čl. 5(3)) a odpovídajících národních zákonů.",
|
||||
"privacy.s5_p1_pre": "DocuElevate používá",
|
||||
"privacy.s5_p1_strong": "pouze striktně nezbytné relace cookies",
|
||||
"privacy.s5_p2_body": "analytické cookies, reklamační cookies, sledovací pixely nebo jakékoli cookies třetích stran, které by vyžadovaly váš souhlas.",
|
||||
"privacy.s5_p2_label": "Používáme:",
|
||||
"privacy.s5_p3_pre": "Pro úplné informace o cookies, které nastavujeme, jejich názvech, trvání a účelu, prosím navštivte náš",
|
||||
"privacy.s6_ai_body": "Když zahájíte OCR nebo extrakci metadat na základě AI, data dokumentu se přenášejí na službu AI, kterou jste nastavili vy nebo váš správce. Tento přenos je řízen dohodou o zpracování dat s příslušným poskytovatelem.",
|
||||
"privacy.s6_ai_label": "Služby zpracování AI (OpenAI, Azure Document Intelligence, další):",
|
||||
"privacy.s6_heading": "6. Služby třetích stran",
|
||||
"privacy.s6_no_sale_body": "Vaše osobní údaje neprodáváme, nepronajímáme ani nesdílíme s třetími stranami za účelem reklamy, marketingu nebo jakýmkoli jiným účelem nesouvisejícím s poskytováním služby.",
|
||||
"privacy.s6_no_sale_label": "Žádný prodej ani sdílení pro reklamu:",
|
||||
"privacy.s6_oauth_body": "Když se rozhodnete autentizovat prostřednictvím OAuth, příslušný poskytovatel zpracovává vaše údaje a může s námi sdílet omezené informace o vašem profilu. Tito poskytovatelé udržují své vlastní zásady ochrany soukromí.",
|
||||
"privacy.s6_oauth_label": "Poskytovatelé OAuth (Google, Dropbox, Microsoft):",
|
||||
"privacy.s6_storage_body": "Dokumenty jsou uloženy u poskytovatele cloudu, kterého jste nastavili. Vaše nastavené údaje jsou uloženy šifrované v databázi aplikace a používají se výhradně k provádění operací uložených, které požadujete.",
|
||||
"privacy.s6_storage_label": "Poskytovatelé cloudového úložiště (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):",
|
||||
"privacy.s7_adequacy_body": "kde Evropská komise uznala ekvivalentní úroveň ochrany (např. Spojené království, Švýcarsko, Kanada (komerční organizace), Japonsko, Jižní Korea).",
|
||||
"privacy.s7_adequacy_label": "Rozhodnutí o adekvátnosti",
|
||||
"privacy.s7_contact": "Můžete požádat o kopii příslušných záruk kontaktem na nás na",
|
||||
"privacy.s7_heading": "7. Mezinárodní přenosy údajů",
|
||||
"privacy.s7_idta_body": "pro přenosy z UK po brexitu.",
|
||||
"privacy.s7_idta_label": "Dohody o mezinárodním přenosu údajů (IDTA) z UK",
|
||||
"privacy.s7_p1": "DocuElevate je ve výchozím nastavení hostováno v Evropské unii / EHP. Když jsou osobní údaje přenášeny mimo EHP (například na poskytovatele AI sídlící v USA, jako je OpenAI), spoléháme na vhodné záruky, které zahrnují:",
|
||||
"privacy.s7_scc_body": "přijaté Evropskou komisí (2021/914/EU) pro přenosy k zpracovatelům a správcům ve třetích zemích.",
|
||||
"privacy.s7_scc_label": "Standardní smluvní doložky (SCC)",
|
||||
"privacy.s8_audit_body": "Udržovány maximálně po dobu 90 dnů pro účely zabezpečení a shody.",
|
||||
"privacy.s8_audit_label": "Auditní protokoly:",
|
||||
"privacy.s8_contact": "Pro požadavek na odstranění vašeho účtu a všech souvisejících osobních údajů nás prosím kontaktujte na",
|
||||
"privacy.s8_files_body": "Udržovány po dobu vašeho používání služby. Můžete jednotlivé soubory kdykoli smazat prostřednictvím aplikace.",
|
||||
"privacy.s8_files_label": "Záznamy o souborech a metadata:",
|
||||
"privacy.s8_heading": "8. Uchovávání údajů",
|
||||
"privacy.s8_oauth_body": "Uloženy v šifrované podobě a zrušitelné kdykoli prostřednictvím vašeho poskytovatele OAuth.",
|
||||
"privacy.s8_oauth_label": "OAuth tokeny:",
|
||||
"privacy.s8_p1": "Osobní údaje uchováváme pouze po dobu nezbytně nutnou k poskytování služby DocuElevate nebo k dodržování právních závazků:",
|
||||
"privacy.s8_session_body": "Smazáno, když se odhlásíte nebo po uplynutí doby relace.",
|
||||
"privacy.s8_session_label": "Údaje o relaci:",
|
||||
"privacy.s9_heading": "9. Bezpečnost údajů",
|
||||
"privacy.s9_li1": "Šifrování přihlašovacích údajů a citlivé konfigurace v klidu.",
|
||||
"privacy.s9_li2": "Transport Layer Security (TLS/HTTPS) pro všechny komunikace.",
|
||||
"privacy.s9_li3": "Kontroly přístupu založené na rolích, které omezují přístup k osobním údajům.",
|
||||
"privacy.s9_li4": "Pravidelné bezpečnostní audity a skenování zranitelností závislostí.",
|
||||
"privacy.s9_li5": "Ochrana proti CSRF na všech požadavcích měnících stav.",
|
||||
"privacy.s9_p1": "Implementujeme vhodná technická a organizační opatření (TOM) k ochraně vašich osobních údajů, včetně:",
|
||||
"privacy.toc_1": "Správce údajů",
|
||||
"privacy.toc_10": "Vaše práva (EU / EEA / UK / Švýcarsko)",
|
||||
"privacy.toc_11": "Další práva – Spojené státy (CCPA/CPRA)",
|
||||
"privacy.toc_12": "Další práva – Kanada (PIPEDA / Zákon 25)",
|
||||
"privacy.toc_13": "Další práva – Latinská Amerika (LGPD a další)",
|
||||
"privacy.toc_14": "Další práva – Asie a Tichomoří & Japonsko",
|
||||
"privacy.toc_15": "Další práva – Ukrajina",
|
||||
"privacy.toc_16": "Aktualizace tohoto Oznámení o ochraně soukromí",
|
||||
"privacy.toc_2": "Rozsah tohoto Oznámení o ochraně soukromí",
|
||||
"privacy.toc_3": "Sbírání údajů a účely",
|
||||
"privacy.toc_4": "Minimalizace údajů a omezení účelu",
|
||||
"privacy.toc_5": "Použití souborů cookie a podobných technologií",
|
||||
"privacy.toc_6": "Služby třetích stran",
|
||||
"privacy.toc_7": "Mezinárodní přenosy údajů",
|
||||
"privacy.toc_8": "Uchovávání údajů",
|
||||
"privacy.toc_9": "Bezpečnost údajů",
|
||||
"privacy.toc_heading": "Obsah",
|
||||
"profile.avatar_alt": "Vaše profilová fotografie",
|
||||
"profile.avatar_heading": "Profilový obrázek",
|
||||
"profile.avatar_remove": "Odstranit vlastní avatar",
|
||||
@@ -838,6 +1377,9 @@
|
||||
"profile.contact_email_label": "E-mail pro kontakt / upozornění",
|
||||
"profile.contact_email_placeholder": "you@example.com",
|
||||
"profile.current_password": "Současné heslo",
|
||||
"profile.default_document_language_auto": "Použít systémové výchozí",
|
||||
"profile.default_document_language_hint": "Dokumenty v jiných jazycích jsou automaticky překládány do tohoto jazyka. Nechte prázdné pro použití systémového výchozího (angličtina).",
|
||||
"profile.default_document_language_label": "Výchozí jazyk dokumentu",
|
||||
"profile.dismiss": "Zavřít",
|
||||
"profile.display_name_hint": "Nechte prázdné pro použití uživatelského jména nebo e-mailu účtu.",
|
||||
"profile.display_name_label": "Zobrazované jméno",
|
||||
@@ -1040,14 +1582,66 @@
|
||||
"similarity.subtitle": "Páry dokumentů s vysokou sémantickou podobností, seřazené podle skóre.",
|
||||
"similarity.trigger_aria": "Spuštění výpočtu vkládání pro všechny soubory, které postrádají vložení",
|
||||
"similarity.trigger_now": "spustit nyní",
|
||||
"status.active": "Aktivní",
|
||||
"status.ai_empty_response": "(prázdné)",
|
||||
"status.ai_extraction_desc": "Vložte čistý text dokumentu níže a spusťte ho prostřednictvím nakonfigurovaného poskytovatele AI, abyste zkontrolovali surovou odpověď, extrahovaný JSON a tagy.",
|
||||
"status.ai_extraction_failed": "Extrakce AI se nezdařila",
|
||||
"status.ai_extraction_label": "Text dokumentu",
|
||||
"status.ai_extraction_placeholder": "Vložte čistý text vašeho dokumentu sem\u00103a...",
|
||||
"status.ai_extraction_title": "Test extrakce AI",
|
||||
"status.app_version": "Verze aplikace",
|
||||
"status.as_account": "jako",
|
||||
"status.auth_required": "Vyžaduje se autentizace",
|
||||
"status.build_date": "Datum sestavení",
|
||||
"status.config_settings": "Nastavení konfigurace",
|
||||
"status.config_settings_desc": "Pro podrobnější nastavení konfigurace a proměnné prostředí zkontrolujte stránku nastavení.",
|
||||
"status.configure_now": "Nakonfigurujte nyní",
|
||||
"status.configured": "Nakonfigurováno",
|
||||
"status.connection_error": "Chyba připojení",
|
||||
"status.connection_test_failed": "Test připojení selhal",
|
||||
"status.connection_test_successful": "Test připojení úspěšný",
|
||||
"status.container_id": "ID kontejneru",
|
||||
"status.container_started": "Kontejner spuštěn",
|
||||
"status.dashboard_subtitle": "Tento dashboard zobrazuje stav všech nakonfigurovaných integrací a cílů.",
|
||||
"status.debug_mode": "Režim ladění",
|
||||
"status.error_running_extraction": "Chyba při běhu extrakce: ",
|
||||
"status.error_testing_connection": "Chyba při testování připojení: ",
|
||||
"status.error_testing_notifications": "Chyba při testování oznámení: ",
|
||||
"status.extracted_tags": "Extrahované tagy",
|
||||
"status.git_commit": "Git commit",
|
||||
"status.inactive": "Neaktivní",
|
||||
"status.json_parse_issue": "Problém s analýzou JSON: ",
|
||||
"status.last_check": "Poslední kontrola",
|
||||
"status.manage": "Spravovat",
|
||||
"status.modal_default_message": "Operace byla úspěšně dokončena.",
|
||||
"status.modal_default_title": "Úspěch",
|
||||
"status.no_details": "Žádné dostupné detaily",
|
||||
"status.not_configured": "Nekonfigurováno",
|
||||
"status.notification_config_missing": "Chybí konfigurace oznámení",
|
||||
"status.open": "Otevřít",
|
||||
"status.page_title": "Stav systému",
|
||||
"status.parsed_json_label": "Anotovaný JSON",
|
||||
"status.provider_config_details": "Podrobnosti konfigurace {name}",
|
||||
"status.provider_details": "Podrobnosti poskytovatele",
|
||||
"status.raw_llm_response": "Surová odpověď LLM",
|
||||
"status.run_extraction": "Spustit extrakci",
|
||||
"status.running": "Probíhá\u001620",
|
||||
"status.sending": "Odesílání...",
|
||||
"status.setting_label": "Nastavení",
|
||||
"status.test_connection": "Otestovat připojení",
|
||||
"status.test_extraction": "Otestovat extrakci",
|
||||
"status.test_failed": "Test selhal",
|
||||
"status.test_notification_failed": "Test oznámení selhal",
|
||||
"status.test_notification_sent": "Testovací oznámení odesláno",
|
||||
"status.test_notifications": "Testovat oznámení",
|
||||
"status.test_provider": "Otestovat {name}",
|
||||
"status.test_successful": "Test úspěšný",
|
||||
"status.testing": "Testování...",
|
||||
"status.token_expired": "Váš token vypršel nebo je neplatný. Prosím, znovu nakonfigurujte toto připojení.",
|
||||
"status.token_valid_for": "Token platný pro:",
|
||||
"status.value_label": "Hodnota",
|
||||
"status.view_config": "Zobrazit podrobnou konfiguraci",
|
||||
"status.view_details": "Zobrazit detaily",
|
||||
"subscription.available_plans_heading": "Dostupné plány",
|
||||
"subscription.back_to_dashboard": "Zpět na řídicí panel",
|
||||
"subscription.cancel_pending": "Zrušit změnu",
|
||||
@@ -1083,6 +1677,51 @@
|
||||
"subscription.upgrade_info": "Upgrady vstupují v platnost okamžitě. Snížení plánu je naplánováno na konec vašeho aktuálního fakturačního období.",
|
||||
"subscription.upgrade_to_prefix": "Upgradovat na",
|
||||
"subscription.usage_heading": "Využití",
|
||||
"terms.cookie_link": "Zásady používání cookies",
|
||||
"terms.heading": "Podmínky služby",
|
||||
"terms.last_updated": "Naposledy aktualizováno:",
|
||||
"terms.license_link": "Informace o licenci",
|
||||
"terms.page_title": "Podmínky služby - DocuElevate",
|
||||
"terms.privacy_link": "Zásady ochrany osobních údajů",
|
||||
"terms.s1_heading": "1. Přijetí podmínek",
|
||||
"terms.s1_p1": "Přístupem nebo používáním služby DocuElevate souhlasíte s tím, že budete vázáni těmito Podmínkami služby. Pokud s těmito podmínkami nesouhlasíte, prosím, tuto službu neužívejte.",
|
||||
"terms.s2_heading": "2. Popis služby",
|
||||
"terms.s2_p1": "DocuElevate poskytuje služby zpracování dokumentů, OCR, extrakce metadat a ukládání. Vyhrazujeme si právo kdykoliv upravit nebo ukončit jakýkoliv aspekt služby.",
|
||||
"terms.s3_heading": "3. Odpovědnosti uživatele",
|
||||
"terms.s3_li1": "Veškerý obsah, který nahrajete do DocuElevate",
|
||||
"terms.s3_li2": "Zajištění, že máte řádná práva nahrávat a zpracovávat dokumenty",
|
||||
"terms.s3_li3": "Udržování důvěrnosti vašich přihlašovacích údajů",
|
||||
"terms.s3_li4": "Jakákoliv činnost, která se provádí pod vaším účtem",
|
||||
"terms.s3_p1": "Nesete odpovědnost za:",
|
||||
"terms.s3_p2_and": "a",
|
||||
"terms.s3_p2_post": ".",
|
||||
"terms.s3_p2_pre": "Používáním našich služeb také souhlasíte s našimi",
|
||||
"terms.s4_heading": "4. Práva duševního vlastnictví",
|
||||
"terms.s4_p1": "DocuElevate respektuje práva duševního vlastnictví. Uživatelé nesmí nahrávat obsah, který porušuje práva duševního vlastnictví jiných osob.",
|
||||
"terms.s5_heading": "5. Omezení odpovědnosti",
|
||||
"terms.s5_p1": "DocuElevate poskytuje službu \"tak jak je\" bez jakýchkoliv záruk. Nebudeme odpovědní za žádné přímé, nepřímé, náhodné, zvláštní, následné nebo sankční škody vyplývající z vašeho používání nebo neschopnosti používat službu.",
|
||||
"terms.s6_heading": "6. Rozhodné právo",
|
||||
"terms.s6_p1": "Tyto podmínky se řídí právními předpisy Německa, bez ohledu na jeho kolizní ustanovení.",
|
||||
"terms.s6_p2_post": ".",
|
||||
"terms.s6_p2_pre": "Pokud máte nějaké dotazy k těmto podmínkám, kontaktujte nás prosím na",
|
||||
"terms.s6_p3_mid": ". Pro informace o licenci se prosím odviďte na naše",
|
||||
"terms.s6_p3_post": ".",
|
||||
"terms.s6_p3_pre": "Pro informace o tom, jak používáme cookies, viz naše",
|
||||
"translation.copied": "Zkopírováno!",
|
||||
"translation.copy": "Kopírovat",
|
||||
"translation.default_language_version": "Verze výchozího jazyka",
|
||||
"translation.detected_language": "Zjištěný jazyk",
|
||||
"translation.hide_text": "Skrýt text",
|
||||
"translation.load_translation": "Načíst překlad",
|
||||
"translation.no_translation": "Žádný překlad zatím není k dispozici \u00199 — může se ještě zpracovávat",
|
||||
"translation.select_language": "Vyberte jazyk\u000206",
|
||||
"translation.select_target": "Vyberte prosím cílový jazyk.",
|
||||
"translation.show_text": "Zobrazit text",
|
||||
"translation.translate_btn": "Přeložit",
|
||||
"translation.translate_to": "Přeložit do jiného jazyka",
|
||||
"translation.translated_to": "Přeloženo do",
|
||||
"translation.translating": "Překládám\u000206",
|
||||
"translation.translation_failed": "Překlad selhal",
|
||||
"upload.browse_button": "Procházet soubory",
|
||||
"upload.button_processing": "Zpracovává se...",
|
||||
"upload.camera_button": "Vyfotit / Naskenovat dokument",
|
||||
|
||||
@@ -41,6 +41,280 @@
|
||||
"about.story_heading": "Ein Stori",
|
||||
"about.story_p1": "Cafodd DocuElevate ei chreu gyda un nod yn ei fryd: i symlhau a llunio rheolaeth dogfennau ar gyfer pawb, beth bynnag yw eich graddfa, boed yn fenter fach neu'n gwmni mawr.",
|
||||
"about.story_p2": "Rydym yn defnyddio pŵer darparwyr AI y gellir eu plugo (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey, ac eraill) ar gyfer echdynnu metadata a pharfannau testun, yn integreiddio'n ddi-dor â Dropbox, Nextcloud, a Paperless NGX ar gyfer storio a mynegeio, yn defnyddio Azure Document Intelligence ar gyfer OCR, ac yn defnyddio Gotenberg ar gyfer trosi ffeiliau i PDF.",
|
||||
"admin_files.admin_only_badge": "Dim ond i Admin",
|
||||
"admin_files.aria_breadcrumb": "Llysenw",
|
||||
"admin_files.badge_delta_detected": "Delta wedi'i ganfod",
|
||||
"admin_files.badge_duplicate": "dyblyg",
|
||||
"admin_files.badge_in_db": "yn DB",
|
||||
"admin_files.badge_on_disk": "ar ddisg",
|
||||
"admin_files.breadcrumb_workdir": "gweithdir",
|
||||
"admin_files.btn_download": "Lawrlwytho",
|
||||
"admin_files.col_actions": "Gweithredoedd",
|
||||
"admin_files.col_db": "DB",
|
||||
"admin_files.col_health": "Iechyd",
|
||||
"admin_files.col_id": "ID",
|
||||
"admin_files.col_ingested": "Mewnblwyd",
|
||||
"admin_files.col_local_filename": "enw_ffeil_leol",
|
||||
"admin_files.col_missing_paths": "Llwybrau coll",
|
||||
"admin_files.col_modified": "Newidiwyd",
|
||||
"admin_files.col_name": "Enw",
|
||||
"admin_files.col_original_file_path": "llwybr_ffeil_gwirioneddol",
|
||||
"admin_files.col_original_filename": "Enw Ffeil Gwirioneddol",
|
||||
"admin_files.col_path_relative": "Llwybr (cymharol i waith)",
|
||||
"admin_files.col_processed_file_path": "llwybr_fedliwiedig_ffeil",
|
||||
"admin_files.col_size": "Maint",
|
||||
"admin_files.delta_detected_detail": "Doddwyd {orphan_count} ffeil derelict ar ddisg heb gofrestr DB, a {ghost_count} cofrestr DB gyda ffeiliau coll ar ddisg.",
|
||||
"admin_files.delta_detected_title": "Delta wedi'i ganfod.",
|
||||
"admin_files.empty_database": "Dim cofrestr ffeiliau a ddysgodd yn y cronfa ddata.",
|
||||
"admin_files.empty_directory": "Mae'r cyfeiriadur hwn yn wag.",
|
||||
"admin_files.ghost_records_desc": "(yn DB, ffeiliau coll ar ddisg)",
|
||||
"admin_files.ghost_records_heading": "Cofrestrau ysbryd",
|
||||
"admin_files.heading": "Reolwr Ffeiliau",
|
||||
"admin_files.health_missing": "Coll",
|
||||
"admin_files.health_ok": "OK",
|
||||
"admin_files.legend_file_exists": "Mae'r ffeil yn bodoli ar ddisg",
|
||||
"admin_files.legend_file_missing": "Mae'r ffeil yn absent o ddisg",
|
||||
"admin_files.legend_found_in_db": "Doddwyd yn y DB",
|
||||
"admin_files.legend_not_in_db": "Nid yn y DB (derelict)",
|
||||
"admin_files.legend_path_not_set": "Llwybr heb ei osod",
|
||||
"admin_files.no_delta": "Dim delta wedi'i ganfod — mae system ffeiliau a'r gronfa ddata yn gyson.",
|
||||
"admin_files.no_ghost_records": "Dim cofrestr ysbryd wedi'i chanfod.",
|
||||
"admin_files.no_orphan_files": "Dim ffeiliau derelict wedi'u canfod.",
|
||||
"admin_files.orphan_files_desc": "(ar ddisg, dim cofrestr DB)",
|
||||
"admin_files.orphan_files_heading": "Ffeiliau derelict",
|
||||
"admin_files.page_title": "Reolwr Ffeiliau – Gweinyddu",
|
||||
"admin_files.status_in_db": "Yn y DB",
|
||||
"admin_files.status_orphan": "Derelict",
|
||||
"admin_files.tab_database": "Cofrestriadau Cronfa Ddata",
|
||||
"admin_files.tab_filesystem": "System Ffeiliau",
|
||||
"admin_files.tab_reconcile": "Cydgyfeirio",
|
||||
"admin_plans.aria_delete_plan": "Dileu {name}",
|
||||
"admin_plans.aria_edit_plan": "Golygu {name}",
|
||||
"admin_plans.aria_feature_n": "Nodwedd {n}",
|
||||
"admin_plans.aria_move_down": "Symud {name} i lawr",
|
||||
"admin_plans.aria_move_up": "Symud {name} i fyny",
|
||||
"admin_plans.aria_remove_feature_n": "Dileu nodwedd {n}",
|
||||
"admin_plans.btn_add_feature": "Ychwanegu Nodwedd",
|
||||
"admin_plans.btn_add_plan": "Ychwanegu Cynllun",
|
||||
"admin_plans.btn_cancel": "Diddymu",
|
||||
"admin_plans.btn_create": "Creu Cynllun",
|
||||
"admin_plans.btn_delete": "Dileu",
|
||||
"admin_plans.btn_edit": "Golygu",
|
||||
"admin_plans.btn_restore_defaults": "Ailadrodd y Rheolau",
|
||||
"admin_plans.btn_restore_defaults_title": "Ailadrodd pob un o'r pedair cynllun draddodiadol (dim ond os nad oes cynlluniau yn bodoli eto)",
|
||||
"admin_plans.btn_restoring": "Ailadrodd\u0001...",
|
||||
"admin_plans.btn_save_changes": "Cadw Newidiadau",
|
||||
"admin_plans.btn_save_order": "Cadw Gorchymyn",
|
||||
"admin_plans.btn_saving": "Yn Cadw\u000001...",
|
||||
"admin_plans.btn_stripe_setup": "Gosod Stripe",
|
||||
"admin_plans.btn_stripe_setup_title": "Agor y Wizard Gosod Stripe i ffurfweddu allweddi API a synchrona cynlluniau",
|
||||
"admin_plans.col_actions": "Gweithredoedd",
|
||||
"admin_plans.col_active": "Active",
|
||||
"admin_plans.col_monthly": "Misol",
|
||||
"admin_plans.col_monthly_limit": "Terfyn Misol",
|
||||
"admin_plans.col_order": "Gorchymyn",
|
||||
"admin_plans.col_overage_pct": "Gormod %",
|
||||
"admin_plans.col_plan": "Cynllun",
|
||||
"admin_plans.col_yearly": "Flynyddol",
|
||||
"admin_plans.coming_soon": "Yn dod yn fuan",
|
||||
"admin_plans.featured_badge": "Arbennig",
|
||||
"admin_plans.field_active": "Active",
|
||||
"admin_plans.field_allow_overage": "Caniatáu Cyfrif Orog",
|
||||
"admin_plans.field_api_access": "Dewisiadau API",
|
||||
"admin_plans.field_badge_text": "Testun Badge",
|
||||
"admin_plans.field_buffer": "Byffer:",
|
||||
"admin_plans.field_cta_text": "Testun Botwm CTA",
|
||||
"admin_plans.field_docs_month": "Dogfennau / Mis",
|
||||
"admin_plans.field_featured": "Arbennig / Tai",
|
||||
"admin_plans.field_lifetime_docs": "Dogfennau Am Oes",
|
||||
"admin_plans.field_mailboxes": "Blwch Post Electronig",
|
||||
"admin_plans.field_max_file_size": "Maint Ffeil Mwyaf (MB)",
|
||||
"admin_plans.field_name": "Enw",
|
||||
"admin_plans.field_ocr_pages": "Pages OCR / Mis",
|
||||
"admin_plans.field_overage_doc_price": "Pris gormodedd / dogfen ($)",
|
||||
"admin_plans.field_overage_ocr_price": "Pris gormodedd / tudalen OCR ($)",
|
||||
"admin_plans.field_plan_id": "ID Cynllun",
|
||||
"admin_plans.field_price_monthly": "Pris Misol ($)",
|
||||
"admin_plans.field_price_yearly": "Pris Blynyddol ($)",
|
||||
"admin_plans.field_sort_order": "Gorchymyn Trefnu",
|
||||
"admin_plans.field_storage_dests": "Lleoliadau Storio",
|
||||
"admin_plans.field_stripe_monthly": "ID Pris Stripe (misol)",
|
||||
"admin_plans.field_stripe_yearly": "ID Pris Stripe (blynyddol)",
|
||||
"admin_plans.field_tagline": "Tagline",
|
||||
"admin_plans.field_trial_days": "Diwrnodau Prawf",
|
||||
"admin_plans.free_label": "Am Ddim",
|
||||
"admin_plans.heading": "Dylunydd Cynllun",
|
||||
"admin_plans.hint_features": "Mae'r pwyntiau pellaid hyn yn ymddangos ar gard trwsio'r dudalen prisio ar gyfer y cynllun hwn.",
|
||||
"admin_plans.hint_plan_id": "Slug llai, ni ellir ei newid ar ôl iddo gael ei greu.",
|
||||
"admin_plans.hint_zero_unlimited": "Rhowch 0 ar gyfer anhygoel.",
|
||||
"admin_plans.js_delete_confirm": "Dileu'r cynllun \"{id}\"? Ni ellir adfer hyn.",
|
||||
"admin_plans.js_delete_failed": "Dileu wedi methu",
|
||||
"admin_plans.js_failed_load": "Methwyd â llwytho cynlluniau",
|
||||
"admin_plans.js_order_saved": "Gorchymyn wedi ei gadw!",
|
||||
"admin_plans.js_plan_created": "Cynllun wedi ei greu!",
|
||||
"admin_plans.js_plan_deleted": "Cynllun \"{id}\" wedi ei ddileu.",
|
||||
"admin_plans.js_plan_updated": "Cynllun wedi ei ddiweddaru!",
|
||||
"admin_plans.js_reorder_failed": "Ail drefnu wedi methu",
|
||||
"admin_plans.js_save_failed": "Cadw wedi methu",
|
||||
"admin_plans.js_seed_confirm": "Seedio'r pedair cynllun sylfaenol? Mae hyn yn weithred ddi-gwaith os yw cynlluniau eisoes yn bodoli.",
|
||||
"admin_plans.js_seed_failed": "Seedio wedi methu",
|
||||
"admin_plans.js_yearly_enter": "Nodwch bris blynyddol i ddangos arbedion",
|
||||
"admin_plans.js_yearly_save": "Cadwch {pct}% yn erbyn misol",
|
||||
"admin_plans.loading": "Llwytho cynlluniau\u00161\u00162",
|
||||
"admin_plans.modal_close_aria": "Cau modal",
|
||||
"admin_plans.modal_create_title": "Ychwanegu Cynllun",
|
||||
"admin_plans.modal_edit_title_prefix": "Golygu Cynllun: ",
|
||||
"admin_plans.no_plans_intro": "Dim cynlluniau hyd yn hyn. Cliciwch",
|
||||
"admin_plans.no_plans_suffix": "i seedio'r pedair cynllun hymgorfforedig.",
|
||||
"admin_plans.overage_0pct": "0% (fanwl)",
|
||||
"admin_plans.overage_100pct": "100%",
|
||||
"admin_plans.overage_50pct": "50%",
|
||||
"admin_plans.overage_announce": "\u00122 hysbysu",
|
||||
"admin_plans.overage_buffer_body_prefix": "Mae'r gweddill gormod yn",
|
||||
"admin_plans.overage_buffer_body_suffix": "Rydyn ni'n hysbysu X dogfenni/mis ond dim ond yn gorfodi ar",
|
||||
"admin_plans.overage_buffer_formula": "X \u0000d7 (1 + buffer%)",
|
||||
"admin_plans.overage_buffer_invisible": "yn anweledig i ddefnyddwyr",
|
||||
"admin_plans.overage_buffer_tail": "dogfennau. Er enghraifft, mae cynllun o 150 dogfen/mis gyda 20% o buffer yn gorfodi ar 180 dogfennau. Mae hyn yn atal stopiau caled ar y ffin benodol a hysbyswyd, gan roi glaniad meddal i ddefnyddwyr.",
|
||||
"admin_plans.overage_buffer_title": "Ynglŷn â'r Gwaddol Gormodol",
|
||||
"admin_plans.overage_docs": "dogfennau,",
|
||||
"admin_plans.overage_docs_end": "dogfennau",
|
||||
"admin_plans.overage_enforce_at": "gorfodi ar",
|
||||
"admin_plans.page_title": "Dylunydd Cynllun \u0014 DocuElevate Admin",
|
||||
"admin_plans.section_basic_info": "Gwybodaeth Sylfaenol",
|
||||
"admin_plans.section_display": "Dangos",
|
||||
"admin_plans.section_features": "Rhestr Nodweddion",
|
||||
"admin_plans.section_overage": "Dylunydd Gormod",
|
||||
"admin_plans.section_pricing": "Prisiau",
|
||||
"admin_plans.section_stripe": "Cyd-fynd â Stripe",
|
||||
"admin_plans.section_volume": "Terfynau Cyfrol",
|
||||
"admin_plans.status_active": "Active",
|
||||
"admin_plans.status_inactive": "Anactif",
|
||||
"admin_plans.stripe_desc_after": "i greu nhw yn awtomatig. Nid oes angen ID Pris Stripe ar gynlluniau di-dâl.",
|
||||
"admin_plans.stripe_desc_before": "Rhowch ID Pris Stripe ar gyfer y cynllun hwn, neu defnyddio'r",
|
||||
"admin_plans.stripe_wizard_aria": "Agor Wizard Gwybodaeth am Stripe mewn tab newydd",
|
||||
"admin_plans.stripe_wizard_link": "Wizard Stripe",
|
||||
"admin_plans.stripe_wizard_text": "Wizard Gwybodaeth am Stripe",
|
||||
"admin_plans.subheading": "Rheoli cynlluniau a chynhelir ar y dudalen brisiau cyhoeddus.",
|
||||
"admin_plans.table_aria_label": "Cynlluniau tanysgrifio",
|
||||
"admin_users.add_user_profile_btn": "Ychwanegu Proffil Defnyddiwr",
|
||||
"admin_users.admin_only_badge": "Admin Yn Unig",
|
||||
"admin_users.btn_password": "Cyfrinair",
|
||||
"admin_users.btn_reset": "Adfer",
|
||||
"admin_users.col_display_name": "Enw Dangos",
|
||||
"admin_users.col_documents": "Dogfennau",
|
||||
"admin_users.col_email": "E-bost",
|
||||
"admin_users.col_last_upload": "Yr Uphola Diweddaraf",
|
||||
"admin_users.col_plan": "Cynllun",
|
||||
"admin_users.col_role": "Rôl",
|
||||
"admin_users.col_upload_limit": "Cyfyngiad Uphola",
|
||||
"admin_users.col_user_id": "ID Defnyddiwr",
|
||||
"admin_users.col_username": "Enw Defnyddiwr",
|
||||
"admin_users.create_local_account_btn": "Creu Cyfrif Lleol",
|
||||
"admin_users.create_local_title": "Creu Cyfrif Lleol",
|
||||
"admin_users.delete_account_btn": "Dileu Cyfrif",
|
||||
"admin_users.delete_local_confirm": "Ydych chi'n siŵr eich bod am ddileu'r cyfrif ar gyfer",
|
||||
"admin_users.delete_local_title": "Dileu Cyfrif Lleol",
|
||||
"admin_users.delete_local_warning": "Ni ellir dadfeisio hyn. Nid yw dogfennau owned gan y defnyddiwr hwn wedi'u dileu.",
|
||||
"admin_users.delete_profile_btn": "Dileu Proffil",
|
||||
"admin_users.delete_profile_confirm": "Ydych chi'n siŵr eich bod am ddileu'r proffil ar gyfer",
|
||||
"admin_users.delete_profile_title": "Dileu Proffil Defnyddiwr",
|
||||
"admin_users.delete_profile_warning": "Mae hyn yn dileu dim ond y cofrestr proffil a reolir gan yr admin. Nid yw dogfennau owned gan y defnyddiwr hwn wedi'u dileu.",
|
||||
"admin_users.deleting": "Dileu\b5",
|
||||
"admin_users.edit_local_title": "Golygu Cyfrif Lleol",
|
||||
"admin_users.filter_placeholder": "Filtru trwy ID defnyddiwr\b5",
|
||||
"admin_users.global_default": "rhyngwladol cyffredinol",
|
||||
"admin_users.heading": "Rheoli Defnyddwyr",
|
||||
"admin_users.js_account_created": "Cofrestrwyd cyfrif",
|
||||
"admin_users.js_account_created_msg": "Cafodd cyfrif lleol ar gyfer \"{username}\" ei greu'n llwyddiannus.",
|
||||
"admin_users.js_account_deleted_msg": "Cafodd cyfrif ar gyfer \"{username}\" ei ddileu.",
|
||||
"admin_users.js_account_updated": "Cafodd y cyfrif ei ddiweddaru.",
|
||||
"admin_users.js_delete_failed": "Dileu wedi methu",
|
||||
"admin_users.js_deleted": "Dileu",
|
||||
"admin_users.js_email_not_sent": "E-bost heb ei anfon",
|
||||
"admin_users.js_email_sent": "E-bost wedi'i anfon",
|
||||
"admin_users.js_email_sent_msg": "E-bost adfer cyfrinair wedi'i anfon i \"{email}\".",
|
||||
"admin_users.js_failed": "Methdiad",
|
||||
"admin_users.js_failed_create": "Methiant i greu cyfrif.",
|
||||
"admin_users.js_failed_load_local": "Methiant i lwytho defnyddwyr lleol",
|
||||
"admin_users.js_failed_load_users": "Methodd i lwytho defnyddwyr",
|
||||
"admin_users.js_failed_set_password": "Methodd i osod cyfrinair.",
|
||||
"admin_users.js_failed_update": "Methodd i ddiweddaru'r cyfrif.",
|
||||
"admin_users.js_network_error": "Gwirfoddoli rhwydwaith",
|
||||
"admin_users.js_password_set": "Cyfrinair wedi'i osod",
|
||||
"admin_users.js_password_set_msg": "Mae'r cyfrinair ar gyfer \"{username}\" wedi'i ddiweddaru.",
|
||||
"admin_users.js_profile_deleted": "Mae'r proffil ar gyfer \"{id}\" wedi'i ddileu.",
|
||||
"admin_users.js_profile_saved": "Mae'r proffil ar gyfer \"{id}\" wedi'i gadw.",
|
||||
"admin_users.js_save_failed": "Methodd i gadw",
|
||||
"admin_users.js_saved": "Wedi'i gadw",
|
||||
"admin_users.js_smtp_not_configured": "Mae SMTP heb ei gyfarwyddo.",
|
||||
"admin_users.js_updated": "Wedi'i ddiweddaru",
|
||||
"admin_users.loading_users": "Llwytho defnyddwyr\b5",
|
||||
"admin_users.local_account_active": "Cyfrif yn weithredol",
|
||||
"admin_users.local_accounts_heading": "Cyfrifon Defnyddiwr Lleol",
|
||||
"admin_users.local_accounts_subheading": "Cyfrifon e-bost/cyfrinair a grëwyd yn uniongyrchol ar y gweinydd hwn.",
|
||||
"admin_users.local_admin_privileges": "Rhoi hawliau gweinyddwr",
|
||||
"admin_users.local_admin_privileges_short": "Hawliau gweinyddwr",
|
||||
"admin_users.local_create_btn": "Creu Cyfrif",
|
||||
"admin_users.local_create_one": "Creu un.",
|
||||
"admin_users.local_creating": "Creu\b5",
|
||||
"admin_users.local_display_name_optional": "(dewisol)",
|
||||
"admin_users.local_loading": "Llwytho\b5",
|
||||
"admin_users.local_no_accounts": "Dim cyfrifon lleol eto.",
|
||||
"admin_users.local_password_hint": "Isafswm 8 cythryn.",
|
||||
"admin_users.local_saving": "Cadw\b5",
|
||||
"admin_users.local_username_hint": "3\u00134 cythryn. Llythrennau, rhifau, mymrynau a thaflenni danliniedig yn unig.",
|
||||
"admin_users.modal_add_title": "Ychwanegu Proffil Defnyddiwr",
|
||||
"admin_users.modal_billing_cycle_label": "Cylch Bilio",
|
||||
"admin_users.modal_billing_monthly": "Misol",
|
||||
"admin_users.modal_billing_yearly": "Flynyddol",
|
||||
"admin_users.modal_block_hint": "(atal uploads dogfen newydd)",
|
||||
"admin_users.modal_block_label": "Blociwch y defnyddiwr hwn",
|
||||
"admin_users.modal_close_aria": "Cau digon",
|
||||
"admin_users.modal_complimentary_hint": "(mae'r defnyddiwr yn cadw buddion lefel ond ni fydd yn cael ei bilio - gosodwn yn awtomatig ar gyfer cyfrifon gweinyddwr)",
|
||||
"admin_users.modal_complimentary_label": "Cynllun am ddim",
|
||||
"admin_users.modal_daily_limit_hint": "(gadael yn wag i ddefnyddio'r deffnydd byd-eang)",
|
||||
"admin_users.modal_daily_limit_label": "Terfyn Uwchlwytho Dyddiol",
|
||||
"admin_users.modal_daily_limit_placeholder": "e.e. 50 (0 = amddiffynnol)",
|
||||
"admin_users.modal_display_name_label": "Enw arddangos",
|
||||
"admin_users.modal_display_name_placeholder": "Alice Smith (dewisol)",
|
||||
"admin_users.modal_edit_title": "Golygu Proffil Defnyddiwr",
|
||||
"admin_users.modal_notes_label": "Nodau Weithredwyr",
|
||||
"admin_users.modal_notes_placeholder": "Nodau mewnol sy'n weladwy dim ond i weinyddion\n",
|
||||
"admin_users.modal_period_start_hint": "Mae trosglwyddo blynyddol yn cael ei gyfrifo o'r dyddiad hwn. Gadewch yn wag ar gyfer gorfodi misol.",
|
||||
"admin_users.modal_period_start_label": "Cyfnod Dechrau Cyflwyno",
|
||||
"admin_users.modal_plan_business": "Busnes — $7.99/mis (300/mis, blychau post diben net)",
|
||||
"admin_users.modal_plan_free": "Am ddim — 25 ffeiliau am byth",
|
||||
"admin_users.modal_plan_hint": "Mae'n gosod y cyfyngiadau cwota ar gyfer y defnyddiwr hwn. Mae cyfyngiadau yn cael eu gorfodi pan i'w gyrru.",
|
||||
"admin_users.modal_plan_label": "Cynllun Cyflwyno",
|
||||
"admin_users.modal_plan_professional": "Proffesiynol — $5.99/mis (150/mis, 3 blychau post)",
|
||||
"admin_users.modal_plan_starter": "Dechreuwr — $2.99/mis (50/mis, 1 blwch post)",
|
||||
"admin_users.modal_save_changes": "Cadw Newidiadau",
|
||||
"admin_users.modal_saving": "Yn cadw\n",
|
||||
"admin_users.modal_user_id_hint": "Y ddynodiad sefydlog sy'n cyd-fynd â owner_id yn y dogfennau.",
|
||||
"admin_users.modal_user_id_label": "ID Defnyddiwr",
|
||||
"admin_users.modal_user_id_placeholder": "user@example.com neu sub OAuth",
|
||||
"admin_users.new_account_btn": "Cyfrif Newydd",
|
||||
"admin_users.new_password_label": "Cyfrinair Newydd",
|
||||
"admin_users.no_users_add_hint": "Cyflwynwch rai dogfennau neu ychwanegwch proffil uchod.",
|
||||
"admin_users.no_users_found": "Dim defnyddwyr wedi'u darganfod.",
|
||||
"admin_users.no_users_search_hint": "Dewiswch derm chwilio gwahanol.",
|
||||
"admin_users.page_title": "Rheoli Defnyddwyr – Weinydd – DocuElevate",
|
||||
"admin_users.pagination_page_of": "o",
|
||||
"admin_users.per_day": "/ diwrnod",
|
||||
"admin_users.role_admin": "Weinydd",
|
||||
"admin_users.role_user": "Defnyddiwr",
|
||||
"admin_users.search_users_label": "Chwilio am ddefnyddwyr",
|
||||
"admin_users.set_password_btn": "Set Cyfrinair",
|
||||
"admin_users.set_password_desc": "Dylai'r defnyddiwr newid y cyfrinair hwn ar ôl mewngofnodi.",
|
||||
"admin_users.set_password_desc_pre": "Set cyfrinair newydd yn uniongyrchol ar gyfer",
|
||||
"admin_users.set_password_title": "Set Cyfrinair Dros Dro",
|
||||
"admin_users.setting": "Gosod\n",
|
||||
"admin_users.status_blocked": "Blociedig",
|
||||
"admin_users.status_unverified": "Heb ei wirio",
|
||||
"admin_users.subheading": "Rheoli proffiliau defnyddwyr, cyfyngiadau cyflwyno fesul defnyddiwr, a phriodwedd dogfennau.",
|
||||
"admin_users.total_count_users": "{count} defnyddwyr",
|
||||
"admin_users.total_no_users": "Dim defnyddwyr",
|
||||
"admin_users.total_one_user": "1 defnyddiwr",
|
||||
"api_tokens.col_created": "Creadig",
|
||||
"api_tokens.col_last_ip": "IP Diweddar",
|
||||
"api_tokens.col_last_used": "Defnyddiwyd Diweddar",
|
||||
@@ -73,6 +347,19 @@
|
||||
"api_tokens.usage_intro_pre": "Defnyddiwch eich token API yn y",
|
||||
"api_tokens.your_tokens": "Eich Tokenau",
|
||||
"app.name": "DocuElevate",
|
||||
"attribution.heading": "Awdurdodau Meddalwedd Trydydd Parti",
|
||||
"attribution.intro": "Mae DocuElevate yn defnyddio sawl llyfrgell a thwyseddfeydd agored. Rydym yn ddiolchgar i ddatblygwyr y prosiectau hyn am eu cyf contributions i feddalwedd agored.",
|
||||
"attribution.page_title": "DocuElevate - Awdurdodau Trydydd Parti",
|
||||
"attribution.paramiko_lgpl_note": "Nodyn: Mae'r llyfrgell hon wedi'i thrwyddedu o dan Drwydded Gymwys Gyhoeddus Ddiffyniog GNU v2.1 (LGPL-2.1)",
|
||||
"attribution.section_docker": "Delweddau Docker",
|
||||
"attribution.section_frontend": "Dibyniaethau Frontend",
|
||||
"attribution.section_python": "Dibyniaethau Python",
|
||||
"attribution.special_lgpl_link": "fan hyn",
|
||||
"attribution.special_lgpl_post": ".",
|
||||
"attribution.special_lgpl_pre": "Gellir dod o hyd i gopi o'r drwydded LGPL",
|
||||
"attribution.special_source_post": ".",
|
||||
"attribution.special_source_pre": "Mae'r meddalwedd hon yn cynnwys Paramiko, sydd wedi'i thrwyddedu o dan LGPL. Mae'r cod ffynhonnell ar gyfer Paramiko ar gael yn",
|
||||
"attribution.special_title": "Awdurdod Arbennig:",
|
||||
"audit.col_ip": "IP",
|
||||
"audit.col_resource": "Adroddiad",
|
||||
"audit.col_timestamp": "Amser Stamp",
|
||||
@@ -252,6 +539,41 @@
|
||||
"cookie.notice_label": "Hysbysiad cwci",
|
||||
"cookie.policy_link": "Polisi cwci",
|
||||
"cookie.privacy_link": "Hysbysiad preifatrwydd",
|
||||
"cookie_policy.heading": "Polisi Cwci",
|
||||
"cookie_policy.last_updated": "Diweddarwyd Diweddaraf:",
|
||||
"cookie_policy.page_title": "Polisi Cwci - DocuElevate",
|
||||
"cookie_policy.s1_heading": "Beth yw Cwcïau",
|
||||
"cookie_policy.s1_p1": "Mae cwcïau yn ffeiliau testun bychain sy'n cael eu storio ar eich cyfrifiadur neu ddirwyn symudol pan fyddwch yn ymweld â gwefan. Maent yn cael eu defnyddio'n eang i wneud gwefannau'n gweithio'n fwy effeithlon a darparu gwybodaeth i berchnogion y gwefan.",
|
||||
"cookie_policy.s2_heading": "Sut Rydym yn Defnyddio Cwcïau",
|
||||
"cookie_policy.s2_li1_body": "I'ch adnabod pan ydych yn mewngofnodi a chadw eich sesiwn tra byddwch yn defnyddio'r cais.",
|
||||
"cookie_policy.s2_li1_label": "Dilysu & Rheoli Sesiwn:",
|
||||
"cookie_policy.s2_p1_post": "ar gyfer y diben canlynol:",
|
||||
"cookie_policy.s2_p1_pre": "Mae DocuElevate yn defnyddio",
|
||||
"cookie_policy.s2_p1_strong": "cwcïau sesiwn sydd eu hangen yn unig",
|
||||
"cookie_policy.s2_p2": "Mae'r cwcïau hyn yn orfodol ar gyfer gweithrediad priodol ein gwasanaeth. Heb y cwcïau hyn, byddai'n rhaid i chi fewngofnodi'n ail yn ystod eich sesiwn pori.",
|
||||
"cookie_policy.s2_p3": "Oherwydd bod y cwcïau hyn yn hollol hanfodol i'r gwasanaeth weithio, maent yn eithrio o ofynion cydsyniad cyn-ddefnydd o dan Gyfarwyddeb ePrivacy yr UE (Art. 5(3)) a gweithredu cenedlaethol cyfatebol. Ni sefydlwn unrhyw gwcïau dewisol, dadansoddi, hysbysebu, nac olrhain.",
|
||||
"cookie_policy.s3_col_duration": "Hyd",
|
||||
"cookie_policy.s3_col_name": "Enw",
|
||||
"cookie_policy.s3_col_purpose": "Diben",
|
||||
"cookie_policy.s3_col_type": "Math",
|
||||
"cookie_policy.s3_heading": "Manylion Cwcï",
|
||||
"cookie_policy.s3_row1_duration": "Sesiwn (wedi'i ddileu ar gau'r porwr neu allgofnodi)",
|
||||
"cookie_policy.s3_row1_purpose": "Cadw eich sesiwn ddilysedig; angenrheidiol ar gyfer gweithredu cofrestru.",
|
||||
"cookie_policy.s3_row1_type": "Iangledd Hanfodol",
|
||||
"cookie_policy.s3_row2_duration": "Parhaol (storage lleol porwr)",
|
||||
"cookie_policy.s3_row2_purpose": "Stores your acknowledgement of the cookie notice so it is not shown repeatedly (stored in localStorage, not a cookie).",
|
||||
"cookie_policy.s3_row2_type": "Iangledd Hanfodol",
|
||||
"cookie_policy.s4_heading": "Dim Cwcïau Trydydd Parti",
|
||||
"cookie_policy.s4_p1": "Mae DocuElevate ddim yn defnyddio unrhyw gwcïau trydydd parti, cwcïau olrhain, cwcïau hysbysebu, nac ystadegol. Rydym yn parchu eich preifatrwydd ac yn gweithredu dim ond y cwcïau lleiaf sydd eu hangen ar gyfer ein gwasanaeth i weithio.",
|
||||
"cookie_policy.s4_p2_pre": "Am ragor o wybodaeth am sut rydym yn delio â'ch data, gweler ein",
|
||||
"cookie_policy.s4_privacy_link": "Hysbysiad Preifatrwydd",
|
||||
"cookie_policy.s5_heading": "Rheoli Cwcis",
|
||||
"cookie_policy.s5_p1": "Mae'r çoedhryddion gwe mwyaf yn caniatáu i chi reoli cwcis trwy eu gosodiadau. Fodd bynnag, bydd blocio neu ddileu ein cwcis sesiwn yn atal DocuElevate rhag gweithio, gan fod dilysu defnyddwyr yn dibynnu ar y cwcis hyn.",
|
||||
"cookie_policy.s5_p2": "Gallwch hefyd ddileu'r cydnabyddiaeth hysbysiad cwci sydd wedi'i storio yn eich almacenamiento lleol porwr ar unrhyw adeg trwy offer datblygu eich porwr (Cymhwyso \u001c\u0010\u001c Cynhelwr).",
|
||||
"cookie_policy.s5_p3_and": "a",
|
||||
"cookie_policy.s5_p3_pre": "Mae'r Polis Cwci hon yn rhan o a'i chynnwys yn ein",
|
||||
"cookie_policy.s5_privacy_link": "Hysbysiad Preifatrwydd",
|
||||
"cookie_policy.s5_terms_link": "Telerau Gwasanaeth",
|
||||
"credentials.col_action": "Gweithred",
|
||||
"credentials.col_credential": "Credenciales",
|
||||
"credentials.col_source": "Ffynhonnell",
|
||||
@@ -517,6 +839,31 @@
|
||||
"help.workflows_step_5_create": "Cadwch – bydd dogfennau newydd yn cael eu prosesu trwy'r pipeline hwn yn awtomatig.",
|
||||
"help.workflows_typical_steps": "Camau Típig",
|
||||
"help.workflows_what_is": "Beth yw Pipeline?",
|
||||
"imprint.business_registration_heading": "Cofrestru Busnes",
|
||||
"imprint.business_registration_vat": "Rhif Adnabod VAT yn unol â \u0000a727a Deddf Treth ar Werth:",
|
||||
"imprint.contact_heading": "Gwybodaeth Cyswllt",
|
||||
"imprint.dispute_heading": "Datrys Dadleuon Ar-lein",
|
||||
"imprint.dispute_p1": "Mae Comisiwn Ewropeaidd yn darparu llwyfan ar gyfer datrys dadleuon ar-lein (OS):",
|
||||
"imprint.dispute_p2": "Nid ydym yn barod nac yn orfodol i gymryd rhan mewn gweithdrefnau datrys dadleuon gerbron bwrdd dyfarnu defnyddwyr.",
|
||||
"imprint.heading": "Imprint",
|
||||
"imprint.legal_copyright": "Mae'r holl gynnwys ar y wefan hon yn cael ei amddiffyn gan hawlfraint. Mae unrhyw ddefnydd y tu allan i ffiniau'r gyfraith hawlfraint yn gofyn am ganiatâd wedi'i ysgrifennu gan yr awdur neu'r creawdwr perthnasol.",
|
||||
"imprint.legal_heading": "Hysbysiadau Cyfreithiol",
|
||||
"imprint.legal_liability": "Er gwaethaf rheolaeth gynnwys fanwl, ni dderbyniwn unrhyw gyfrifoldeb am gynnwys dolenni allanol. Mae'r gweithredwyr o'r tudalennau cysylltiedig yn gyfrifol yn unig am eu cynnwys.",
|
||||
"imprint.page_title": "Imprint - DocuElevate",
|
||||
"imprint.policies_cookie_desc": "Gwybodaeth am gwcis rydym yn eu defnyddio",
|
||||
"imprint.policies_cookie_label": "Polis Cwci",
|
||||
"imprint.policies_heading": "Polisau Cysylltiedig",
|
||||
"imprint.policies_intro": "Mae ein gwasanaeth yn cael ei reoleiddio gan y polisiau canlynol:",
|
||||
"imprint.policies_license_desc": "Sut mae ein meddalwedd yn cael ei drwyddedu",
|
||||
"imprint.policies_license_label": "Gwybodaeth Drwydded",
|
||||
"imprint.policies_privacy_desc": "Sut rydym yn trin eich data",
|
||||
"imprint.policies_privacy_label": "Polis Preifatrwydd",
|
||||
"imprint.policies_terms_desc": "Reolau ar ddefnyddio DocuElevate",
|
||||
"imprint.policies_terms_label": "Telerau Gwasanaeth",
|
||||
"imprint.provider_heading": "Darparwr Gwasanaeth",
|
||||
"imprint.responsible_content_heading": "Cyfrifol am Gynnwys",
|
||||
"imprint.responsible_content_rstv": "Yn unol â \u0000a7 55 Abs. 2 RStV:",
|
||||
"imprint.subtitle": "Gwybodaeth yn unol â \u0000a7 5 TMG (Deddf Telemedi Germany)",
|
||||
"index.badge_intelligent": "Prosesu Dogfennau Deallus",
|
||||
"index.button_browse_files": "Pori Dogfennau",
|
||||
"index.button_upload": "Uwchlwytho",
|
||||
@@ -728,6 +1075,19 @@
|
||||
"language.tr": "Twrceg",
|
||||
"language.uk": "Wcrainig",
|
||||
"language.zh": "Tsieinëeg",
|
||||
"license.apache_description": "Mae DocuElevate ar gael o dan Drwydded Apache 2.0, sy'n drwydded meddalwedd agored hawdd sy'n caniatáu ichi ddefnyddio, addasu, dosbarthu, a chyfrannu at y prosiect.",
|
||||
"license.apache_heading": "Drwydded Apache 2.0",
|
||||
"license.heading": "Gwybodaeth Drwydded",
|
||||
"license.page_title": "Gwybodaeth Drwydded - DocuElevate",
|
||||
"license.related_about_link": "Tudalen am",
|
||||
"license.related_and": "a",
|
||||
"license.related_heading": "Gwybodaeth Gysylltiedig",
|
||||
"license.related_p1_post": "am wybodaeth am ddefnyddio'r gwasanaeth DocuElevate.",
|
||||
"license.related_p1_pre": "Er bod y drwydded hon yn llywodraethu defnydd ein meddalwedd, gwnewch yn siŵr hefyd eich bod yn adolygu ein",
|
||||
"license.related_p2_post": ".",
|
||||
"license.related_p2_pre": "Am ragor o wybodaeth am DocuElevate, ewch i'r",
|
||||
"license.related_privacy_link": "Polisi Preifatrwydd",
|
||||
"license.related_terms_link": "Telerau Gwasanaeth",
|
||||
"nav.about": "Ynghylch",
|
||||
"nav.account_menu": "Meny Cyfrif",
|
||||
"nav.account_menu_for": "Meny Cyfrif ar gyfer {name}",
|
||||
@@ -828,6 +1188,185 @@
|
||||
"pipelines.system_label": "System",
|
||||
"pipelines.system_pipeline_label": "Pipeline system (gweldadwy gan bob defnyddiwr)",
|
||||
"pipelines.title": "Pipelinau Prosesu",
|
||||
"privacy.heading": "DocuElevate – Hysbysiad Preifatrwydd",
|
||||
"privacy.last_updated": "Diwygiedig Diweddar:",
|
||||
"privacy.page_title": "Hysbysiad Preifatrwydd - DocuElevate",
|
||||
"privacy.s10_access_body": "Gallwch ofyn am gopi o'r data personol sydd gennym amdanoch chi.",
|
||||
"privacy.s10_access_label": "Hawl i'w Chyrchu (Art. 15):",
|
||||
"privacy.s10_complaint_body": "Mae gennych hawl i gyflwyno cwyn i'ch Awdurdod Diogelu Data cenedlaethol (DPA). Yn yr Almaen: Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI). Yn y DU: Swyddfa'r Comisiynwr Gwybodaeth (ICO). Yn y Swistir: Comisiynydd Diogelu Data a Gwybodaeth (FDPIC).",
|
||||
"privacy.s10_complaint_label": "Hawl i Gynhyrchu Cwyn:",
|
||||
"privacy.s10_contact": "I weithredu unrhyw un o'r hawl uchod, cysylltwch â ni yn",
|
||||
"privacy.s10_erasure_body": "Gallwch ofyn am ddileu eich data personol lle nad oes rheswm dilys dros ei gadw.",
|
||||
"privacy.s10_erasure_label": "Hawl i Ddileu (Art. 17):",
|
||||
"privacy.s10_heading": "10. Eich Hawliau (EU / EEA / DU / Swistir)",
|
||||
"privacy.s10_object_body": "Gallwch wrthwynebu prosesu yn seiliedig ar ddiddordebau dilys ar unrhyw adeg.",
|
||||
"privacy.s10_object_label": "Hawl i Wrthwynebu (Art. 21):",
|
||||
"privacy.s10_p1": "O dan y GDPR (a'r GDPR y DU / cyfwerth nFADP y Swistir), mae gennych y hawliau canlynol:",
|
||||
"privacy.s10_portability_body": "Gallwch ofyn am eich data mewn fformat strwythuredig, a ddefnyddir yn gyffredin, sy'n ddealladwy gan beiriannau.",
|
||||
"privacy.s10_portability_label": "Hawl i Drosglwyddo Data (Art. 20):",
|
||||
"privacy.s10_rectification_body": "Gallwch ofyn am gywiriad o'r data personol anwir neu anghymwys.",
|
||||
"privacy.s10_rectification_label": "Hawl i Gywiro (Art. 16):",
|
||||
"privacy.s10_response": "Byddwn yn ateb o fewn un mis calendr (gall cael ei hymestyn am ddau fis pellach ar gyfer ceisiadau cymhleth).",
|
||||
"privacy.s10_restriction_body": "Gallwch ofyn i ni atal prosesu eich data yn dros dro dan rai amodau.",
|
||||
"privacy.s10_restriction_label": "Hawl i Gyfyngu (Art. 18):",
|
||||
"privacy.s10_withdraw_body": "Lle mae prosesu yn seiliedig ar gonsent, gallwch dynnu'r gonsent hwnnw yn ôl ar unrhyw adeg heb ddylanwad ar gyfreithlondeb prosesu cynt.",
|
||||
"privacy.s10_withdraw_label": "Hawl i Ddynnu Cynhelai:",
|
||||
"privacy.s11_categories_body": "Dynodwyr (enw, e-bost), tocynnau dilysu cyfrif, a meta-data dogfennau a ddewiswch eu hiliwrth.",
|
||||
"privacy.s11_categories_label": "Categorïau o wybodaeth bersonol a gasglwyd:",
|
||||
"privacy.s11_contact": "I gyflwyno cais gwirfoddol i'r defnyddiwr, cysylltwch â ni yn",
|
||||
"privacy.s11_correct_body": "Gallwch ofyn am gywiriad o'r wybodaeth bersonol anwir.",
|
||||
"privacy.s11_correct_label": "Hawl i Gywiro:",
|
||||
"privacy.s11_delete_body": "Gallwch ofyn am ddileu gwybodaeth bersonol a gasglwyd gennym, yn amodol ar rai eithriadau.",
|
||||
"privacy.s11_delete_label": "Hawl i Ddileu:",
|
||||
"privacy.s11_heading": "11. Hawliau Ychwanegol – Yr UD (CCPA / CPRA)",
|
||||
"privacy.s11_know_body": "Gallwch ofyn am ddynodi'r categorïau a'r darnau penodol o wybodaeth bersonol a gasglwyd gennym amdanoch chi.",
|
||||
"privacy.s11_know_label": "Hawl i Wybod:",
|
||||
"privacy.s11_limit_body": "Nid ydym yn defnyddio gwybodaeth bersonol sensitif y tu hwnt i'r hyn sydd ei hangen i ddarparu'r gwasanaeth.",
|
||||
"privacy.s11_limit_label": "Hawliau i Lymu Defnydd Gwybodaeth Bersonol Sensitif:",
|
||||
"privacy.s11_nondiscrim_body": "Nid ydym yn gwneud gwahaniaeth yn eich herbyn am ddefnyddio unrhyw un o'r hawliau hyn.",
|
||||
"privacy.s11_nondiscrim_label": "Di-Gwahaniaethu:",
|
||||
"privacy.s11_optout_body": "Nid ydym yn gwerthu na rhannu gwybodaeth bersonol fel y diffwiniwyd gan CCPA/CPRA. Nid oes angen mecanwaith dad-od; fodd bynnag, gallwch gysylltu â ni i gadarnhau hyn.",
|
||||
"privacy.s11_optout_label": "Hawliau i Ddad-od o Werthiant / Rhannu:",
|
||||
"privacy.s11_p1": "Os ydych chi'n byw yn California neu state arall yn yr UD gyda deddfwriaeth breifat berthnasol (gan gynnwys Virginia VCDPA, Colorado CPA, Connecticut CTDPA, Utah UCPA), mae'r datgeliadau ychwanegol canlynol yn berthnasol:",
|
||||
"privacy.s11_purpose_body": "Darparu, gwellt ac amddiffyn gwasanaeth DocuElevate. Nid ydym yn gwerthu na rhannu gwybodaeth bersonol ar gyfer hysbysebu ymddygiadol traws-gyd-destun.",
|
||||
"privacy.s11_purpose_label": "Pwrpas y casglu:",
|
||||
"privacy.s11_response": "Byddwn yn ymateb o fewn 45 diwrnod (gallai ymestyn am 45 diwrnod ychwanegol pan fo angen yn rhesymol).",
|
||||
"privacy.s12_access_body": "Gallwch ofyn am fynediad at eich gwybodaeth bersonol a gwybodaeth am sut y defnyddiwyd neu ddathlwyd hi.",
|
||||
"privacy.s12_access_label": "Hawliau Mynediad:",
|
||||
"privacy.s12_contact": "Cyfeirier cwynion preifatrwydd yn ddirybudd i'n Swyddog Preifatrwydd yn",
|
||||
"privacy.s12_contact_post": ", neu i Swyddfa'r Comisiynydd Preifatrwydd yn Canada.",
|
||||
"privacy.s12_correction_body": "Gallwch herio cywirdeb neu gyflawnder eich gwybodaeth bersonol a gofyn am gywiriad.",
|
||||
"privacy.s12_correction_label": "Hawliau i Gywiriad:",
|
||||
"privacy.s12_heading": "12. Hawliau Ychwanegol – Canada (PIPEDA / Deddf 25 Québec)",
|
||||
"privacy.s12_li1": "Rydym yn casglu, defnyddio, ac yn datgelu gwybodaeth bersonol yn unig gyda’ch gwybodaeth a chaniatâd, neu fel y caniateir gan y gyfraith.",
|
||||
"privacy.s12_p1": "Os ydych chi'n byw yn Canada, mae'r canlynol yn berthnasol o dan y Ddeddf Diogelu Gwybodaeth Bersonol a Dogfennau Electronig (PIPEDA) a deddfwriaeth ranbedrol berthnasol (gan gynnwys Deddf 25 Québec / Bil 64):",
|
||||
"privacy.s12_quebec_body": "Dan Ddeddf 25, mae gennych hawliau ychwanegol gan gynnwys hawl i gyfleustodau data (yn gweithredu mis Medi 2023) a hawl i ddad-dynnu lle y rhoddir gwybodaeth bersonol ar-lein.",
|
||||
"privacy.s12_quebec_label": "Preswylwyr Québec:",
|
||||
"privacy.s12_withdraw_body": "O dan gyfyngiadau cyfreithiol neu gontraktol, gallwch dynnu'n ôl eich caniatâd i'r casglu, defnyddio, neu ddatgelu eich gwybodaeth bersonol gyda rhybudd rhesymol.",
|
||||
"privacy.s12_withdraw_label": "Hawliau i Dynnu'n Ôl Caniatâd:",
|
||||
"privacy.s13_brazil_body": "Os ydych chi'n byw yn Brasil, mae gennych y hawliau canlynol o dan y LGPD:",
|
||||
"privacy.s13_brazil_label": "Brasil (LGPD – Deddf Gyffredinol am Ddiogelu Data, Deddf 13.709/2018):",
|
||||
"privacy.s13_contact": "Cysylltwch â:",
|
||||
"privacy.s13_heading": "13. Hawliau Ychwanegol – America Ladin (LGPD a Chanlyniadau Eraill)",
|
||||
"privacy.s13_li1": "Cadarnhad o argaeledd prosesu a mynediad at eich data.",
|
||||
"privacy.s13_li2": "Cywiriad o ddata anweledig, anwir, neu hen.",
|
||||
"privacy.s13_li3": "Anonymiad, blocio, neu ddileu data diangen neu ormodol.",
|
||||
"privacy.s13_li4": "Cyfleustodau eich data i ddarparwr gwasanaeth neu gynnyrch arall.",
|
||||
"privacy.s13_li5": "Dileu data personol a broseswyd gyda’ch caniatâd.",
|
||||
"privacy.s13_li6": "Gwybodaeth am endidau y rhoddir eich data iddynt.",
|
||||
"privacy.s13_li7": "Gwybodaeth am y posibilrwydd o beidio â chanuatâd a chanlyniadau gwrthod.",
|
||||
"privacy.s13_li8": "Cynhyrchiad caniatâd.",
|
||||
"privacy.s13_other_body": "Cydnabyddwn hefyd yr deddfau preifatrwydd perthnasol yn yr Ariannin (PDPA), Mecsico (LFPDPPP), Chile, Colombia (Deddf 1581), a eraill. Gall defnyddwyr yn y jurisdicciynau hyn arfer hawliau cyfwerth fel y nodir dan eu deddf genedlaethol trwy gysylltu â ni.",
|
||||
"privacy.s13_other_label": "Gwybodaeth am Wladwriaethau Eraill yn America Ladin:",
|
||||
"privacy.s14_apj_body": "Cydnabyddwn y hawliau diogelu data a roddir i drigolion y jurisdicciynau hyn dan eu deddfau cenedlaethol perthnasol. Cysylltwch â ni i arfer eich hawliau.",
|
||||
"privacy.s14_apj_label": "Marchnadoedd Eraill APJ (PDPA Singapore, Deddf Preifatrwydd Seland Newydd, Deddf DPDP India):",
|
||||
"privacy.s14_australia_body": "Gall preswylwyr Awstralia ofyn am fynediad a chywiriad i’w gwybodaeth bersonol. Byddwn yn ymateb i geisiadau mynediad o fewn 30 diwrnod. Gellir cyflwyno cwynion i Swyddfa Gomisiynydd Gwybodaeth Awstralaidd (OAIC).",
|
||||
"privacy.s14_australia_label": "Awstralia (Deddf Preifatrwydd 1988 a Phriodoldeb Preifatrwydd Awstralia):",
|
||||
"privacy.s14_contact": "Cysylltwch â ni:",
|
||||
"privacy.s14_heading": "14. Hawliau Ychwanegol – Asia-Pasiffig a Japan",
|
||||
"privacy.s14_japan_body": "Gall preswylwyr Japan ofyn am ddatgeliad, cywiro, ychwanegu neu ddileu, atal defnydd, dileu, neu atal rhoi gwybodaeth bersonol drydydd parti sydd gennym. Mae datgeliadau trydydd parti yn gofyn am eich consent cynradd ac eithrio ble bo'r gyfraith yn caniatáu.",
|
||||
"privacy.s14_japan_label": "Japan (APPI – Deddf ar ddiogelu gwybodaeth bersonol):",
|
||||
"privacy.s14_korea_body": "Gall preswylwyr Corea ofyn am fynediad, cywiro, dileu, a chadw'n ddiamod. Rydym yn rheoli gwybodaeth bersonol preswylwyr Corea yn unol â'r PIPA.",
|
||||
"privacy.s14_korea_label": "De Corea (PIPA – Deddf Diogelu Gwybodaeth Bersonol):",
|
||||
"privacy.s15_contact": "Cysylltwch â ni:",
|
||||
"privacy.s15_heading": "15. Hawliau Ychwanegol – Wcráin",
|
||||
"privacy.s15_p1": "Mae defnyddwyr sydd wedi'u lleoli yn Wcráin yn cael eu diogelu o dan Ddeddf Wcráin \"Ar ddiogelu data personol\" (Rhif 2297-VI). Mae eich hawliau yn cynnwys mynediad i, cywiro, blocio, a dileu eich data personol, yn ogystal â'r hawl i wrthwynebu prosesu.",
|
||||
"privacy.s16_cookies_link": "Polisi Cwcis",
|
||||
"privacy.s16_heading": "16. Diweddariadau i'r Hysbysiad Preifatrwydd hwn",
|
||||
"privacy.s16_license_link": "Gwybodaeth drwy gydnabydd",
|
||||
"privacy.s16_p1": "Gallwn ddiweddaru'r hysbysiad hwn o bryd i'w gilydd i adlewyrchu newidiadau yn ein harferion neu'r deddfau perthnasol. Mae'r dyddiad \"Diweddarwyd yn ddiweddar\" ar ben y dudalen hon yn dangos pryd y newidwyd yr hysbysiad diwethaf. Pan fo newidiadau'n sylweddol, byddwn yn hysbysu defnyddwyr trwy hysbysiad ym mhob cais neu drwy e-bost lle bo'n briodol.",
|
||||
"privacy.s16_p2_pre": "Os oes gennych unrhyw gwestiynau neu bryderon am yr Hysbysiad Preifatrwydd hwn neu eich data personol, cysylltwch â ni yn",
|
||||
"privacy.s16_p3_pre": "Gweler hefyd ein",
|
||||
"privacy.s16_terms_link": "Telerau Gwasanaeth",
|
||||
"privacy.s1_address": "Alter Steinweg 3, 20459 Hamburg, Germany",
|
||||
"privacy.s1_company": "Christian Louis IT Beratung",
|
||||
"privacy.s1_contact_label": "E-bost Cyswllt:",
|
||||
"privacy.s1_heading": "1. Rheolwr Data",
|
||||
"privacy.s1_p1": "Y rheolwr sy'n gyfrifol am brosesu eich data personol o dan Rheoliad Cyffredinol Diogelu Data'r UE (GDPR) a deddfau preifatrwydd cyfatebol ledled y byd yw:",
|
||||
"privacy.s1_p3": "Ar gyfer pob cais sy'n gysylltied ag preifatrwydd (mynediad, dileu, cywiro, optio-allan, neu gwynion), cysylltwch â ni yn y cyfeiriad e-bost uchod. Byddwn yn ymateb o fewn 30 diwrnod (neu'r cyfnod a benodwyd gan y gyfraith berthnasol).",
|
||||
"privacy.s2_heading": "2. Cylch gorchwyl yr Hysbysiad Preifatrwydd hwn",
|
||||
"privacy.s2_p1_pre": "Mae'r hysbysiad hwn yn gymwys i'r cais gwe DocuElevate, a gynhelir yn",
|
||||
"privacy.s2_p2": "Mae'n cynnwys pob defnyddiwr yn fyd-eang, gan gynnwys y rhai yn yr Undeb Ewropeaidd (UE), Ardal Economaidd Ewropeaidd (EEA), Yr Almaen, y Deyrnas Unedig (DU), Yr Iseldiroedd, Wcráin, yr UD, Canada, America Ladin (Latam), Asia-Pasiffig, a Japan. Mae datgeliadau penodol i'r farchnad ar gael yn adrannau penodol isod.",
|
||||
"privacy.s3_audit_body": "Mae gennym gofrestri cyfyngedig o archwiliadau (math gweithred, amser cofrestru, adnabod defnyddiwr) i sicrhau diogelwch a dibynadwyedd y gwasanaeth. Nid yw'r gofrestriadau hyn yn cynnwys cynnwys dogfen.",
|
||||
"privacy.s3_audit_label": "Cofrestri Archwilio:",
|
||||
"privacy.s3_auth_body": "Rydym yn defnyddio OAuth 2.0 (Google, Dropbox, Microsoft/OneDrive) a dilysiad lleol dewisol. Trwy OAuth, gallwn dderbyn eich enw, cyfeiriad e-bost, a llun proffil.",
|
||||
"privacy.s3_auth_label": "Dilysu Defnyddiwr:",
|
||||
"privacy.s3_doc_body": "Mae dogfennau rydych chi'n eu llwytho ar gyfer OCR (adnabod cymeriadau optegol), echdynnu metadata, a storio yn eich darparu cwmwl dewisol. Mae cynnwys dogfen yn cael ei brosesu yn unig at y diben yr ydych yn ei gynnal ac ni chaiff ei storio y tu hwnt i'r hyn sy'n angenrheidiol i'w weithredu.",
|
||||
"privacy.s3_doc_label": "Prosesu Dogfennau:",
|
||||
"privacy.s3_heading": "3. Casglu Data a Dibenion",
|
||||
"privacy.s3_legal_body": "Mae ein prif sail gyfreithiol ar gyfer prosesu yn:",
|
||||
"privacy.s3_legal_label": "Sail Gyfreithiol (GDPR Art. 6):",
|
||||
"privacy.s3_li1": "(1)(b) Perfformiad contract: i ddarparu'r gwasanaeth DocuElevate yr ydych wedi'i ofyn.",
|
||||
"privacy.s3_li2": "(1)(c) Rhwymedigaeth gyfreithiol: i gydymffurfio â deddfau a rheolau perthnasol.",
|
||||
"privacy.s3_li3": "(1)(f) Buddiannau dilys: sicrhau diogelwch y gwasanaeth a phreventio twyll.",
|
||||
"privacy.s4_heading": "4. Lleihau Data a Chyfyngiad Diben",
|
||||
"privacy.s4_li1": "Rydym yn casglu dim ond y data personol lleiaf sydd ei angen i weithredu'r gwasanaeth.",
|
||||
"privacy.s4_li2": "Mae cynnwys dogfen yn cael ei brosesu'n benodol ar gyfer y diben yr ydych yn ei gynnal (OCR, storio, echdynnu metadata). Nid ydym yn defnyddio eich dogfennau i hyfforddi modelau AI nac at unrhyw ddiben eilaidd.",
|
||||
"privacy.s4_li3": "Nid yw hysbysebion, olrhain ymddygiad, nac proffilio yn cael eu perfformio.",
|
||||
"privacy.s4_li4": "Nid yw cwcis olrhain nac ysgyfarnogion dadansoddol yn cael eu llwytho.",
|
||||
"privacy.s4_li5": "Mae gwasanaethau AI trydydd parti (e.e., OpenAI, Azure Document Intelligence) yn cael eu galw dim ond pan fyddwch yn dechrau prosesu dogfennau, a bydd data yn cael ei drosglwyddo o dan gytundebau prosesu data.",
|
||||
"privacy.s4_p1": "Mae DocuElevate wedi’i ddylunio gyda lleihau data fel prif egwyddor (GDPR Art. 5(1)(c)):",
|
||||
"privacy.s5_cookie_link": "Polisi Cwci",
|
||||
"privacy.s5_heading": "5. Defnyddio Cwcis a Thechnolegau Tebyg",
|
||||
"privacy.s5_p1_post": "i gynnal eich sesiwn dilyswyd. Mae'r cwcis hyn yn hanfodol i'r gwasanaeth weithio ac maent wedi'u heithrio rhag gofynion cydsyniad cynheliedig o dan Gyfarwyddeb ePrivacy yr UE (Art. 5(3)) a deddfau cenedlaethol cyfatebol.",
|
||||
"privacy.s5_p1_pre": "Mae DocuElevate yn defnyddio",
|
||||
"privacy.s5_p1_strong": "dim ond cwcis sesiwn sy'n hanfodol o reidrwydd",
|
||||
"privacy.s5_p2_body": "cwcis dadansoddol, cwcis hysbysebu, pixelau olrhain, neu unrhyw gwcis trydydd parti y byddai angen eich cydsyniad.",
|
||||
"privacy.s5_p2_label": "Ni wnawn ddefnyddio:",
|
||||
"privacy.s5_p3_pre": "I gael manylion llawn am y cwcis rydym yn eu gosod, eu henwau, eu hyd, a'u pwrpas, ewch i'n",
|
||||
"privacy.s6_ai_body": "Pan fyddwch yn dechrau OCR neu ddirwyn gwybodaeth fanylion AI, mae data dogfennau yn cael ei drosglwyddo i'r gwasanaeth AI sydd wedi'i ddefnyddio gan chi neu eich gweinyddwr. Mae'r trosglwyddiad hwn yn cael ei reoleiddio gan gytundeb prosesu data gyda'r darparwr priodol.",
|
||||
"privacy.s6_ai_label": "Gwasanaethau Prosesu AI (OpenAI, Azure Document Intelligence, eraill):",
|
||||
"privacy.s6_heading": "6. Gwasanaethau Trydydd Parti",
|
||||
"privacy.s6_no_sale_body": "Ni fyddwn yn gwerthu, rhentu, nac yn rhannu eich data personol gyda thrwyddedau i drydydd parti ar gyfer hysbysebu, marchnata, nac unrhyw ddiben nad yw'n gysylltiedig â rhoi'r gwasanaeth.",
|
||||
"privacy.s6_no_sale_label": "Dim Gwerthiant nac Rhannu ar gyfer Hysbysebu:",
|
||||
"privacy.s6_oauth_body": "Pan ddewiswch ddilysu trwy OAuth, mae'r darparwr perthnasol yn prosesu eich credentals ac efallai y bydd yn rhannu gwybodaeth benodol am eich proffil gyda ni. Mae'r darparwyr hyn yn cynnal eu polisi preifatrwydd eu hunain.",
|
||||
"privacy.s6_oauth_label": "Darparwyr OAuth (Google, Dropbox, Microsoft):",
|
||||
"privacy.s6_storage_body": "Mae dogfennau wedi’u storio yn y darparwr cwmwl rydych chi’n ei ddylunio. Mae eich credentals a gynhelir wedi’u storio yn gyfyngedig yn y gronfa ddata cais ac maent yn cael eu defnyddio yn unig er mwyn gweithredu’r gweithrediadau storio a ofynnwyd.",
|
||||
"privacy.s6_storage_label": "Darparwyr Storio Cwmwl (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):",
|
||||
"privacy.s7_adequacy_body": "lle mae'r Comisiwn Ewropeaidd wedi cydnabod lefel gyffelyb o warchod (e.e., y Deyrnas Unedig, Swis, Canada (sefydliadau masnachol), Japan, De Korea).",
|
||||
"privacy.s7_adequacy_label": "Penderfyniadau Digonoldeb",
|
||||
"privacy.s7_contact": "Gallwch ofyn am gopi o'r diogelwch perthnasol trwy gysylltu â ni yn",
|
||||
"privacy.s7_heading": "7. Trosglwyddiadau Data Rhyngwladol",
|
||||
"privacy.s7_idta_body": "ar gyfer trosglwyddiadau o'r DU ar ôl Brexit.",
|
||||
"privacy.s7_idta_label": "Cytundebau Trosglwyddo Data Rhyngwladol y DU (IDTAs)",
|
||||
"privacy.s7_p1": "Mae DocuElevate wedi'i letya yn yr Undeb Ewropeaidd / EEA yn ddiofyn. Pan drosglwyddir data personol y tu allan i'r EEA (er enghraifft i ddarparwyr gwasanaethau AI sydd wedi'u lleoli yn yr UD fel OpenAI), rydym yn ymddwyn ar ddiogelwch priodol gan gynnwys:",
|
||||
"privacy.s7_scc_body": "a fabwysiadwyd gan y Comisiwn Ewropeaidd (2021/914/EU) ar gyfer trosglwyddiadau i broseswyr a rheolwyr mewn gwledydd trydydd.",
|
||||
"privacy.s7_scc_label": "Clau Contractau Safonol (SCCs)",
|
||||
"privacy.s8_audit_body": "Cadwyd am hyd at 90 diwrnod ar gyfer dibenion diogelwch a chydymffurfio.",
|
||||
"privacy.s8_audit_label": "Cofnodion archwilio:",
|
||||
"privacy.s8_contact": "I ofyn am ddileu eich cyfrif a'r holl ddata personol cysylltiedig, cysylltwch â ni yn",
|
||||
"privacy.s8_files_body": "Cadwyd am gyfnod eich defnydd o'r gwasanaeth. Gallwch ddileu ffeiliau unigol ar unrhyw adeg trwy'r cais.",
|
||||
"privacy.s8_files_label": "Cofnodion ffeiliau a metadata:",
|
||||
"privacy.s8_heading": "8. Cadw Data",
|
||||
"privacy.s8_oauth_body": "Cadwyd yn ffurf wedi'i chyfrif a gellir ei diddymu ar unrhyw adeg trwy eich darparwr OAuth.",
|
||||
"privacy.s8_oauth_label": "Tocynnau OAuth:",
|
||||
"privacy.s8_p1": "Cadwn ddata personol dim ond cyn belled ag sy'n hanfodol yn ystod darparu gwasanaeth DocuElevate neu gydymffurfio â rhwymedigaethau cyfreithiol:",
|
||||
"privacy.s8_session_body": "Dileu pan fyddwch yn ddirwyn neu ar ôl amser aros sesiwn.",
|
||||
"privacy.s8_session_label": "Data sesiwn:",
|
||||
"privacy.s9_heading": "9. Diogelwch Data",
|
||||
"privacy.s9_li1": "Hunaniaeth gyfandirol a sefydliadau sensitif pan fo'n segur.",
|
||||
"privacy.s9_li2": "Diogelwch Haen Gyrchu (TLS/HTTPS) ar gyfer pob cyfathrebu.",
|
||||
"privacy.s9_li3": "Rheolaethau mynediad ar sail rôl sy'n cyfyngu mynediad at ddata personol.",
|
||||
"privacy.s9_li4": "Archwiliadau diogelwch rheolaidd a sganio am fregau dibyniaeth.",
|
||||
"privacy.s9_li5": "Diogelu CSRF ar bob cais sy'n newid statws.",
|
||||
"privacy.s9_p1": "Rydym yn gweithredu mesurau technegol ac sefydliadol priodol (TOMs) i ddiogelu eich data personol, gan gynnwys:",
|
||||
"privacy.toc_1": "Rheolwr Data",
|
||||
"privacy.toc_10": "Eich Hawliau (EU / EEA / DU / Gwlad yr Iâ)",
|
||||
"privacy.toc_11": "Hawliau Lleoliadol – Unol Daleithiau (CCPA/CPRA)",
|
||||
"privacy.toc_12": "Hawliau Lleoliadol – Canada (PIPEDA / Deddf 25)",
|
||||
"privacy.toc_13": "Hawliau Lleoliadol – America Ladin (LGPD ac eraill)",
|
||||
"privacy.toc_14": "Hawliau Lleoliadol – Asia-Paciif a Japan",
|
||||
"privacy.toc_15": "Hawliau Lleoliadol – Cymru",
|
||||
"privacy.toc_16": "Diweddariadau i'r Hysbysiad Preifatrwydd hwn",
|
||||
"privacy.toc_2": "Cefndir i'r Hysbysiad Preifatrwydd hwn",
|
||||
"privacy.toc_3": "Cysylltu Data a Phwrpasau",
|
||||
"privacy.toc_4": "Arian Data a Chyfyngiad Pwrpas",
|
||||
"privacy.toc_5": "Defnydd o Gwci a Thechnolegau Tebyg",
|
||||
"privacy.toc_6": "Gwasanaethau Trydydd Parti",
|
||||
"privacy.toc_7": "Trodiadau Data Rhyngwladol",
|
||||
"privacy.toc_8": "Cadw Data",
|
||||
"privacy.toc_9": "Diogelwch Data",
|
||||
"privacy.toc_heading": "Cynnwys",
|
||||
"profile.avatar_alt": "Eich llun proffil",
|
||||
"profile.avatar_heading": "Llun Proffil",
|
||||
"profile.avatar_remove": "Dileu llun proffil arferol",
|
||||
@@ -838,6 +1377,9 @@
|
||||
"profile.contact_email_label": "E-bost Cyswllt / Hysbysiad",
|
||||
"profile.contact_email_placeholder": "chi@example.com",
|
||||
"profile.current_password": "Cyfrinair Cyfredol",
|
||||
"profile.default_document_language_auto": "Defnyddiwch y system ddaeth yn flaenoriaeth",
|
||||
"profile.default_document_language_hint": "Mae dogfennau mewn ieithoedd eraill yn cael eu cyfieithu'n awtomatig i'r iaith hon. Gadewch yn wag i ddefnyddio'r iaith leol (Saesneg).",
|
||||
"profile.default_document_language_label": "Iaith Dogfen Ddiffyg",
|
||||
"profile.dismiss": "Gwrthod",
|
||||
"profile.display_name_hint": "Gadewch yn wag i ddefnyddio enw defnyddiwr eich cyfrif neu e-bost.",
|
||||
"profile.display_name_label": "Enw arddangos",
|
||||
@@ -1040,14 +1582,66 @@
|
||||
"similarity.subtitle": "Parau o ddogfennau gyda thebygolrwydd semantig uchel, wedi eu graddio gan sgôr.",
|
||||
"similarity.trigger_aria": "Dyna gyfrifiad embedding ar gyfer pob ffeil sydd heb embeddings",
|
||||
"similarity.trigger_now": "dyna fe nawr",
|
||||
"status.active": "Broses",
|
||||
"status.ai_empty_response": "(gwag)",
|
||||
"status.ai_extraction_desc": "Gosodwch gynnwys testun gwahanu dogfen isod a rhedeg ef trwy'r ddarparwr AI wedi'i gosod i archwilio'r ymateb crai, JSON wedi'i ddadansoddi, a thagiau.",
|
||||
"status.ai_extraction_failed": "Methiant Ddadansoddi AI",
|
||||
"status.ai_extraction_label": "Testun Dogfen",
|
||||
"status.ai_extraction_placeholder": "Gosodwch gynnwys testun gwahanu eich dogfen yma\u00115...",
|
||||
"status.ai_extraction_title": "Prawf Ddadansoddi AI",
|
||||
"status.app_version": "Fersiwn App",
|
||||
"status.as_account": "fel",
|
||||
"status.auth_required": "Mae mynediad wedi'i ofyn",
|
||||
"status.build_date": "Dyddiad Adeiladu",
|
||||
"status.config_settings": "Gosodiadau Configuraeth",
|
||||
"status.config_settings_desc": "Am ragor o fanylion am osodiadau configuraeth a newidynnau amgylchedd, gwirio'r dudalen gosodiadau.",
|
||||
"status.configure_now": "Gosodwch Nawr",
|
||||
"status.configured": "Wedi'i Fowndio",
|
||||
"status.connection_error": "Gwall Cysylltu",
|
||||
"status.connection_test_failed": "Methiant Prawf Cysylltu",
|
||||
"status.connection_test_successful": "Profion Cysylltiad Llwyddiannus",
|
||||
"status.container_id": "ID Cuddian",
|
||||
"status.container_started": "Cynhwysydd Wedi'i Dechrau",
|
||||
"status.dashboard_subtitle": "Mae'r dangosfwrdd hwn yn dangos statws pob integreiddiad a tharged a sefydlwyd.",
|
||||
"status.debug_mode": "Modd Dadfygio",
|
||||
"status.error_running_extraction": "Gwall wrth redeg allgludo: ",
|
||||
"status.error_testing_connection": "Gwall wrth brofion cysylltiad: ",
|
||||
"status.error_testing_notifications": "Gwall wrth brofion hysbysiadau: ",
|
||||
"status.extracted_tags": "Tagiau wedi'u Hallygu",
|
||||
"status.git_commit": "Git Commit",
|
||||
"status.inactive": "Anweithredol",
|
||||
"status.json_parse_issue": "Mater parsiad JSON: ",
|
||||
"status.last_check": "Ysgol Reoli Diweddaraf",
|
||||
"status.manage": "Rheoli",
|
||||
"status.modal_default_message": "Mae'r weithred wedi'i chwblhau'n llwyddiannus.",
|
||||
"status.modal_default_title": "Llwyddiant",
|
||||
"status.no_details": "Dim manylion ar gael",
|
||||
"status.not_configured": "Ansawdd",
|
||||
"status.notification_config_missing": "Methodd Gydmchwarae Hysbysiad",
|
||||
"status.open": "Agor",
|
||||
"status.page_title": "Statws y System",
|
||||
"status.parsed_json_label": "JSON wedi'i Barso",
|
||||
"status.provider_config_details": "Manylion Cydmhwysiad {name}",
|
||||
"status.provider_details": "Manylion Darparwr",
|
||||
"status.raw_llm_response": "Ymchwiliad LLM Crai",
|
||||
"status.run_extraction": "Redwch Allgludo",
|
||||
"status.running": "Yn Rhedeg…",
|
||||
"status.sending": "Anfon...",
|
||||
"status.setting_label": "Gosodiad",
|
||||
"status.test_connection": "Profion Cysylltiad",
|
||||
"status.test_extraction": "Profion Allgludo",
|
||||
"status.test_failed": "Profion wedi methu",
|
||||
"status.test_notification_failed": "Profion Hysbysiad wedi methu",
|
||||
"status.test_notification_sent": "Hysbysiad Profion wedi'i Anfon",
|
||||
"status.test_notifications": "Profion Hysbysiadau",
|
||||
"status.test_provider": "Profion {name}",
|
||||
"status.test_successful": "Profion Llwyddiannus",
|
||||
"status.testing": "Profi...",
|
||||
"status.token_expired": "Mae eich tocyn wedi drehau neu'n annilys. Os gwelwch yn dda, ail sefydlu'r cysylltiad hwn.",
|
||||
"status.token_valid_for": "Tocyn yn ddilys am:",
|
||||
"status.value_label": "Gwerth",
|
||||
"status.view_config": "Gweld Gwybodaeth Fanwl",
|
||||
"status.view_details": "Gweld Manylion",
|
||||
"subscription.available_plans_heading": "Cynlluniau ArGael",
|
||||
"subscription.back_to_dashboard": "Yn ôl i'r Dashboard",
|
||||
"subscription.cancel_pending": "Diddymu newid",
|
||||
@@ -1083,6 +1677,51 @@
|
||||
"subscription.upgrade_info": "Mae cynnydd yn dod i rym ar unwaith. Mae bleychi yn cael eu cynllunio ar gyfer diwedd eich cyfnod bilio presennol.",
|
||||
"subscription.upgrade_to_prefix": "Cynnydd i",
|
||||
"subscription.usage_heading": "Defnydd",
|
||||
"terms.cookie_link": "Polisi Cwcis",
|
||||
"terms.heading": "Telerau Gwasanaeth",
|
||||
"terms.last_updated": "Diweddarwyd Diweddar:",
|
||||
"terms.license_link": "Gwybodaeth Trwydded",
|
||||
"terms.page_title": "Telerau Gwasanaeth - DocuElevate",
|
||||
"terms.privacy_link": "Polisi Preifatrwydd",
|
||||
"terms.s1_heading": "1. Derbyn Telerau",
|
||||
"terms.s1_p1": "Trwy fynd i mewn neu ddefnyddio DocuElevate, rydych yn agreeing i fod yn gorfod gan y Telerau Gwasanaeth hyn. Os na ydych yn cytuno i'r telerau hyn, os gwelwch yn dda peidiwch â defnyddio'r gwasanaeth hwn.",
|
||||
"terms.s2_heading": "2. Disgrifiad o'r Gwasanaeth",
|
||||
"terms.s2_p1": "Mae DocuElevate yn darparu prosesu dogfennau, OCR, dadansoddiad metaddata, a gwasanaethau storio. Rydym yn cadw'r hawl i newid neu ddihirio unrhyw agwedd ar y gwasanaeth unrhyw bryd.",
|
||||
"terms.s3_heading": "3. Cyfrifoldebau Defnyddiwr",
|
||||
"terms.s3_li1": "Pob cynnwys rydych yn ei uwchlwytho i DocuElevate",
|
||||
"terms.s3_li2": "Cydnabod bod gennych hawliau priodol i uwchlwytho a phrosesu dogfennau",
|
||||
"terms.s3_li3": "Cynnal cyfrinachedd eich cyfrif mynediad",
|
||||
"terms.s3_li4": "Unrhyw weithgaredd sy'n digwydd o dan eich cyfrif",
|
||||
"terms.s3_p1": "Rydych yn gyfrifol am:",
|
||||
"terms.s3_p2_and": "a",
|
||||
"terms.s3_p2_post": ".",
|
||||
"terms.s3_p2_pre": "Trwy ddefnyddio ein gwasanaeth, rydych hefyd yn agreeing i'n",
|
||||
"terms.s4_heading": "4. Hawliau Eiddo Deallusol",
|
||||
"terms.s4_p1": "Mae DocuElevate yn parchu hawliau eiddo deallusol. Ni all defnyddwyr uwchlwytho cynnwys sy'n tramgwyddo hawliau eiddo deallusol eraill.",
|
||||
"terms.s5_heading": "5. Cyfyngiad ar Atebolrwydd",
|
||||
"terms.s5_p1": "Mae DocuElevate yn darparu'r gwasanaeth \"fel y mae\" heb warantau o unrhyw fath. Ni fyddwn yn atebol am unrhyw ddifrod uniongyrchol, anuniongyrchol, damweiniol, arbennig, dilynol, nac ymosodol sy'n deillio o'ch defnyddio neu ddiffyg gallu i ddefnyddio'r gwasanaeth.",
|
||||
"terms.s6_heading": "6. Cyfraith Rheoli",
|
||||
"terms.s6_p1": "Bydd y Telerau hyn yn cael eu rheoli gan gyfreithiau yr Almaen, heb ystyried ei ddarpariaethau gwrthdaro cyfreithiol.",
|
||||
"terms.s6_p2_post": ".",
|
||||
"terms.s6_p2_pre": "Os oes gennych unrhyw gwestiynau am y Telerau hyn, cysylltwch â ni yn",
|
||||
"terms.s6_p3_mid": ". Am wybodaeth drwyddedu, os gwelwch yn dda cyfeiriwch at ein",
|
||||
"terms.s6_p3_post": ".",
|
||||
"terms.s6_p3_pre": "Am wybodaeth am sut rydym yn defnyddio cwcis, gweler ein",
|
||||
"translation.copied": "Copiau!",
|
||||
"translation.copy": "Copi",
|
||||
"translation.default_language_version": "Fersiwn Iaith Ddiffyg",
|
||||
"translation.detected_language": "Iaith a ganfuwyd",
|
||||
"translation.hide_text": "Cuddio testun",
|
||||
"translation.load_translation": "Llwytho cyfieithiad",
|
||||
"translation.no_translation": "Nid oes cyfieithiad ar gael eto \u0000 efallai ei fod yn dal i brosesu",
|
||||
"translation.select_language": "Dewis iaith\u0000",
|
||||
"translation.select_target": "Dewiswch iaith darged, os gwelwch yn dda.",
|
||||
"translation.show_text": "Dangos testun",
|
||||
"translation.translate_btn": "Cyfieithu",
|
||||
"translation.translate_to": "Cyfieithwch i iaith arall",
|
||||
"translation.translated_to": "Cyfieithwyd i",
|
||||
"translation.translating": "Cyfieithu\u0000",
|
||||
"translation.translation_failed": "Methwyd â chyfieithu",
|
||||
"upload.browse_button": "Pori Ffeiliau",
|
||||
"upload.button_processing": "Y broses...",
|
||||
"upload.camera_button": "Cymryd Llun / Sganio Dogfen",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user