diff --git a/apps/backend/app/database.py b/apps/backend/app/database.py index 1032cd6c4..cc6c14245 100644 --- a/apps/backend/app/database.py +++ b/apps/backend/app/database.py @@ -47,6 +47,11 @@ def improvements(self) -> Table: """Improvement results table.""" return self.db.table("improvements") + @property + def resume_json_backups(self) -> Table: + """Backups created before browser JSON imports overwrite resumes.""" + return self.db.table("resume_json_backups") + def close(self) -> None: """Close database connection.""" if self._db is not None: @@ -169,6 +174,29 @@ def update_resume(self, resume_id: str, updates: dict[str, Any]) -> dict[str, An return result + def create_resume_json_backup( + self, + resume: dict[str, Any], + source: str = "json_upload", + ) -> dict[str, Any]: + """Create a point-in-time backup before replacing resume JSON.""" + now = datetime.now(timezone.utc).isoformat() + backup = { + "backup_id": str(uuid4()), + "resume_id": resume["resume_id"], + "source": source, + "created_at": now, + "previous_content": resume.get("content"), + "previous_content_type": resume.get("content_type"), + "previous_processed_data": resume.get("processed_data"), + "previous_processing_status": resume.get("processing_status"), + "previous_title": resume.get("title"), + "previous_filename": resume.get("filename"), + "previous_updated_at": resume.get("updated_at"), + } + self.resume_json_backups.insert(backup) + return backup + def delete_resume(self, resume_id: str) -> bool: """Delete resume by ID.""" Resume = Query() diff --git a/apps/backend/app/routers/resumes.py b/apps/backend/app/routers/resumes.py index 3c06197ab..eda04ce90 100644 --- a/apps/backend/app/routers/resumes.py +++ b/apps/backend/app/routers/resumes.py @@ -7,12 +7,14 @@ import logging import unicodedata from collections.abc import Awaitable +from datetime import datetime, timezone from pathlib import Path from typing import Any, NoReturn from uuid import uuid4 from fastapi import APIRouter, File, HTTPException, Query, UploadFile from fastapi.responses import Response +from pydantic import ValidationError from app.config_cache import get_content_language, load_config as _load_config from app.database import db @@ -137,6 +139,70 @@ def _get_original_resume_data(resume: dict[str, Any]) -> dict[str, Any] | None: return original_data +def _get_resume_json_payload(resume: dict[str, Any]) -> dict[str, Any]: + """Return the structured resume JSON for export or replacement.""" + resume_data = resume.get("processed_data") + if not resume_data and resume.get("content_type") == "json": + try: + resume_data = json.loads(resume["content"]) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=422, + detail="Stored resume content is not valid JSON", + ) from e + + if not isinstance(resume_data, dict): + raise HTTPException(status_code=404, detail="Resume JSON not found") + + normalized_data = normalize_resume_data(copy.deepcopy(resume_data)) + try: + return ResumeData.model_validate(normalized_data).model_dump(mode="json") + except ValidationError as e: + raise HTTPException(status_code=422, detail="Stored resume JSON is invalid") from e + + +def _extract_uploaded_resume_json(payload: Any, resume_id: str) -> dict[str, Any]: + """Accept either a raw ResumeData object or exported {metadata, resume} JSON.""" + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Uploaded JSON must be an object") + + metadata = payload.get("metadata") + uploaded_resume_id = None + if isinstance(metadata, dict): + uploaded_resume_id = metadata.get("resume_id") + if uploaded_resume_id and uploaded_resume_id != resume_id: + raise HTTPException( + status_code=400, + detail="Uploaded JSON belongs to a different resume_id", + ) + + resume_payload = payload.get("resume", payload) + if not isinstance(resume_payload, dict): + raise HTTPException(status_code=400, detail="Uploaded resume JSON must be an object") + + normalized_data = normalize_resume_data(copy.deepcopy(resume_payload)) + try: + return ResumeData.model_validate(normalized_data).model_dump(mode="json") + except ValidationError as e: + raise HTTPException(status_code=422, detail="Uploaded resume JSON is invalid") from e + + +def _build_json_export_metadata(resume: dict[str, Any]) -> dict[str, Any]: + return { + "schema": "resume-matcher-json-export/v1", + "resume_id": resume["resume_id"], + "title": resume.get("title"), + "filename": resume.get("filename"), + "is_master": resume.get("is_master", False), + "parent_id": resume.get("parent_id"), + "content_type": resume.get("content_type"), + "processing_status": resume.get("processing_status", "pending"), + "created_at": resume.get("created_at"), + "updated_at": resume.get("updated_at"), + "exported_at": datetime.now(timezone.utc).isoformat(), + } + + def _get_original_markdown(resume: dict[str, Any]) -> str | None: """Get the original markdown content from a resume. @@ -1386,6 +1452,70 @@ async def update_resume_endpoint( ) +@router.get("/{resume_id}/json") +async def export_resume_json(resume_id: str) -> dict[str, Any]: + """Export a resume as JSON with metadata for browser downloads.""" + resume = db.get_resume(resume_id) + if not resume: + raise HTTPException(status_code=404, detail="Resume not found") + + return { + "metadata": _build_json_export_metadata(resume), + "resume": _get_resume_json_payload(resume), + } + + +@router.put("/{resume_id}/json", response_model=ResumeFetchResponse) +async def replace_resume_json( + resume_id: str, payload: dict[str, Any] +) -> ResumeFetchResponse: + """Replace a resume's structured JSON, preserving the same resume_id. + + The uploaded body can be either the export wrapper ({metadata, resume}) or + a raw ResumeData object. A backup of the previous JSON/content is stored + before the overwrite. + """ + existing = db.get_resume(resume_id) + if not existing: + raise HTTPException(status_code=404, detail="Resume not found") + + updated_data = _extract_uploaded_resume_json(payload, resume_id) + updated_content = json.dumps(updated_data, indent=2, ensure_ascii=False) + + backup = db.create_resume_json_backup(existing, source="browser_json_upload") + updated = db.update_resume( + resume_id, + { + "content": updated_content, + "content_type": "json", + "processed_data": updated_data, + "processing_status": "ready", + "last_json_backup_id": backup["backup_id"], + }, + ) + + raw_resume = RawResume( + id=None, + content=updated["content"], + content_type=updated["content_type"], + created_at=updated["created_at"], + processing_status=updated.get("processing_status", "pending"), + ) + + return ResumeFetchResponse( + request_id=str(uuid4()), + data=ResumeFetchData( + resume_id=resume_id, + raw_resume=raw_resume, + processed_resume=ResumeData.model_validate(updated.get("processed_data")), + cover_letter=updated.get("cover_letter"), + outreach_message=updated.get("outreach_message"), + parent_id=updated.get("parent_id"), + title=updated.get("title"), + ), + ) + + @router.get("/{resume_id}/pdf") async def download_resume_pdf( resume_id: str, diff --git a/apps/backend/tests/integration/test_resume_api.py b/apps/backend/tests/integration/test_resume_api.py index 09ac3e061..76d87b7ec 100644 --- a/apps/backend/tests/integration/test_resume_api.py +++ b/apps/backend/tests/integration/test_resume_api.py @@ -148,6 +148,79 @@ async def test_update_outreach(self, mock_db, client, mock_resume_record): assert resp.status_code == 200 +class TestResumeJsonImportExport: + """GET/PUT /api/v1/resumes/{resume_id}/json""" + + @patch("app.routers.resumes.db") + async def test_export_resume_json_includes_metadata( + self, mock_db, client, mock_resume_record + ): + mock_db.get_resume.return_value = { + **mock_resume_record, + "title": "Google TPM", + "content_type": "json", + } + async with client: + resp = await client.get("/api/v1/resumes/res-123/json") + + assert resp.status_code == 200 + payload = resp.json() + assert payload["metadata"]["schema"] == "resume-matcher-json-export/v1" + assert payload["metadata"]["resume_id"] == "res-123" + assert payload["metadata"]["title"] == "Google TPM" + assert payload["resume"]["personalInfo"]["name"] == "Jane Doe" + + @patch("app.routers.resumes.db") + async def test_replace_resume_json_creates_backup( + self, mock_db, client, mock_resume_record, sample_resume + ): + mock_db.get_resume.return_value = mock_resume_record + mock_db.create_resume_json_backup.return_value = {"backup_id": "backup-123"} + mock_db.update_resume.return_value = { + **mock_resume_record, + "content": "{}", + "content_type": "json", + "processed_data": sample_resume, + "last_json_backup_id": "backup-123", + } + + async with client: + resp = await client.put( + "/api/v1/resumes/res-123/json", + json={ + "metadata": {"resume_id": "res-123"}, + "resume": sample_resume, + }, + ) + + assert resp.status_code == 200 + mock_db.create_resume_json_backup.assert_called_once_with( + mock_resume_record, source="browser_json_upload" + ) + update_payload = mock_db.update_resume.call_args.args[1] + assert update_payload["content_type"] == "json" + assert update_payload["processing_status"] == "ready" + assert update_payload["last_json_backup_id"] == "backup-123" + + @patch("app.routers.resumes.db") + async def test_replace_resume_json_rejects_other_resume_id( + self, mock_db, client, mock_resume_record, sample_resume + ): + mock_db.get_resume.return_value = mock_resume_record + + async with client: + resp = await client.put( + "/api/v1/resumes/res-123/json", + json={ + "metadata": {"resume_id": "other-resume"}, + "resume": sample_resume, + }, + ) + + assert resp.status_code == 400 + mock_db.update_resume.assert_not_called() + + class TestRetryProcessing: """POST /api/v1/resumes/{resume_id}/retry-processing""" diff --git a/apps/frontend/app/(default)/resumes/[id]/page.tsx b/apps/frontend/app/(default)/resumes/[id]/page.tsx index 29b59529c..ffc8a6b06 100644 --- a/apps/frontend/app/(default)/resumes/[id]/page.tsx +++ b/apps/frontend/app/(default)/resumes/[id]/page.tsx @@ -1,25 +1,44 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import Resume, { ResumeData } from '@/components/dashboard/resume-component'; import { fetchResume, + downloadResumeJson, downloadResumePdf, getResumePdfUrl, deleteResume, retryProcessing, renameResume, + uploadResumeJson, + type ResumeJsonExport, } from '@/lib/api/resume'; import { useStatusCache } from '@/lib/context/status-cache'; -import { ArrowLeft, Edit, Download, Loader2, AlertCircle, Sparkles, Pencil } from 'lucide-react'; +import { + ArrowLeft, + Edit, + Download, + Loader2, + AlertCircle, + Sparkles, + Pencil, + FileJson, + Upload, + RefreshCw, +} from 'lucide-react'; import { EnrichmentModal } from '@/components/enrichment/enrichment-modal'; import { useTranslations } from '@/lib/i18n'; import { withLocalizedDefaultSections } from '@/lib/utils/section-helpers'; import { useLanguage } from '@/lib/context/language-context'; -import { downloadBlobAsFile, openUrlInNewTab, sanitizeFilename } from '@/lib/utils/download'; +import { + downloadBlobAsFile, + openUrlInNewTab, + sanitizeDownloadFilename, + sanitizeFilename, +} from '@/lib/utils/download'; type ProcessingStatus = 'pending' | 'processing' | 'ready' | 'failed'; @@ -41,9 +60,17 @@ export default function ResumeViewerPage() { const [showEnrichmentModal, setShowEnrichmentModal] = useState(false); const [isRetrying, setIsRetrying] = useState(false); const [isDownloading, setIsDownloading] = useState(false); + const [isDownloadingJson, setIsDownloadingJson] = useState(false); + const [isUploadingJson, setIsUploadingJson] = useState(false); const [resumeTitle, setResumeTitle] = useState(null); const [isEditingTitle, setIsEditingTitle] = useState(false); const [editingTitleValue, setEditingTitleValue] = useState(''); + const [pendingJsonUpload, setPendingJsonUpload] = useState( + null + ); + const [jsonActionError, setJsonActionError] = useState(null); + const [jsonActionSuccess, setJsonActionSuccess] = useState(null); + const uploadJsonInputRef = useRef(null); const resumeId = params?.id as string; @@ -163,6 +190,80 @@ export default function ResumeViewerPage() { reloadResumeData(); }; + const getJsonFilenameTitle = (jsonExport?: ResumeJsonExport): string | null | undefined => + resumeTitle || jsonExport?.metadata.title || resumeData?.personalInfo?.name; + + const handleDownloadJson = async () => { + setIsDownloadingJson(true); + try { + const jsonExport = await downloadResumeJson(resumeId); + const blob = new Blob([JSON.stringify(jsonExport, null, 2)], { + type: 'application/json', + }); + const filename = sanitizeDownloadFilename( + getJsonFilenameTitle(jsonExport), + `resume_${resumeId}`, + 'json' + ); + downloadBlobAsFile(blob, filename); + setJsonActionSuccess(t('resumeViewer.jsonDownloadSuccess')); + } catch (err) { + console.error('Failed to download resume JSON:', err); + setJsonActionError(t('resumeViewer.jsonDownloadError')); + } finally { + setIsDownloadingJson(false); + } + }; + + const handleUploadJsonClick = () => { + uploadJsonInputRef.current?.click(); + }; + + const handleUploadJsonFile = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + + try { + const text = await file.text(); + const payload = JSON.parse(text) as ResumeJsonExport | ResumeData; + if ( + 'metadata' in payload && + payload.metadata?.resume_id && + payload.metadata.resume_id !== resumeId + ) { + setJsonActionError(t('resumeViewer.jsonDifferentResume')); + return; + } + setPendingJsonUpload(payload); + } catch (err) { + console.error('Failed to read resume JSON:', err); + setJsonActionError(t('resumeViewer.jsonInvalidFile')); + } + }; + + const handleUploadJsonConfirm = async () => { + if (!pendingJsonUpload) return; + setIsUploadingJson(true); + try { + const updated = await uploadResumeJson(resumeId, pendingJsonUpload); + if (updated.processed_resume) { + setResumeData(updated.processed_resume as ResumeData); + } else { + await reloadResumeData(); + } + setProcessingStatus((updated.raw_resume?.processing_status || 'ready') as ProcessingStatus); + setResumeTitle(updated.title ?? null); + setJsonActionSuccess(t('resumeViewer.jsonUploadSuccess')); + setPendingJsonUpload(null); + } catch (err) { + console.error('Failed to upload resume JSON:', err); + setJsonActionError(t('resumeViewer.jsonUploadError')); + } finally { + setIsUploadingJson(false); + } + }; + const handleDownload = async () => { setIsDownloading(true); try { @@ -292,7 +393,7 @@ export default function ResumeViewerPage() { {t('nav.backToDashboard')} -
+
{isMasterResume && ( + + + +
@@ -425,6 +553,47 @@ export default function ResumeViewerPage() { showCancelButton={false} /> + { + if (!open && !isUploadingJson) setPendingJsonUpload(null); + }} + title={t('resumeViewer.replaceJsonTitle')} + description={t('resumeViewer.replaceJsonDescription')} + confirmLabel={ + isUploadingJson ? t('common.uploading') : t('resumeViewer.replaceJsonConfirm') + } + cancelLabel={t('common.cancel')} + onConfirm={handleUploadJsonConfirm} + variant="warning" + /> + + {jsonActionSuccess && ( + setJsonActionSuccess(null)} + title={t('common.success')} + description={jsonActionSuccess} + confirmLabel={t('common.ok')} + onConfirm={() => setJsonActionSuccess(null)} + variant="success" + showCancelButton={false} + /> + )} + + {jsonActionError && ( + setJsonActionError(null)} + title={t('common.error')} + description={jsonActionError} + confirmLabel={t('common.ok')} + onConfirm={() => setJsonActionError(null)} + variant="danger" + showCancelButton={false} + /> + )} + {deleteError && ( { + const res = await apiFetch(`/resumes/${encodeURIComponent(resumeId)}/json`); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Failed to download resume JSON (status ${res.status}): ${text}`); + } + return res.json(); +} + +export async function uploadResumeJson( + resumeId: string, + payload: ResumeJsonExport | ProcessedResume +): Promise { + const res = await apiPut(`/resumes/${encodeURIComponent(resumeId)}/json`, payload); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Failed to upload resume JSON (status ${res.status}): ${text}`); + } + const response = (await res.json()) as ResumeResponse; + return response.data; +} + export function getResumePdfUrl( resumeId: string, settings?: TemplateSettings, diff --git a/apps/frontend/lib/utils/download.ts b/apps/frontend/lib/utils/download.ts index a25d36907..060f85400 100644 --- a/apps/frontend/lib/utils/download.ts +++ b/apps/frontend/lib/utils/download.ts @@ -34,10 +34,22 @@ export function sanitizeFilename( title: string | null | undefined, fallbackId: string, type: 'resume' | 'cover-letter' = 'resume' +): string { + return sanitizeDownloadFilename( + title, + type === 'resume' ? `resume_${fallbackId}` : `cover_letter_${fallbackId}`, + 'pdf' + ); +} + +export function sanitizeDownloadFilename( + title: string | null | undefined, + fallbackBaseName: string, + extension: string ): string { // Use fallback if no title if (!title?.trim()) { - return type === 'resume' ? `resume_${fallbackId}.pdf` : `cover_letter_${fallbackId}.pdf`; + return `${fallbackBaseName}.${extension}`; } // Normalize Unicode to NFC form to ensure consistent representation @@ -57,8 +69,7 @@ export function sanitizeFilename( sanitized = chars.slice(0, 100).join('').trim(); } - // Add .pdf extension - return `${sanitized}.pdf`; + return `${sanitized}.${extension}`; } /** diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index 52b5fab03..22f35086e 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -723,6 +723,18 @@ "returnToDashboard": "Return to Dashboard", "enhanceResume": "Enhance Resume", "downloadResume": "Download Resume", + "downloadJson": "Download JSON", + "uploadJson": "Upload JSON", + "refresh": "Refresh", + "jsonDownloadSuccess": "Resume JSON downloaded successfully.", + "jsonDownloadError": "Failed to download resume JSON.", + "jsonUploadSuccess": "Resume JSON uploaded successfully.", + "jsonUploadError": "Failed to upload resume JSON.", + "jsonInvalidFile": "The selected file is not valid JSON.", + "jsonDifferentResume": "This JSON file belongs to a different resume.", + "replaceJsonTitle": "Replace resume JSON?", + "replaceJsonDescription": "This will create a backup, then replace the JSON content for this resume. The resume ID and existing app metadata will be preserved.", + "replaceJsonConfirm": "Replace JSON", "deletedTitle": "Resume Deleted", "deletedDescriptionMaster": "Your master resume has been permanently deleted from the system.", "deletedDescriptionRegular": "The resume has been permanently deleted from the system.", diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index 6c4dae0e4..0b40fec16 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -564,6 +564,7 @@ "apiKeyError": "Tu clave API no funciona. Revísala en Configuración.", "rateLimit": "Se alcanzó el límite de tasa. Espera aproximadamente un minuto e inténtalo de nuevo.", "failedToGenerate": "No se pudo generar el currículum. Revisa tu clave API en Configuración e inténtalo de nuevo.", + "timeout": "La solicitud agotó el tiempo de espera. Prueba con una descripción de empleo más corta o revisa tu conexión.", "failedToPreview": "No se pudo previsualizar el currículum. Revisa tu clave API en Configuración e inténtalo de nuevo.", "failedToConfirm": "No se pudieron aplicar los cambios. Inténtalo de nuevo." } @@ -722,6 +723,18 @@ "returnToDashboard": "Volver al Panel", "enhanceResume": "Mejorar currículum", "downloadResume": "Descargar currículum", + "downloadJson": "Descargar JSON", + "uploadJson": "Subir JSON", + "refresh": "Actualizar", + "jsonDownloadSuccess": "JSON del currículum descargado correctamente.", + "jsonDownloadError": "No se pudo descargar el JSON del currículum.", + "jsonUploadSuccess": "JSON del currículum subido correctamente.", + "jsonUploadError": "No se pudo subir el JSON del currículum.", + "jsonInvalidFile": "El archivo seleccionado no es un JSON válido.", + "jsonDifferentResume": "Este archivo JSON pertenece a otro currículum.", + "replaceJsonTitle": "¿Reemplazar JSON del currículum?", + "replaceJsonDescription": "Se creará una copia de seguridad y luego se reemplazará el contenido JSON de este currículum. Se conservarán el ID del currículum y los metadatos existentes de la aplicación.", + "replaceJsonConfirm": "Reemplazar JSON", "deletedTitle": "Currículum eliminado", "deletedDescriptionMaster": "Tu currículum maestro se ha eliminado permanentemente del sistema.", "deletedDescriptionRegular": "El currículum se ha eliminado permanentemente del sistema.", diff --git a/apps/frontend/messages/ja.json b/apps/frontend/messages/ja.json index 7ec30c4d5..f23c5e65c 100644 --- a/apps/frontend/messages/ja.json +++ b/apps/frontend/messages/ja.json @@ -564,6 +564,7 @@ "apiKeyError": "API キーが機能していません。設定で確認してください。", "rateLimit": "レート制限に達しました。約1分待ってから再試行してください。", "failedToGenerate": "履歴書を生成できませんでした。設定で API キーを確認してから、もう一度お試しください。", + "timeout": "リクエストがタイムアウトしました。職務内容を短くするか、接続を確認してからもう一度お試しください。", "failedToPreview": "履歴書をプレビューできませんでした。設定で API キーを確認してから、もう一度お試しください。", "failedToConfirm": "変更を適用できませんでした。もう一度お試しください。" } @@ -722,6 +723,18 @@ "returnToDashboard": "ダッシュボードに戻る", "enhanceResume": "履歴書を強化", "downloadResume": "履歴書をダウンロード", + "downloadJson": "JSONをダウンロード", + "uploadJson": "JSONをアップロード", + "refresh": "更新", + "jsonDownloadSuccess": "履歴書JSONをダウンロードしました。", + "jsonDownloadError": "履歴書JSONのダウンロードに失敗しました。", + "jsonUploadSuccess": "履歴書JSONをアップロードしました。", + "jsonUploadError": "履歴書JSONのアップロードに失敗しました。", + "jsonInvalidFile": "選択したファイルは有効なJSONではありません。", + "jsonDifferentResume": "このJSONファイルは別の履歴書に属しています。", + "replaceJsonTitle": "履歴書JSONを置き換えますか?", + "replaceJsonDescription": "バックアップを作成してから、この履歴書のJSON内容を置き換えます。履歴書IDと既存のアプリメタデータは保持されます。", + "replaceJsonConfirm": "JSONを置き換え", "deletedTitle": "履歴書を削除しました", "deletedDescriptionMaster": "マスター履歴書はシステムから完全に削除されました。", "deletedDescriptionRegular": "履歴書はシステムから完全に削除されました。", diff --git a/apps/frontend/messages/pt-BR.json b/apps/frontend/messages/pt-BR.json index a9f81d457..2f37fae22 100644 --- a/apps/frontend/messages/pt-BR.json +++ b/apps/frontend/messages/pt-BR.json @@ -564,6 +564,7 @@ "apiKeyError": "Sua chave de API não está funcionando. Verifique em Configurações.", "rateLimit": "Limite de requisições atingido. Aguarde cerca de um minuto e tente novamente.", "failedToGenerate": "Não foi possível gerar o currículo. Verifique sua chave de API em Configurações e tente novamente.", + "timeout": "A solicitação expirou. Tente usar uma descrição de vaga mais curta ou verifique sua conexão.", "failedToPreview": "Não foi possível pré-visualizar o currículo. Verifique sua chave de API em Configurações e tente novamente.", "failedToConfirm": "Não foi possível aplicar as alterações. Tente novamente." } @@ -722,6 +723,18 @@ "returnToDashboard": "Voltar ao Painel", "enhanceResume": "Aprimorar Currículo", "downloadResume": "Baixar Currículo", + "downloadJson": "Baixar JSON", + "uploadJson": "Enviar JSON", + "refresh": "Atualizar", + "jsonDownloadSuccess": "JSON do currículo baixado com sucesso.", + "jsonDownloadError": "Falha ao baixar o JSON do currículo.", + "jsonUploadSuccess": "JSON do currículo enviado com sucesso.", + "jsonUploadError": "Falha ao enviar o JSON do currículo.", + "jsonInvalidFile": "O arquivo selecionado não é um JSON válido.", + "jsonDifferentResume": "Este arquivo JSON pertence a outro currículo.", + "replaceJsonTitle": "Substituir JSON do currículo?", + "replaceJsonDescription": "Isso criará um backup e depois substituirá o conteúdo JSON deste currículo. O ID do currículo e os metadados existentes do app serão preservados.", + "replaceJsonConfirm": "Substituir JSON", "deletedTitle": "Currículo Excluído", "deletedDescriptionMaster": "Seu currículo principal foi permanentemente excluído do sistema.", "deletedDescriptionRegular": "O currículo foi permanentemente excluído do sistema.", diff --git a/apps/frontend/messages/zh.json b/apps/frontend/messages/zh.json index 0d12c63ee..9f4ba6162 100644 --- a/apps/frontend/messages/zh.json +++ b/apps/frontend/messages/zh.json @@ -723,6 +723,18 @@ "returnToDashboard": "返回仪表板", "enhanceResume": "增强简历", "downloadResume": "下载简历", + "downloadJson": "下载 JSON", + "uploadJson": "上传 JSON", + "refresh": "刷新", + "jsonDownloadSuccess": "简历 JSON 下载成功。", + "jsonDownloadError": "简历 JSON 下载失败。", + "jsonUploadSuccess": "简历 JSON 上传成功。", + "jsonUploadError": "简历 JSON 上传失败。", + "jsonInvalidFile": "所选文件不是有效的 JSON。", + "jsonDifferentResume": "此 JSON 文件属于另一份简历。", + "replaceJsonTitle": "替换简历 JSON?", + "replaceJsonDescription": "这将先创建备份,然后替换此简历的 JSON 内容。简历 ID 和现有应用元数据将被保留。", + "replaceJsonConfirm": "替换 JSON", "deletedTitle": "已删除简历", "deletedDescriptionMaster": "您的主简历已从系统中永久删除。", "deletedDescriptionRegular": "该简历已从系统中永久删除。",