|
| 1 | +name: Apply collaboration i18n cleanup |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request: |
| 5 | + branches: ["main"] |
| 6 | + |
| 7 | +permissions: |
| 8 | + contents: write |
| 9 | + |
| 10 | +jobs: |
| 11 | + apply: |
| 12 | + if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'feat/stable-list-collaboration' |
| 13 | + runs-on: ubuntu-latest |
| 14 | + steps: |
| 15 | + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 16 | + with: |
| 17 | + ref: ${{ github.event.pull_request.head.sha }} |
| 18 | + persist-credentials: true |
| 19 | + |
| 20 | + - name: Apply i18n cleanup |
| 21 | + shell: bash |
| 22 | + run: | |
| 23 | + set -euo pipefail |
| 24 | + python <<'PY' |
| 25 | + from pathlib import Path |
| 26 | +
|
| 27 | + def replace_all(path, old, new, minimum=1): |
| 28 | + p = Path(path) |
| 29 | + text = p.read_text() |
| 30 | + count = text.count(old) |
| 31 | + if count < minimum: |
| 32 | + raise RuntimeError(f"{path}: expected >= {minimum} matches, found {count}: {old[:90]!r}") |
| 33 | + p.write_text(text.replace(old, new)) |
| 34 | + return count |
| 35 | +
|
| 36 | + modal = "apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx" |
| 37 | + replace_all(modal, '''import { |
| 38 | + canManageCollaboratorOnList, |
| 39 | + collaboratorRemovalMessage, |
| 40 | + formatInvitationDate, |
| 41 | + invitationDeliveryMessage, |
| 42 | + } from "./collaborationUi";''', '''import { |
| 43 | + canManageCollaboratorOnList, |
| 44 | + formatInvitationDate, |
| 45 | + } from "./collaborationUi";''') |
| 46 | + count = replace_all( |
| 47 | + modal, |
| 48 | + 'toast({ description: invitationDeliveryMessage(result.emailSent) });', |
| 49 | + '''toast({ |
| 50 | + description: t( |
| 51 | + result.emailSent |
| 52 | + ? "lists.collaborators.invitation_delivery_sent" |
| 53 | + : "lists.collaborators.invitation_delivery_failed", |
| 54 | + ), |
| 55 | + });''', |
| 56 | + minimum=2, |
| 57 | + ) |
| 58 | + if count != 2: |
| 59 | + raise RuntimeError(f"expected exactly 2 invitation delivery toasts, found {count}") |
| 60 | + |
| 61 | + replacements = [ |
| 62 | + ('"Invite people to this list and optionally include current and future nested lists."', 't("lists.collaborators.stable_description")'), |
| 63 | + ('aria-label="Invitation role"', 'aria-label={t("lists.collaborators.invitation_role")}'), |
| 64 | + ('<SelectItem value="viewer">Viewer</SelectItem>', '<SelectItem value="viewer">{t("lists.collaborators.viewer")}</SelectItem>'), |
| 65 | + ('<SelectItem value="editor">Editor</SelectItem>', '<SelectItem value="editor">{t("lists.collaborators.editor")}</SelectItem>'), |
| 66 | + (' Invite\n', ' {t("lists.collaborators.invite")}\n'), |
| 67 | + (' Also share all nested lists\n', ' {t("lists.collaborators.share_all_nested")}\n'), |
| 68 | + (' Includes current nested lists and lists added or moved here\n later. Leave this off to share only this list.\n', ' {t("lists.collaborators.share_all_nested_description")}\n'), |
| 69 | + ('<span className="text-sm text-muted-foreground">Owner</span>', '<span className="text-sm text-muted-foreground">\n {t("lists.collaborators.owner")}\n </span>'), |
| 70 | + ('{expired ? "Expired" : "Pending"}', '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.pending")}'), |
| 71 | + ('<Badge variant="secondary">Inherited</Badge>', '<Badge variant="secondary">\n {t("lists.collaborators.inherited")}\n </Badge>'), |
| 72 | + ('<Badge variant="outline">Nested lists</Badge>', '<Badge variant="outline">\n {t("lists.collaborators.nested_lists")}\n </Badge>'), |
| 73 | + (' Inherited from {collaborator.sourceListName}\n', ' {t("lists.collaborators.inherited_from", {\n name: collaborator.sourceListName,\n })}\n'), |
| 74 | + ('{expired ? "Expired" : "Expires"}{" "}', '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.expires")}{" "}'), |
| 75 | + ('{collaborator.role}', '{t(\n collaborator.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}'), |
| 76 | + (' Override here\n', ' {t("lists.collaborators.override_here")}\n'), |
| 77 | + ('aria-label="Pending invitation role"', 'aria-label={t("lists.collaborators.pending_invitation_role")}'), |
| 78 | + (' Nested lists\n', ' {t("lists.collaborators.nested_lists")}\n'), |
| 79 | + ('<RefreshCw className="mr-1 size-3" /> Resend', '<RefreshCw className="mr-1 size-3" />\n {t("lists.collaborators.resend")}'), |
| 80 | + (' Revoke\n', ' {t("lists.collaborators.revoke")}\n'), |
| 81 | + ('aria-label="Collaborator role"', 'aria-label={t("lists.collaborators.collaborator_role")}'), |
| 82 | + ('aria-label={`Remove ${collaborator.user.name}`}', 'aria-label={t("lists.collaborators.remove_aria", {\n name: collaborator.user.name,\n })}'), |
| 83 | + (' collaboratorRemovalMessage(\n collaborator.user.name,\n ),', ' t("lists.collaborators.remove_confirmation", {\n name: collaborator.user.name,\n }),'), |
| 84 | + ] |
| 85 | + for old, new in replacements: |
| 86 | + replace_all(modal, old, new) |
| 87 | + |
| 88 | + pending = "apps/web/components/dashboard/lists/PendingInvitationsCard.tsx" |
| 89 | + for old, new in [ |
| 90 | + (' {invitation.role}\n', ' {t(\n invitation.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n'), |
| 91 | + ('<Badge variant="secondary">Includes nested lists</Badge>', '<Badge variant="secondary">\n {t("lists.invitations.includes_nested_lists")}\n </Badge>'), |
| 92 | + ('<Badge variant="destructive">Expired</Badge>', '<Badge variant="destructive">\n {t("lists.invitations.expired")}\n </Badge>'), |
| 93 | + ('{invitation.list.owner?.name || "Unknown"}', '{invitation.list.owner?.name || t("lists.invitations.unknown")}'), |
| 94 | + ('{invitation.expired ? "Expired" : "Expires"}{" "}', '{invitation.expired\n ? t("lists.invitations.expired")\n : t("lists.invitations.expires")}{" "}'), |
| 95 | + (' Ask the list owner to resend this invitation to renew it for 30\n days.\n', ' {t("lists.invitations.expired_help")}\n'), |
| 96 | + ]: |
| 97 | + replace_all(pending, old, new) |
| 98 | + |
| 99 | + Path("apps/web/components/dashboard/lists/collaborationUi.ts").write_text('''const invitationDateFormatter = new Intl.DateTimeFormat("en-US", {\n dateStyle: "medium",\n timeZone: "UTC",\n});\n\nexport function formatInvitationDate(date: Date) {\n return invitationDateFormatter.format(date);\n}\n\nexport function canManageCollaboratorOnList(collaborator: {\n status: "pending" | "accepted" | "declined";\n inherited?: boolean;\n}) {\n return collaborator.status === "accepted" && !collaborator.inherited;\n}\n''') |
| 100 | + Path("apps/web/components/dashboard/lists/collaborationUi.test.ts").write_text('''import { describe, expect, test } from "vitest";\n\nimport {\n canManageCollaboratorOnList,\n formatInvitationDate,\n} from "./collaborationUi";\n\ndescribe("stable collaboration UI semantics", () => {\n test("formats invitation dates deterministically", () => {\n expect(formatInvitationDate(new Date("2026-08-15T23:30:00-07:00"))).toBe(\n "Aug 16, 2026",\n );\n });\n\n test("only direct accepted collaborators can be managed from this list", () => {\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: false }),\n ).toBe(true);\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: true }),\n ).toBe(false);\n expect(\n canManageCollaboratorOnList({ status: "pending", inherited: false }),\n ).toBe(false);\n });\n});\n''') |
| 101 | + |
| 102 | + collaborator_keys = ''' "stable_description": "Invite people to this list and optionally include current and future nested lists.",\n "invitation_role": "Invitation role",\n "invite": "Invite",\n "share_all_nested": "Also share all nested lists",\n "share_all_nested_description": "Includes current nested lists and lists added or moved here later. Leave this off to share only this list.",\n "nested_lists": "Nested lists",\n "inherited": "Inherited",\n "inherited_from": "Inherited from {{name}}",\n "expired": "Expired",\n "expires": "Expires",\n "override_here": "Override here",\n "pending_invitation_role": "Pending invitation role",\n "collaborator_role": "Collaborator role",\n "resend": "Resend",\n "invitation_delivery_sent": "Invitation created and email sent.",\n "invitation_delivery_failed": "Invitation created, but the email was not sent. You can resend it later.",\n "remove_aria": "Remove {{name}}",\n "remove_confirmation": "Remove {{name}} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library.",\n''' |
| 103 | + invitation_keys = ''' "includes_nested_lists": "Includes nested lists",\n "expired": "Expired",\n "expires": "Expires",\n "unknown": "Unknown",\n "expired_help": "Ask the list owner to resend this invitation to renew it for 30 days.",\n''' |
| 104 | + for locale in [ |
| 105 | + "apps/web/lib/i18n/locales/en/translation.json", |
| 106 | + "apps/web/lib/i18n/locales/en_US/translation.json", |
| 107 | + ]: |
| 108 | + replace_all(locale, ' "manage": "Manage Collaborators",\n', ' "manage": "Manage Collaborators",\n' + collaborator_keys) |
| 109 | + replace_all(locale, ' "pending": "Pending Invitations",\n', ' "pending": "Pending Invitations",\n' + invitation_keys) |
| 110 | + PY |
| 111 | + git config user.name "github-actions[bot]" |
| 112 | + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" |
| 113 | + git add apps/web/components/dashboard/lists apps/web/lib/i18n/locales/en/translation.json apps/web/lib/i18n/locales/en_US/translation.json |
| 114 | + git commit --no-verify -m "fix(web): localize stable collaboration UI" |
| 115 | + git push origin HEAD:${{ github.head_ref }} |
0 commit comments