Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…rojects Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com>
…ions Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com>
Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds an “update existing project from uploaded file” flow for Xcode .xcloc projects, mirroring the existing PO update UX and enabling incremental localization updates while retaining matching translations.
Changes:
- Implemented
XclocClient.updateFromXcloc()to merge a newly uploaded xcloc archive into an existing project and return update stats. - Added
XclocUpdateDialogand generalized the editor toolbar prop (onUpdatePo→onUpdate) so the Update button works for both PO and xcloc. - Added unit/e2e tests plus an updated xcloc zip fixture, and updated i18n strings (en/zh).
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
lib/translation/xcloc/client.ts |
Adds xcloc merge/update logic and stats reporting. |
lib/translation/xcloc/xcloc-update-dialog.tsx |
New UI dialog to upload/validate xcloc archives and preview update stats. |
app/[locale]/dashboard/projects/[id]/editor-client.tsx |
Wires the Update button to open the right dialog for PO/xcloc and applies updates. |
lib/translation/components/types.ts |
Renames editor prop to onUpdate to support multiple formats. |
lib/translation/components/translation-editor.tsx |
Uses new onUpdate prop to render Update actions in toolbar/menu. |
__tests__/translation/xcloc/client.test.ts |
Adds unit tests for xcloc merge/update behavior and export correctness. |
e2e/xcloc-update.spec.ts |
Adds end-to-end coverage for xcloc update flow and reload behavior. |
messages/en.po |
Adds new UI strings for xcloc update flow. |
messages/zh.po |
Adds Chinese translations for new xcloc update strings. |
test-assets/zh-Hans-updated.xcloc.zip |
New fixture used by unit/e2e tests for update scenarios. |
| const dialogFileInput = page | ||
| .locator("input[accept='.zip,.xcloc']") | ||
| .first(); | ||
| await dialogFileInput.setInputFiles( | ||
| path.resolve(__dirname, "../test-assets/zh-Hans-updated.xcloc.zip"), | ||
| ); | ||
|
|
There was a problem hiding this comment.
This test locates the update dialog’s file input via input[accept='.zip,.xcloc'], which is brittle (there may be multiple file inputs on the page with the same accept string, including the initial upload input). Prefer targeting the dialog input via the dedicated test id (xcloc-update-file-input) to avoid intermittent mis-selection.
| const dialogFileInput = page | |
| .locator("input[accept='.zip,.xcloc']") | |
| .first(); | |
| await dialogFileInput.setInputFiles( | |
| path.resolve(__dirname, "../test-assets/zh-Hans-updated.xcloc.zip"), | |
| ); | |
| const dialogFileInput = page.getByTestId("xcloc-update-file-input"); | |
| await dialogFileInput.setInputFiles( | |
| path.resolve(__dirname, "../test-assets/zh-Hans-updated.xcloc.zip"), | |
| ); | |
| test("should preserve translations after xcloc update and page reload", async ({ | ||
| page, | ||
| }) => { | ||
| // 1. Navigate to new project page and upload original xcloc | ||
| await page.goto("/dashboard/projects/new"); | ||
| await expect(page.getByTestId("tab-upload")).toBeVisible(); | ||
| const fileInput = page.getByTestId("upload-file-input"); | ||
| await fileInput.setInputFiles( | ||
| path.resolve(__dirname, "../test-assets/zh-Hans.xcloc.zip"), | ||
| ); | ||
|
|
||
| // 2. Wait for editor | ||
| await expect(page).toHaveURL(/\/dashboard\/projects\/[a-f0-9-]+/, { | ||
| timeout: 15_000, | ||
| }); | ||
| await expect(page.getByTestId("translation-editor")).toBeVisible({ | ||
| timeout: 10_000, | ||
| }); | ||
|
|
||
| // 3. Click Update and upload the updated xcloc | ||
| const updateButton = page.getByRole("button", { name: /update/i }).first(); | ||
| await updateButton.click(); | ||
|
|
||
| const dialogFileInput = page | ||
| .locator("input[accept='.zip,.xcloc']") | ||
| .first(); | ||
| await dialogFileInput.setInputFiles( | ||
| path.resolve(__dirname, "../test-assets/zh-Hans-updated.xcloc.zip"), | ||
| ); | ||
|
|
||
| // 4. Wait for diff preview and confirm | ||
| await expect(page.getByTestId("xcloc-update-diff-preview")).toBeVisible({ | ||
| timeout: 5_000, | ||
| }); | ||
| await page.getByTestId("xcloc-update-confirm").click(); | ||
| await expect( | ||
| page.getByTestId("xcloc-update-diff-preview"), | ||
| ).not.toBeVisible(); | ||
|
|
||
| // 5. Editor should still be visible after update | ||
| await expect(page.getByTestId("translation-editor")).toBeVisible(); | ||
|
|
||
| // 6. Wait for auto-save (debounce 5s + buffer) | ||
| await page.waitForTimeout(7_000); | ||
|
|
||
| // 7. Reload and verify the project still loads correctly | ||
| const currentUrl = page.url(); | ||
| await page.goto(currentUrl); | ||
| await expect(page.getByTestId("translation-editor")).toBeVisible({ | ||
| timeout: 10_000, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The test name says it “should preserve translations … after … reload”, but it never sets or asserts any translations before/after the update/reload (it only checks that the editor loads). Either add assertions that a known translated entry remains translated after reload (or that preserved count > 0 and a specific target text is retained), or rename the test to reflect what it actually verifies.
| const readAndPreview = useCallback( | ||
| async (selectedFile: File) => { | ||
| setLoading(true); | ||
| setParseError(null); | ||
| try { | ||
| const processor = new DefaultUploadProcessor(); | ||
| const result = await processor.process(selectedFile); | ||
| if (result.hasError) { | ||
| setParseError(result.errorMessage); | ||
| setPayload(null); | ||
| setDiffStats(null); | ||
| return; | ||
| } | ||
|
|
||
| if (result.data.kind !== "archive") { | ||
| setParseError( | ||
| t("The selected file is not a valid xcloc archive."), | ||
| ); | ||
| setPayload(null); | ||
| setDiffStats(null); | ||
| return; | ||
| } | ||
|
|
||
| const stats = computeDiffStats(currentProject, result.data); | ||
| if (!stats) { | ||
| setParseError( | ||
| t("Could not parse the xcloc bundle. Ensure it contains a valid contents.json and XLIFF file."), | ||
| ); | ||
| setPayload(null); | ||
| setDiffStats(null); | ||
| return; | ||
| } | ||
|
|
||
| setPayload(result.data); | ||
| setDiffStats(stats); | ||
| } catch { | ||
| setParseError(t("Failed to read the file. Please ensure it is a valid xcloc archive.")); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }, | ||
| [currentProject, t], | ||
| ); | ||
|
|
||
| const handleFileSelect = useCallback( | ||
| (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const selected = e.target.files?.[0]; | ||
| if (selected) { | ||
| setFile(selected); | ||
| readAndPreview(selected); | ||
| } | ||
| }, | ||
| [readAndPreview], | ||
| ); | ||
|
|
||
| const handleDrop = useCallback( | ||
| (e: React.DragEvent) => { | ||
| e.preventDefault(); | ||
| const dropped = e.dataTransfer.files[0]; | ||
| if (dropped) { | ||
| setFile(dropped); | ||
| readAndPreview(dropped); | ||
| } | ||
| }, | ||
| [readAndPreview], | ||
| ); | ||
|
|
||
| const canConfirm = payload !== null && !parseError; | ||
|
|
||
| const handleConfirm = useCallback(() => { | ||
| if (!payload) return; | ||
| onConfirm(payload); |
There was a problem hiding this comment.
In readAndPreview, the previous successful payload/diffStats are kept while a new file is being parsed. Because canConfirm doesn’t consider loading, the Confirm button can remain enabled during parsing and could submit the stale payload. Clear payload/diffStats at the start of readAndPreview and disable confirm while loading is true.
| function computeDiffStats( | ||
| currentProject: TranslationProject, | ||
| payload: UploadPayload, | ||
| ): DiffStats | null { | ||
| if (payload.kind !== "archive") return null; | ||
|
|
||
| const files = payload.tree.files.filter( | ||
| (f) => !f.path.includes("__MACOSX"), | ||
| ); | ||
|
|
||
| const contentsFile = files.find((f) => f.path.endsWith("contents.json")); | ||
| if (!contentsFile) return null; | ||
|
|
||
| const contentsResult = parseContentsJson(contentsFile.content); | ||
| if (contentsResult.hasError) return null; | ||
|
|
||
| const xliffFile = files.find( | ||
| (f) => | ||
| f.path.includes("Localized Contents/") && f.path.endsWith(".xliff"), | ||
| ); | ||
| if (!xliffFile) return null; | ||
|
|
||
| const xliffXml = new TextDecoder().decode(xliffFile.content); | ||
| const newXliffDoc = parseXliff(xliffXml); | ||
|
|
||
| // Build set of old keys | ||
| const oldKeys = new Set<string>(); | ||
| for (const resource of currentProject.resources) { | ||
| for (const entry of resource.entries) { | ||
| oldKeys.add(`${resource.id}\x00${entry.id}`); | ||
| } | ||
| } | ||
|
|
||
| // Build set of new keys | ||
| const newKeys = new Set<string>(); | ||
| for (const file of newXliffDoc.files) { | ||
| for (const tu of file.transUnits) { | ||
| newKeys.add(`${file.original}\x00${tu.id}`); | ||
| } | ||
| } | ||
|
|
||
| const preserved = [...newKeys].filter((k) => oldKeys.has(k)).length; | ||
| const added = [...newKeys].filter((k) => !oldKeys.has(k)).length; | ||
| const removed = [...oldKeys].filter((k) => !newKeys.has(k)).length; | ||
|
|
||
| return { added, removed, preserved, total: newKeys.size }; | ||
| } |
There was a problem hiding this comment.
The dialog’s preserved stat counts overlapping entry keys regardless of whether the existing entry actually has a translation, but XclocClient.updateFromXcloc() reports preserved only when a non-empty targetText is carried over. This can make the preview badges (and the “no translations preserved” warning) disagree with the actual update result/toast. Consider aligning the preview’s preserved definition with the update logic (e.g., count only entries with non-empty current targetText) or rename the badge to clarify it’s “matching entries”.
| ...this.project, | ||
| resources: newResources, | ||
| sourceLanguage: contentsResult.data.developmentRegion, | ||
| targetLanguages: [contentsResult.data.targetLocale], |
There was a problem hiding this comment.
updateFromXcloc() updates this.contentsJson from the new bundle, but it does not update this.project.metadata (which is initially derived from contentsJson in load()). After an update, project.metadata.project/toolInfo can become stale relative to the persisted contentsJson. Update the project metadata alongside sourceLanguage/targetLanguages to keep state consistent.
| targetLanguages: [contentsResult.data.targetLocale], | |
| targetLanguages: [contentsResult.data.targetLocale], | |
| metadata: { | |
| ...this.project.metadata, | |
| project: { | |
| ...(this.project.metadata && this.project.metadata.project), | |
| toolInfo: | |
| contentsResult.data.project && contentsResult.data.project.toolInfo | |
| ? contentsResult.data.project.toolInfo | |
| : this.project.metadata && | |
| this.project.metadata.project && | |
| this.project.metadata.project.toolInfo, | |
| }, | |
| }, |
Adds the ability to update existing Xcode localization projects by uploading a new
.xclocfile — preserving existing translations for matching entries while adding new terms and removing stale ones. Mirrors the existing PO update pattern.Core logic
XclocClient.updateFromXcloc(payload)— merges new xcloc archive into existing project, matching entries by composite key (resourceId + "\x00" + entryId). Syncs preserved translations back into the XLIFF doc for correct exports. Returns{ added, removed, preserved, total }stats.UI
XclocUpdateDialog— drop zone for.zip/.xcloc, validates bundle structure, shows diff preview badges before confirmonUpdatePo→onUpdateprop inTranslationEditorso the toolbar "Update" button works for both PO and xcloc formatsTests
zh-Hans-updated.xcloc.zip(3 entries removed, 2 added vs original)i18n
Warning
Firewall rules blocked me from connecting to one or more addresses (expand for details)
I tried to connect to the following addresses, but was blocked by firewall rules:
fonts.googleapis.com/opt/hostedtoolcache/node/24.13.1/x64/bin/node /opt/hostedtoolcache/node/24.13.1/x64/bin/node /home/REDACTED/work/universal-translation/universal-translation/node_modules/next/dist/server/lib/start-server.js(dns block)/home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/universal-translation/universal-translation/node_modules/.bin/next build(dns block)/home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/universal-translation/universal-translation/node_modules/.bin/next build git l-tr�� get --global /home/REDACTED/.dotnet/tools/git user.name(dns block)If you need me to access, download, or install something from one of these locations, you can either:
Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.