feat(translate): add translation cache with source_hash dedup + enforce dictionary post-processing
- Add source_hash column (SHA256 of source_text+source_data+dict_hash+config_hash) to TranslationRecord for cache dedup - Add Alembic migration b1c2d3e4f5a6 for the new column and composite index (source_hash, status) - Implement _compute_source_hash / _check_translation_cache in executor.py — before LLM call, check if same source+dict+config combo was already translated successfully - Implement cache-aware routing in _process_batch: full cache hit → skip LLM, partial hit → route to LLM - Store source_hash on all new TranslationRecord rows for future cache hits - Strengthen dictionary prompt instructions from 'use when applicable' to 'MUST use — mandatory' - Add _enforce_dictionary() post-processing: force-replace dictionary terms in LLM output if LLM ignored them - Fix CRITICAL: use run.dict_snapshot_hash/config_hash (exist on TranslationRun, not TranslationJob) - Fix MAJOR: verify cached languages cover ALL target_languages before accepting cache hit - Fix MAJOR: prevent regex back-reference injection in dictionary enforcement (use lambda) - Fix: add joinedload to cache lookup to avoid N+1 queries - Fix: remove redundant single-column index (composite index is sufficient) - Fix(frontend): parse paginated /translate/dictionaries response correctly (result.items)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
@@ -14,6 +15,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Allow overriding sqlalchemy.url via DATABASE_URL env var
|
||||
if "DATABASE_URL" in os.environ:
|
||||
config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Add source_hash column to translation_records for cache dedup
|
||||
|
||||
Adds source_hash (SHA256 of source_text + source_data + dict/config hashes)
|
||||
enabling cache-hit lookups: if the same source row + dictionary + config
|
||||
combination has already been successfully translated, the LLM call is skipped.
|
||||
|
||||
Revision ID: b1c2d3e4f5a6
|
||||
Revises: aa1b2c3d4e5f
|
||||
Create Date: 2026-05-15 23:00:00.000000
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b1c2d3e4f5a6"
|
||||
down_revision: str | Sequence[str] | None = "aa1b2c3d4e5f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add source_hash column with indices."""
|
||||
bind = op.get_bind()
|
||||
if bind.engine.name == "sqlite":
|
||||
# SQLite — check existence first
|
||||
inspector = sa.inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns("translation_records")]
|
||||
if "source_hash" not in columns:
|
||||
op.add_column("translation_records",
|
||||
sa.Column("source_hash", sa.String(), nullable=True,
|
||||
comment="SHA256(source_text+source_data+dict_snapshot_hash+config_hash) for cache dedup"))
|
||||
op.create_index("ix_translation_records_source_hash_status",
|
||||
"translation_records", ["source_hash", "status"])
|
||||
else:
|
||||
# PostgreSQL and others
|
||||
op.add_column("translation_records",
|
||||
sa.Column("source_hash", sa.String(), nullable=True,
|
||||
comment="SHA256(source_text+source_data+dict_snapshot_hash+config_hash) for cache dedup"))
|
||||
op.create_index("ix_translation_records_source_hash_status",
|
||||
"translation_records", ["source_hash", "status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop source_hash column and index."""
|
||||
bind = op.get_bind()
|
||||
if bind.engine.name == "sqlite":
|
||||
inspector = sa.inspect(bind)
|
||||
columns = [c["name"] for c in inspector.get_columns("translation_records")]
|
||||
if "source_hash" in columns:
|
||||
op.drop_index("ix_translation_records_source_hash_status",
|
||||
table_name="translation_records")
|
||||
op.drop_column("translation_records", "source_hash")
|
||||
else:
|
||||
op.drop_index("ix_translation_records_source_hash_status",
|
||||
table_name="translation_records")
|
||||
op.drop_column("translation_records", "source_hash")
|
||||
@@ -132,12 +132,15 @@ class TranslationRecord(Base):
|
||||
token_count_input = Column(Integer, nullable=True)
|
||||
token_count_output = Column(Integer, nullable=True)
|
||||
translation_duration_ms = Column(Integer, nullable=True)
|
||||
source_hash = Column(String, nullable=True,
|
||||
comment="SHA256(source_text+source_data+dict_snapshot_hash+config_hash) for cache dedup")
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
|
||||
|
||||
languages = relationship("TranslationLanguage", back_populates="record")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_translation_records_run_status", "run_id", "status"),
|
||||
Index("ix_translation_records_source_hash_status", "source_hash", "status"),
|
||||
)
|
||||
# #endregion TranslationRecord
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
# @RATIONALE: Batch processing with retry — independent batches allow partial recovery.
|
||||
# @REJECTED: Single monolithic LLM call — would lose all progress on any failure.
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
@@ -56,6 +58,127 @@ MAX_RETRIES_PER_BATCH = 3
|
||||
MAX_ROWS_PER_RUN = 10000
|
||||
# #endregion MAX_ROWS_PER_RUN
|
||||
|
||||
# #region _enforce_dictionary [TYPE Function]
|
||||
# @BRIEF Post-processing: enforce dictionary entries in LLM output.
|
||||
# If a source term from the dictionary appears in the original text,
|
||||
# but the target term is missing from the translation, replace occurrences
|
||||
# of the source term in the translated output with the target term.
|
||||
# @PRE: dict_matches is a list of dict entries with source_term/target_term.
|
||||
# @POST: per_lang_values may be mutated to include forced dictionary replacements.
|
||||
# @SIDE_EFFECT: Logs when enforcement is applied.
|
||||
def _enforce_dictionary(
|
||||
source_text: str,
|
||||
per_lang_values: dict[str, str],
|
||||
dict_matches: list[dict[str, Any]],
|
||||
batch_id: str,
|
||||
row_id: str,
|
||||
) -> None:
|
||||
"""Post-processing: enforce dictionary terms in LLM translation output.
|
||||
|
||||
For each dictionary entry whose source_term appears in the source_text,
|
||||
verify that the target_term is present in each language's translation.
|
||||
If missing, replace any occurrence of the source term (which the LLM
|
||||
may have left untranslated) with the dictionary target term.
|
||||
"""
|
||||
if not dict_matches or not source_text:
|
||||
return
|
||||
|
||||
text_lower = source_text.lower()
|
||||
|
||||
for dm in dict_matches:
|
||||
src_term = dm.get("source_term", "")
|
||||
tgt_term = dm.get("target_term", "")
|
||||
if not src_term or not tgt_term:
|
||||
continue
|
||||
|
||||
# Only process if source term actually appears in the original text
|
||||
if src_term.lower() not in text_lower:
|
||||
continue
|
||||
|
||||
# Try to replace in each language
|
||||
for lang_code in list(per_lang_values.keys()):
|
||||
val = per_lang_values[lang_code]
|
||||
if not val:
|
||||
continue
|
||||
|
||||
# Check if target term is already present (case-insensitive)
|
||||
if tgt_term.lower() in val.lower():
|
||||
continue
|
||||
|
||||
# Try to find the source term (or a close variant) in the output
|
||||
# This catches cases where the LLM left the source term untranslated
|
||||
# Use lambda to prevent regex back-reference injection from tgt_term
|
||||
src_pattern = re.compile(re.escape(src_term), re.IGNORECASE)
|
||||
if src_pattern.search(val):
|
||||
new_val = src_pattern.sub(lambda _: tgt_term, val)
|
||||
if new_val != val:
|
||||
logger.reason("Dictionary enforcement applied", {
|
||||
"batch_id": batch_id,
|
||||
"row_id": row_id,
|
||||
"language_code": lang_code,
|
||||
"source_term": src_term,
|
||||
"target_term": tgt_term,
|
||||
"before": val[:200],
|
||||
"after": new_val[:200],
|
||||
})
|
||||
per_lang_values[lang_code] = new_val
|
||||
# #endregion _enforce_dictionary
|
||||
|
||||
|
||||
# #region _compute_source_hash [TYPE Function]
|
||||
# @BRIEF Compute deterministic cache key for a source row.
|
||||
# SHA256 of (source_text + source_data + dict_config_ctx) so that changing
|
||||
# the text, context, dictionary snapshot, or job config produces a new key.
|
||||
def _compute_source_hash(
|
||||
source_text: str,
|
||||
source_data: dict | None,
|
||||
dict_snapshot_hash: str | None,
|
||||
config_hash: str | None,
|
||||
) -> str:
|
||||
"""Deterministic cache key for a translation source row."""
|
||||
payload = json.dumps({
|
||||
"text": source_text,
|
||||
"data": source_data,
|
||||
"dict_hash": dict_snapshot_hash or "",
|
||||
"config_hash": config_hash or "",
|
||||
}, sort_keys=True, ensure_ascii=False)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
# #endregion _compute_source_hash
|
||||
|
||||
|
||||
# #region _check_translation_cache [TYPE Function]
|
||||
# @BRIEF Look up a previously successful translation by source_hash.
|
||||
# Returns per-language dict {lang_code: final_value} or None.
|
||||
def _check_translation_cache(
|
||||
db: Session,
|
||||
source_hash: str,
|
||||
) -> dict[str, str] | None:
|
||||
"""Check if this source_hash was already translated successfully."""
|
||||
from sqlalchemy.orm import joinedload
|
||||
from ...models.translate import TranslationRecord, TranslationLanguage
|
||||
|
||||
cached = (
|
||||
db.query(TranslationRecord)
|
||||
.options(joinedload(TranslationRecord.languages))
|
||||
.filter(
|
||||
TranslationRecord.source_hash == source_hash,
|
||||
TranslationRecord.status == "SUCCESS",
|
||||
)
|
||||
.order_by(TranslationRecord.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if not cached:
|
||||
return None
|
||||
|
||||
lang_values: dict[str, str] = {}
|
||||
for lang in cached.languages:
|
||||
if lang.status == "translated" and lang.final_value:
|
||||
lang_values[lang.language_code] = lang.final_value
|
||||
|
||||
return lang_values if lang_values else None
|
||||
# #endregion _check_translation_cache
|
||||
|
||||
|
||||
# #region TranslationExecutor [C:4] [TYPE Class]
|
||||
# @BRIEF Process translation batches: fetch source rows, filter dict, call LLM, persist results.
|
||||
# @PRE: DB session and config manager available.
|
||||
@@ -161,6 +284,8 @@ class TranslationExecutor:
|
||||
run_id=run.id,
|
||||
batch_index=batch_idx,
|
||||
batch_rows=batch_rows,
|
||||
dict_snapshot_hash=run.dict_snapshot_hash,
|
||||
config_hash=run.config_hash,
|
||||
)
|
||||
successful_records += batch_result["successful"]
|
||||
failed_records += batch_result["failed"]
|
||||
@@ -551,6 +676,8 @@ class TranslationExecutor:
|
||||
run_id: str,
|
||||
batch_index: int,
|
||||
batch_rows: list[dict[str, Any]],
|
||||
dict_snapshot_hash: str | None = None,
|
||||
config_hash: str | None = None,
|
||||
) -> dict[str, int]:
|
||||
with belief_scope("TranslationExecutor._process_batch"):
|
||||
batch_start = time.monotonic()
|
||||
@@ -584,7 +711,28 @@ class TranslationExecutor:
|
||||
row_context=row_context,
|
||||
)
|
||||
|
||||
# For each row, determine if we need LLM translation or can use approved/preview edit
|
||||
# ── Translation cache check: skip LLM for rows translated before ──
|
||||
for row in batch_rows:
|
||||
if row.get("approved_translation"):
|
||||
continue
|
||||
source_text = row.get("source_text", "")
|
||||
if not source_text:
|
||||
continue
|
||||
source_data = row.get("source_data")
|
||||
source_hash = _compute_source_hash(
|
||||
source_text, source_data,
|
||||
dict_snapshot_hash, config_hash,
|
||||
)
|
||||
row["_source_hash"] = source_hash
|
||||
cached = _check_translation_cache(self.db, source_hash)
|
||||
if cached:
|
||||
row["_cached_lang_values"] = cached
|
||||
logger.reason("Translation cache hit", {
|
||||
"source_hash": source_hash[:12],
|
||||
"langs": list(cached.keys()),
|
||||
})
|
||||
|
||||
# For each row, determine if we need LLM translation or can use approved/preview edit/cache
|
||||
rows_for_llm = []
|
||||
pre_translated = []
|
||||
|
||||
@@ -593,6 +741,26 @@ class TranslationExecutor:
|
||||
pre_translated.append(row)
|
||||
continue
|
||||
|
||||
# Translation cache hit → treat as pre-translated
|
||||
# BUT only if cached langs cover ALL target languages
|
||||
cached_langs = row.get("_cached_lang_values")
|
||||
if cached_langs:
|
||||
target_langs_for_check = job.target_languages or [job.target_dialect or "en"]
|
||||
if not isinstance(target_langs_for_check, list):
|
||||
target_langs_for_check = [str(target_langs_for_check)]
|
||||
cached_covers_all = all(
|
||||
lang_code in cached_langs
|
||||
for lang_code in target_langs_for_check
|
||||
)
|
||||
if cached_covers_all:
|
||||
pre_translated.append(row)
|
||||
continue
|
||||
else:
|
||||
logger.reason("Partial cache hit — missing languages, routing to LLM", {
|
||||
"cached_langs": list(cached_langs.keys()),
|
||||
"target_langs": target_langs_for_check,
|
||||
})
|
||||
|
||||
# Check for preview edits carry-forward
|
||||
source_data = row.get("source_data") or {}
|
||||
if source_data and self._preview_edits_cache:
|
||||
@@ -618,29 +786,35 @@ class TranslationExecutor:
|
||||
target_languages = [str(target_languages)]
|
||||
|
||||
for row in pre_translated:
|
||||
cached_langs = row.get("_cached_lang_values") # None for approved, dict for cache hits
|
||||
record = TranslationRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
batch_id=batch_id,
|
||||
run_id=run_id,
|
||||
source_sql=row.get("source_text", ""),
|
||||
target_sql=row.get("approved_translation"),
|
||||
target_sql=row.get("approved_translation", ""),
|
||||
source_object_type="table_row",
|
||||
source_object_id=row.get("row_index"),
|
||||
source_object_name=row.get("source_object_name", ""),
|
||||
source_data=row.get("source_data"),
|
||||
source_hash=row.get("_source_hash"),
|
||||
status="SUCCESS",
|
||||
)
|
||||
self.db.add(record)
|
||||
|
||||
# Create per-language entry for each target language (pre-approved)
|
||||
# Create per-language entry for each target language
|
||||
for lang_code in target_languages:
|
||||
if cached_langs and lang_code in cached_langs:
|
||||
final_val = cached_langs[lang_code]
|
||||
else:
|
||||
final_val = row.get("approved_translation", "")
|
||||
lang_entry = TranslationLanguage(
|
||||
id=str(uuid.uuid4()),
|
||||
record_id=record.id,
|
||||
language_code=lang_code,
|
||||
source_language_detected="und",
|
||||
translated_value=row.get("approved_translation"),
|
||||
final_value=row.get("approved_translation"),
|
||||
translated_value=final_val,
|
||||
final_value=final_val,
|
||||
status="translated",
|
||||
needs_review=False,
|
||||
)
|
||||
@@ -1130,6 +1304,14 @@ class TranslationExecutor:
|
||||
per_lang_values[target_languages[0]] = translation_text
|
||||
has_any_translation = True
|
||||
|
||||
# ── Dictionary post-processing enforcement ──
|
||||
# If a dictionary entry matches the source text but the target term
|
||||
# is missing from the LLM output, apply forced replacement.
|
||||
if dict_matches and source_text:
|
||||
_enforce_dictionary(source_text, per_lang_values, dict_matches,
|
||||
batch_id, row_id)
|
||||
# ── End dictionary enforcement ──
|
||||
|
||||
if not has_any_translation:
|
||||
# Empty/all-empty translations — skip
|
||||
skipped += 1
|
||||
@@ -1163,6 +1345,7 @@ class TranslationExecutor:
|
||||
source_object_id=row.get("row_index"),
|
||||
source_object_name=row.get("source_object_name", ""),
|
||||
source_data=row.get("source_data"),
|
||||
source_hash=row.get("_source_hash"),
|
||||
status="SUCCESS",
|
||||
)
|
||||
self.db.add(record)
|
||||
|
||||
@@ -49,6 +49,10 @@ DEFAULT_EXECUTION_PROMPT_TEMPLATE: str = (
|
||||
"Target dialect(s): {target_dialect}\n"
|
||||
"Column to translate: {translation_column}\n\n"
|
||||
"{dictionary_section}"
|
||||
"IMPORTANT — You MUST use the terminology dictionary above. "
|
||||
"For any source term listed in the dictionary, you MUST use its exact target translation. "
|
||||
"Do not translate dictionary terms differently. "
|
||||
"This is mandatory, not optional.\n\n"
|
||||
"For each row, provide an accurate translation of the text into each target language.\n\n"
|
||||
"Rows to translate:\n{rows_json}\n\n"
|
||||
"Respond with a JSON object in this exact format:\n"
|
||||
@@ -67,6 +71,10 @@ DEFAULT_PREVIEW_PROMPT_TEMPLATE: str = (
|
||||
"Source dialect: {source_dialect}\n"
|
||||
"Column to translate: {translation_column}\n\n"
|
||||
"{dictionary_section}"
|
||||
"IMPORTANT — You MUST use the terminology dictionary above. "
|
||||
"For any source term listed in the dictionary, you MUST use its exact target translation. "
|
||||
"Do not translate dictionary terms differently. "
|
||||
"This is mandatory, not optional.\n\n"
|
||||
"Translate to the following languages: {target_languages}\n\n"
|
||||
"For each row, provide an accurate translation of the '{translation_column}' value into each language.\n"
|
||||
"Consider the context columns when determining the meaning of the text.\n\n"
|
||||
|
||||
@@ -81,12 +81,17 @@
|
||||
admin: "nav.admin",
|
||||
settings: "nav.settings",
|
||||
git: "nav.git",
|
||||
translate: "translate.jobs.title",
|
||||
};
|
||||
|
||||
if (specialCases[segment]) {
|
||||
return _(specialCases[segment]) || segment;
|
||||
}
|
||||
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
|
||||
return _('translate.config.breadcrumb_job') || 'Job';
|
||||
}
|
||||
|
||||
return segment
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
let nextExecutions = $state([]);
|
||||
let hasPriorRun = $state(true);
|
||||
let scheduleExists = $state(false);
|
||||
let isEditing = $derived(uxState === 'editing');
|
||||
|
||||
$effect(() => {
|
||||
if (jobId) loadSchedule();
|
||||
@@ -190,11 +191,13 @@
|
||||
type="text"
|
||||
bind:value={cronExpression}
|
||||
placeholder="0 2 * * *"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500"
|
||||
disabled={!isEditing}
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500"
|
||||
/>
|
||||
<select
|
||||
bind:value={cronExpression}
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
disabled={!isEditing}
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg text-sm disabled:bg-gray-50 disabled:text-gray-500"
|
||||
>
|
||||
<option value="0 2 * * *">{$t.translate?.schedule?.preset_daily || 'Daily at 02:00'}</option>
|
||||
<option value="0 */6 * * *">{$t.translate?.schedule?.preset_every_6h || 'Every 6 hours'}</option>
|
||||
@@ -213,7 +216,8 @@
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.schedule?.timezone || 'Timezone'}</label>
|
||||
<select
|
||||
bind:value={timezone}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
disabled={!isEditing}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm disabled:bg-gray-50 disabled:text-gray-500"
|
||||
>
|
||||
{#each commonTimezones as tz}
|
||||
<option value={tz}>{tz}</option>
|
||||
@@ -226,15 +230,15 @@
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.schedule?.execution_mode || 'Execution Mode'}</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<input type="radio" bind:group={executionMode} value="full" class="sr-only peer" />
|
||||
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400">
|
||||
<input type="radio" bind:group={executionMode} value="full" class="sr-only peer" disabled={!isEditing} />
|
||||
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400 {isEditing ? '' : 'opacity-70'}">
|
||||
<div class="font-medium">{$t.translate?.schedule?.mode_full || 'Full'}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">{$t.translate?.schedule?.mode_full_desc || 'Translate all rows every run'}</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<input type="radio" bind:group={executionMode} value="new_key_only" class="sr-only peer" />
|
||||
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400">
|
||||
<input type="radio" bind:group={executionMode} value="new_key_only" class="sr-only peer" disabled={!isEditing} />
|
||||
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400 {isEditing ? '' : 'opacity-70'}">
|
||||
<div class="font-medium">{$t.translate?.schedule?.mode_new_keys || 'New Keys Only'}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">{$t.translate?.schedule?.mode_new_keys_desc || 'Only translate rows with new key values'}</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,15 @@
|
||||
let pendingCount = $derived(records.filter(r => r.status === 'PENDING').length);
|
||||
let isAllApproved = $derived(pendingCount === 0 && records.length > 0);
|
||||
|
||||
function getRowStatusLabel(status) {
|
||||
const labels = {
|
||||
APPROVED: $t.translate?.preview?.status_approved || 'Approved',
|
||||
REJECTED: $t.translate?.preview?.status_rejected || 'Rejected',
|
||||
PENDING: $t.translate?.preview?.status_pending || 'Pending',
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
/** Get language status for a record and language code */
|
||||
function getLangStatus(record, langCode) {
|
||||
const lang = (record.languages || []).find(l => l.language_code === langCode);
|
||||
@@ -131,6 +140,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} rowId @param {string} langCode */
|
||||
async function handleApproveLanguage(rowId, langCode) {
|
||||
try {
|
||||
const updated = await approveRow(jobId, rowId, langCode);
|
||||
records = records.map(r => r.id === rowId ? {
|
||||
...r,
|
||||
status: updated.status || r.status,
|
||||
target_sql: updated.target_sql || r.target_sql,
|
||||
languages: updated.languages || r.languages,
|
||||
_isEdited: false
|
||||
} : r);
|
||||
} catch (err) {
|
||||
addToast(err?.message || $t.translate?.preview?.approve_failed, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} rowId @param {string} langCode */
|
||||
async function handleRejectLanguage(rowId, langCode) {
|
||||
try {
|
||||
const updated = await rejectRow(jobId, rowId, langCode);
|
||||
records = records.map(r => r.id === rowId ? {
|
||||
...r,
|
||||
status: updated.status || r.status,
|
||||
languages: updated.languages || r.languages,
|
||||
_isEdited: false
|
||||
} : r);
|
||||
} catch (err) {
|
||||
addToast(err?.message || $t.translate?.preview?.reject_failed, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} rowId @param {string} langCode */
|
||||
function startLangEdit(rowId, langCode) {
|
||||
const key = `${rowId}_${langCode}`;
|
||||
@@ -427,7 +467,7 @@
|
||||
<div class="flex gap-1 mt-1 flex-wrap">
|
||||
{#if langStatus !== 'approved' && langStatus !== 'edited'}
|
||||
<button
|
||||
onclick={() => approveRow(jobId, row.id, lang)}
|
||||
onclick={() => handleApproveLanguage(row.id, lang)}
|
||||
class="px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors"
|
||||
title={$t.translate?.preview?.approve}
|
||||
>
|
||||
@@ -443,7 +483,7 @@
|
||||
</button>
|
||||
{#if langStatus !== 'rejected'}
|
||||
<button
|
||||
onclick={() => rejectRow(jobId, row.id, lang)}
|
||||
onclick={() => handleRejectLanguage(row.id, lang)}
|
||||
class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
|
||||
title={$t.translate?.preview?.reject}
|
||||
>
|
||||
@@ -461,7 +501,7 @@
|
||||
{row.status === 'REJECTED' ? 'bg-red-100 text-red-700' : ''}
|
||||
{row.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : ''}
|
||||
">
|
||||
{row.status}
|
||||
{getRowStatusLabel(row.status)}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
{#if row.status !== 'APPROVED'}
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
import CorrectionCell from './CorrectionCell.svelte';
|
||||
import TermCorrectionPopup from './TermCorrectionPopup.svelte';
|
||||
import BulkReplaceModal from './BulkReplaceModal.svelte';
|
||||
import BulkCorrectionSidebar from './BulkCorrectionSidebar.svelte';
|
||||
|
||||
/** @type {{ runId: string, onRefresh: Function }} */
|
||||
let { runId, onRefresh = () => {} } = $props();
|
||||
@@ -65,7 +64,17 @@
|
||||
let targetLanguages = $state([]);
|
||||
let correctionPopupData = $state(null);
|
||||
let showBulkReplace = $state(false);
|
||||
let bulkCorrectionItems = $state([]);
|
||||
|
||||
function getRunStatusLabel(value) {
|
||||
const labels = {
|
||||
COMPLETED: $t.translate?.run?.completed || 'Completed',
|
||||
FAILED: $t.translate?.run?.translation_failed || 'Failed',
|
||||
RUNNING: $t.translate?.history?.status_running || 'Running',
|
||||
PENDING: $t.translate?.history?.status_pending || 'Pending',
|
||||
CANCELLED: $t.translate?.run?.cancelled || 'Cancelled',
|
||||
};
|
||||
return labels[value] || value;
|
||||
}
|
||||
|
||||
// Derived
|
||||
let insertStatusBadge = $derived.by(() => {
|
||||
@@ -211,12 +220,6 @@
|
||||
{isRetryingInsert ? $t.translate?.run?.retrying_insert : $t.translate?.run?.retry_insert}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => showBulkReplace = true}
|
||||
class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
{$t.translate?.run?.bulk_replace || 'Bulk Replace'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -278,6 +281,10 @@
|
||||
{$t.translate?.run?.copy_id}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">{$t.translate?.run?.status_label || 'Статус'}</span>
|
||||
<span class="ml-1 text-xs font-medium text-gray-700">{getRunStatusLabel(status.status)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">{$t.translate?.run?.insert_status}</span>
|
||||
<span class="ml-1 inline-flex px-2 py-0.5 text-xs rounded-full font-medium {insertStatusBadge.class}">
|
||||
@@ -434,7 +441,6 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-center align-top">
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
onclick={() => {
|
||||
const firstLang = row.languages?.[0] || {};
|
||||
@@ -452,27 +458,6 @@
|
||||
>
|
||||
{$t.translate?.run?.correct || 'Correct'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
const firstLang = row.languages?.[0] || {};
|
||||
const item = {
|
||||
sourceTerm: row.source_sql || row.source_object_name || '',
|
||||
incorrectTarget: firstLang?.final_value || firstLang?.translated_value || '',
|
||||
rowKey: row.id
|
||||
};
|
||||
const exists = bulkCorrectionItems.some(
|
||||
i => i.sourceTerm === item.sourceTerm && i.incorrectTarget === item.incorrectTarget
|
||||
);
|
||||
if (!exists) {
|
||||
bulkCorrectionItems = [...bulkCorrectionItems, item];
|
||||
}
|
||||
}}
|
||||
class="px-2 py-0.5 text-[10px] bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
|
||||
title="Collect for bulk correction"
|
||||
>
|
||||
{$t.translate?.run?.collect || 'Collect'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -500,11 +485,4 @@
|
||||
onClose={() => showBulkReplace = false}
|
||||
onApplied={(count) => { showBulkReplace = false; loadData(); onRefresh(); }}
|
||||
/>
|
||||
|
||||
<BulkCorrectionSidebar
|
||||
{runId}
|
||||
initialItems={bulkCorrectionItems}
|
||||
onClose={() => bulkCorrectionItems = []}
|
||||
onSubmitted={() => { bulkCorrectionItems = []; loadData(); onRefresh(); }}
|
||||
/>
|
||||
<!-- #endregion TranslationRunResult -->
|
||||
|
||||
@@ -55,8 +55,15 @@
|
||||
"target_schema_placeholder": "e.g. public",
|
||||
"target_table_placeholder": "e.g. users_translated",
|
||||
"target_language": "Target Language",
|
||||
"target_language_search_placeholder": "Search languages...",
|
||||
"target_language_required": "Select at least one target language",
|
||||
"status": "Status",
|
||||
"status_draft": "Draft",
|
||||
"status_ready": "Ready",
|
||||
"status_active": "Active",
|
||||
"target_column_placeholder": "Target Column",
|
||||
"target_column_mapping_title": "Target Column Mapping",
|
||||
"target_column_mapping_description": "Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.",
|
||||
"target_column_label": "Target Column (translated text)",
|
||||
"target_column_hint": "Column where translated text will be inserted",
|
||||
"target_language_column_label": "Language Column",
|
||||
@@ -108,7 +115,10 @@
|
||||
"include_source_reference": "Include source language in translations",
|
||||
"include_source_reference_hint": "The original text will be stored as a verified reference copy in its detected language",
|
||||
"disable_reasoning": "Disable reasoning (save tokens)",
|
||||
"disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning"
|
||||
"disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning",
|
||||
"mark_ready": "Mark as READY",
|
||||
"save_job_first": "Save the job first",
|
||||
"breadcrumb_job": "Job"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
@@ -170,7 +180,10 @@
|
||||
"detected_language": "Detected Lang",
|
||||
"languages_count": "Languages: {count}",
|
||||
"across_languages": "Across {count} language(s)",
|
||||
"language_label": "Language"
|
||||
"language_label": "Language",
|
||||
"status_approved": "Approved",
|
||||
"status_rejected": "Rejected",
|
||||
"status_pending": "Pending"
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Translation Jobs",
|
||||
@@ -256,6 +269,7 @@
|
||||
"next_executions": "Next Executions",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"tab_label": "Schedule",
|
||||
"edit": "Edit",
|
||||
"update": "Update",
|
||||
"create": "Create",
|
||||
@@ -450,7 +464,9 @@
|
||||
"failed_label": "{count} failed",
|
||||
"skipped_label": "{count} skipped",
|
||||
"tokens_label": "{count} tokens",
|
||||
"load_failed": "Failed to load run status"
|
||||
"load_failed": "Failed to load run status",
|
||||
"bulk_replace": "Bulk Replace",
|
||||
"status_label": "Status"
|
||||
},
|
||||
"corrections": {
|
||||
"title": "Bulk Corrections",
|
||||
|
||||
@@ -55,8 +55,15 @@
|
||||
"target_schema_placeholder": "например, public",
|
||||
"target_table_placeholder": "например, users_translated",
|
||||
"target_language": "Язык перевода",
|
||||
"target_language_search_placeholder": "Поиск языков...",
|
||||
"target_language_required": "Выберите хотя бы один язык перевода",
|
||||
"status": "Статус",
|
||||
"status_draft": "Черновик",
|
||||
"status_ready": "Готово",
|
||||
"status_active": "Активно",
|
||||
"target_column_placeholder": "Целевая колонка",
|
||||
"target_column_mapping_title": "Маппинг целевых колонок",
|
||||
"target_column_mapping_description": "Настройте, в какие колонки писать перевод, коды языков, исходный текст и определённый язык источника при INSERT.",
|
||||
"target_column_label": "Целевая колонка (переведённый текст)",
|
||||
"target_column_hint": "Колонка, в которую будет вставлен переведённый текст",
|
||||
"target_language_column_label": "Колонка языка",
|
||||
@@ -108,7 +115,10 @@
|
||||
"include_source_reference": "Сохранять исходный текст как эталонную копию",
|
||||
"include_source_reference_hint": "Исходный текст будет сохранён как верифицированная эталонная копия на его обнаруженном языке",
|
||||
"disable_reasoning": "Отключить рассуждения (экономия токенов)",
|
||||
"disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)"
|
||||
"disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)",
|
||||
"mark_ready": "Пометить как готово",
|
||||
"save_job_first": "Сначала сохраните задание",
|
||||
"breadcrumb_job": "Задание"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Предпросмотр",
|
||||
@@ -171,6 +181,10 @@
|
||||
"languages_count": "Языков: {count}",
|
||||
"across_languages": "Для {count} язык(ов)",
|
||||
"language_label": "Язык"
|
||||
,
|
||||
"status_approved": "Одобрено",
|
||||
"status_rejected": "Отклонено",
|
||||
"status_pending": "Ожидает"
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Задания перевода",
|
||||
@@ -256,6 +270,7 @@
|
||||
"next_executions": "Следующие выполнения",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Отключено",
|
||||
"tab_label": "Расписание",
|
||||
"edit": "Редактировать",
|
||||
"update": "Обновить",
|
||||
"create": "Создать",
|
||||
@@ -450,7 +465,9 @@
|
||||
"failed_label": "{count} с ошибками",
|
||||
"skipped_label": "{count} пропущено",
|
||||
"tokens_label": "{count} токенов",
|
||||
"load_failed": "Не удалось загрузить статус запуска"
|
||||
"load_failed": "Не удалось загрузить статус запуска",
|
||||
"bulk_replace": "Массовая замена",
|
||||
"status_label": "Статус"
|
||||
},
|
||||
"corrections": {
|
||||
"title": "Массовые исправления",
|
||||
|
||||
@@ -58,33 +58,35 @@
|
||||
clearOnCompleteCallback,
|
||||
} from '$lib/stores/translationRun.js';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'ru', name: 'Russian' },
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
{ code: 'zh', name: 'Chinese' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'ar', name: 'Arabic' },
|
||||
{ code: 'tr', name: 'Turkish' },
|
||||
{ code: 'nl', name: 'Dutch' },
|
||||
{ code: 'pl', name: 'Polish' },
|
||||
{ code: 'sv', name: 'Swedish' },
|
||||
{ code: 'da', name: 'Danish' },
|
||||
{ code: 'fi', name: 'Finnish' },
|
||||
{ code: 'cs', name: 'Czech' },
|
||||
{ code: 'hu', name: 'Hungarian' },
|
||||
{ code: 'ro', name: 'Romanian' },
|
||||
{ code: 'vi', name: 'Vietnamese' },
|
||||
{ code: 'th', name: 'Thai' },
|
||||
{ code: 'he', name: 'Hebrew' },
|
||||
{ code: 'id', name: 'Indonesian' },
|
||||
{ code: 'ms', name: 'Malay' },
|
||||
];
|
||||
const LANGUAGE_LABELS = {
|
||||
ru: 'Русский',
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
fr: 'Français',
|
||||
es: 'Español',
|
||||
it: 'Italiano',
|
||||
pt: 'Português',
|
||||
zh: '中文',
|
||||
ja: '日本語',
|
||||
ko: '한국어',
|
||||
ar: 'العربية',
|
||||
tr: 'Türkçe',
|
||||
nl: 'Nederlands',
|
||||
pl: 'Polski',
|
||||
sv: 'Svenska',
|
||||
da: 'Dansk',
|
||||
fi: 'Suomi',
|
||||
cs: 'Čeština',
|
||||
hu: 'Magyar',
|
||||
ro: 'Română',
|
||||
vi: 'Tiếng Việt',
|
||||
th: 'ไทย',
|
||||
he: 'עברית',
|
||||
id: 'Bahasa Indonesia',
|
||||
ms: 'Bahasa Melayu',
|
||||
};
|
||||
|
||||
const LANGUAGES = Object.entries(LANGUAGE_LABELS).map(([code, name]) => ({ code, name }));
|
||||
|
||||
/** @type {string} idle | loading | configured | saving | validation_error | datasource_unavailable */
|
||||
let uxState = $state('idle');
|
||||
@@ -147,7 +149,7 @@
|
||||
{ id: 'run', label: $t.translate?.config?.run_translation || 'Run' },
|
||||
];
|
||||
if (!isNewJob && existingJob) {
|
||||
base.push({ id: 'schedule', label: 'Schedule' });
|
||||
base.push({ id: 'schedule', label: $t.translate?.schedule?.tab_label || 'Schedule' });
|
||||
}
|
||||
return base;
|
||||
});
|
||||
@@ -160,6 +162,12 @@
|
||||
let isSaving = $state(false);
|
||||
let validationErrors = $state({});
|
||||
let warnings = $state([]);
|
||||
let pageTitle = $derived.by(() => {
|
||||
if (isNewJob) return $t.translate?.config?.new_title || 'New Translation Job';
|
||||
const jobName = existingJob?.name || name;
|
||||
const base = $t.translate?.config?.edit_title || 'Edit Translation Job';
|
||||
return jobName ? `${jobName} | ${base}` : base;
|
||||
});
|
||||
|
||||
// Run state — using global store to survive page navigation
|
||||
let runState = fromStore(translationRunStore);
|
||||
@@ -272,8 +280,8 @@
|
||||
|
||||
// Load dictionaries (from available translate dictionaries)
|
||||
try {
|
||||
const dicts = await api.fetchApi('/translate/dictionaries').catch(() => []);
|
||||
availableDictionaries = Array.isArray(dicts) ? dicts : [];
|
||||
const result = await api.fetchApi('/translate/dictionaries').catch(() => ({}));
|
||||
availableDictionaries = Array.isArray(result) ? result : (result?.items || []);
|
||||
} catch (_e) {
|
||||
availableDictionaries = [];
|
||||
}
|
||||
@@ -610,8 +618,25 @@
|
||||
function isVirtual(col) {
|
||||
return virtualColumnNames.includes(col);
|
||||
}
|
||||
|
||||
function getJobStatusLabel(value) {
|
||||
const labels = {
|
||||
DRAFT: $t.translate?.config?.status_draft || 'Draft',
|
||||
READY: $t.translate?.config?.status_ready || 'Ready',
|
||||
ACTIVE: $t.translate?.config?.status_active || 'Active',
|
||||
RUNNING: $t.translate?.jobs?.status_running || 'Running',
|
||||
COMPLETED: $t.translate?.jobs?.status_completed || 'Completed',
|
||||
FAILED: $t.translate?.jobs?.status_failed || 'Failed',
|
||||
CANCELLED: $t.translate?.history?.status_cancelled || 'Cancelled',
|
||||
};
|
||||
return labels[value] || value || ($t.translate?.config?.status_draft || 'Draft');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-full xl:max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
@@ -1031,8 +1056,8 @@
|
||||
|
||||
<!-- Target Column Mapping -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-100">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Target Column Mapping</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.</p>
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">{$t.translate?.config?.target_column_mapping_description || 'Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.'}</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
@@ -1105,11 +1130,11 @@
|
||||
options={LANGUAGES}
|
||||
bind:selected={targetLanguages}
|
||||
searchable={true}
|
||||
placeholder="Search languages..."
|
||||
placeholder={$t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
|
||||
required={true}
|
||||
/>
|
||||
{#if targetLanguages.length === 0}
|
||||
<p class="text-xs text-amber-600 mt-1">Select at least one target language</p>
|
||||
<p class="text-xs text-amber-600 mt-1">{$t.translate?.config?.target_language_required || 'Select at least one target language'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
@@ -1225,7 +1250,7 @@
|
||||
</section>
|
||||
{:else}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-gray-500">{$t.translate?.config?.save} {$t.translate?.config?.basic_info?.toLowerCase?.() || 'Save the job first'}</p>
|
||||
<p class="text-sm text-gray-500">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1242,10 +1267,11 @@
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{status === 'READY' ? 'bg-green-100 text-green-700' : ''}
|
||||
{status === 'DRAFT' ? 'bg-yellow-100 text-yellow-700' : ''}
|
||||
{status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : ''}
|
||||
{status === 'RUNNING' ? 'bg-blue-100 text-blue-700' : ''}
|
||||
{status === 'COMPLETED' ? 'bg-green-100 text-green-700' : ''}
|
||||
{status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}">
|
||||
{status || 'DRAFT'}
|
||||
{getJobStatusLabel(status)}
|
||||
</span>
|
||||
{#if status === 'DRAFT'}
|
||||
<button
|
||||
@@ -1260,7 +1286,7 @@
|
||||
}}
|
||||
class="ml-auto px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Mark as READY
|
||||
{$t.translate?.config?.mark_ready || 'Mark as READY'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1350,7 +1376,7 @@
|
||||
</section>
|
||||
{:else}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||
<p class="text-sm text-gray-500">{$t.translate?.config?.save} {$t.translate?.config?.basic_info?.toLowerCase?.() || 'Save the job first'}</p>
|
||||
<p class="text-sm text-gray-500">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user