feat(i18n): fix translation QA issues and add automation (#16756)
## Summary This PR fixes translation QA issues and adds automation to prevent future issues. ### Translation Fixes - Fixed **escaped Unicode sequences** in translations (e.g., `\u62db\u5f85` → `招待`) - Removed **corrupted control characters** from .po files (null bytes, invalid characters) - Fixed **missing/incorrect placeholders** in various languages - Deleted **35 problematic translations** via Crowdin API that had variable mismatches ### New Scripts (in `packages/twenty-utils/`) - `fix-crowdin-translations.ts` - Auto-fixes encoding issues and syncs to Crowdin - `fix-qa-issues.ts` - Fixes specific QA issues via Crowdin API - `translation-qa-report.ts` - Generates weekly QA report from Crowdin API ### New Workflow - `i18n-qa-report.yaml` - Weekly workflow that creates a PR with translation QA issues for review ### Other Changes - Moved GitHub Actions from `.github/workflows/actions/` to `.github/actions/` - Fixed `date-utils.ts` to avoid nested `t` macros in plural expressions (root cause of confusing placeholders) ### QA Status After Fixes | Category | Count | Status | |----------|-------|--------| | variables | 0 ✅ | Fixed | | tags | 1 | Minor | | empty | 0 ✅ | Fixed | | spaces | 127 | Low priority | | numbers | 246 | Locale-specific | | special_symbols | 268 | Locale-specific |
This commit is contained in:
@@ -100,7 +100,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build shared dependencies
|
||||
run: |
|
||||
@@ -188,8 +188,8 @@ jobs:
|
||||
|
||||
- name: Download GraphQL and REST responses from current branch
|
||||
run: |
|
||||
# Admin token from jest-integration.config.ts
|
||||
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
|
||||
# Read admin token from shared test tokens file (single source of truth)
|
||||
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
|
||||
|
||||
# Load introspection query from file
|
||||
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
rm -rf node_modules packages/*/node_modules packages/*/dist dist .nx/cache
|
||||
|
||||
- name: Install dependencies for main branch
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build main branch dependencies
|
||||
run: |
|
||||
@@ -341,8 +341,8 @@ jobs:
|
||||
|
||||
- name: Download GraphQL and REST responses from main branch
|
||||
run: |
|
||||
# Admin token from jest-integration.config.ts
|
||||
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
|
||||
# Read admin token from shared test tokens file (single source of truth)
|
||||
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
|
||||
|
||||
# Load introspection query from file
|
||||
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
|
||||
|
||||
@@ -38,11 +38,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build
|
||||
run: npx nx build create-twenty-app
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:create-app
|
||||
tasks: ${{ matrix.task }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Docs / Lint English MDX files
|
||||
run: npx eslint "packages/twenty-docs/{developers,user-guide,twenty-ui,getting-started,snippets}/**/*.mdx" --max-warnings 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: CI E2E Playwright Tests
|
||||
name: CI E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
lscpu
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build twenty-emails
|
||||
run: npx nx build twenty-emails
|
||||
- name: Run email tests
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Diagnostic disk space issue
|
||||
run: df -h
|
||||
- name: Front / Write .env
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Front / Build storybook
|
||||
run: npx nx storybook:build twenty-front
|
||||
- name: Save storybook build cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
uses: ./.github/actions/save-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
|
||||
front-sb-test:
|
||||
@@ -74,11 +74,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Install Playwright
|
||||
run: cd packages/twenty-front && npx playwright install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
|
||||
- name: Front / Write .env
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
|
||||
@@ -132,9 +132,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
|
||||
- name: Front / Write .env
|
||||
@@ -165,24 +165,24 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Restore ${{ matrix.task }} cache
|
||||
id: restore-task-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.TASK_CACHE_KEY }}
|
||||
- name: Reset .env
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: reset:env
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: ${{ matrix.task }}
|
||||
- name: Save ${{ matrix.task }} cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
uses: ./.github/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-task-cache.outputs.cache-primary-key }}
|
||||
front-build:
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Build frontend
|
||||
|
||||
@@ -38,11 +38,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build
|
||||
run: npx nx build twenty-sdk
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:sdk
|
||||
tasks: ${{ matrix.task }}
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Server / Append billing config to .env.test
|
||||
working-directory: packages/twenty-server
|
||||
run: |
|
||||
|
||||
@@ -59,16 +59,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Restore server setup
|
||||
id: restore-server-setup-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
- name: Server / Run lint & typecheck
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: lint,typecheck
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
- name: Save server setup
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
uses: ./.github/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
|
||||
server-test:
|
||||
@@ -153,13 +153,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Restore server setup
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Server / Run Tests
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: test
|
||||
@@ -216,7 +216,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Update .env.test for integrations tests
|
||||
run: |
|
||||
echo "" >> .env.test
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
- name: Restore server setup
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Server / Build
|
||||
@@ -243,7 +243,7 @@ jobs:
|
||||
- name: Run ClickHouse seeds
|
||||
run: npx nx clickhouse:seed twenty-server
|
||||
- name: Server / Run Integration Tests
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: 'test:integration'
|
||||
|
||||
@@ -38,9 +38,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: ${{ matrix.task }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: 'Test Docker Compose'
|
||||
name: CI Docker Compose
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Utils / Run Danger.js
|
||||
run: cd packages/twenty-utils && npx nx danger:ci
|
||||
env:
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Run congratulate-dangerfile.js
|
||||
run: cd packages/twenty-utils && npx nx danger:congratulate
|
||||
env:
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Server / Create DB
|
||||
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
|
||||
@@ -24,7 +24,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/twenty-docs/**'
|
||||
- 'crowdin.yml'
|
||||
- 'crowdin-docs.yml'
|
||||
- '.github/workflows/docs-i18n-pull.yaml'
|
||||
|
||||
concurrency:
|
||||
@@ -96,12 +96,14 @@ jobs:
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
config: 'crowdin-docs.yml'
|
||||
# Only download languages supported by Mintlify (see supported-languages.ts)
|
||||
# Using multiple -l flags since download_language only accepts single language
|
||||
download_translations_args: '-l fr -l ar -l cs -l de -l es -l it -l ja -l ko -l pt -l ro -l ru -l tr -l zh-CN'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
# Docs translations project
|
||||
CROWDIN_PROJECT_ID: '2'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Fix file permissions
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
paths:
|
||||
- 'packages/twenty-docs/**/*.mdx'
|
||||
- '!packages/twenty-docs/fr/**'
|
||||
- 'crowdin.yml'
|
||||
- 'crowdin-docs.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
@@ -62,7 +62,9 @@ jobs:
|
||||
download_translations: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: 'crowdin-docs.yml'
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: 1
|
||||
# Docs translations project
|
||||
CROWDIN_PROJECT_ID: '2'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
git checkout -B i18n origin/i18n || git checkout -b i18n
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
@@ -89,8 +89,10 @@ jobs:
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
config: 'crowdin-app.yml'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# App translations project
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -99,6 +101,12 @@ jobs:
|
||||
- name: Fix file permissions
|
||||
run: sudo chown -R runner:docker .
|
||||
|
||||
# Fix encoding issues (escaped Unicode like \u62db -> 招) and push fixes back to Crowdin
|
||||
- name: Fix translation encoding and sync to Crowdin
|
||||
run: npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Compile translations
|
||||
id: compile_translations
|
||||
# Because we have set English as a fallback locale, this condition does not work anymore
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
git checkout -B i18n origin/i18n || git checkout -b i18n
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
@@ -87,11 +87,10 @@ jobs:
|
||||
download_translations: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: 'crowdin-app.yml'
|
||||
env:
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: 1
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
# App translations project
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: Create a pull request
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Weekly translation QA report using Crowdin's native QA checks
|
||||
|
||||
name: 'Weekly Translation QA Report'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Every Monday at 9am UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qa_report:
|
||||
name: Generate QA Report
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Generate QA report from Crowdin
|
||||
id: generate_report
|
||||
run: |
|
||||
npx ts-node packages/twenty-utils/translation-qa-report.ts || true
|
||||
if [ -f TRANSLATION_QA_REPORT.md ]; then
|
||||
echo "report_generated=true" >> $GITHUB_OUTPUT
|
||||
# Count critical issues (exclude spellcheck)
|
||||
CRITICAL=$(grep -oP '⚠️\s+\K\d+' TRANSLATION_QA_REPORT.md 2>/dev/null || echo "0")
|
||||
echo "critical_issues=$CRITICAL" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "report_generated=false" >> $GITHUB_OUTPUT
|
||||
echo "critical_issues=0" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Create QA branch and commit report
|
||||
if: steps.generate_report.outputs.report_generated == 'true'
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
|
||||
BRANCH_NAME="i18n-qa-report-$(date +%Y-%m-%d)"
|
||||
git checkout -B $BRANCH_NAME
|
||||
|
||||
git add TRANSLATION_QA_REPORT.md
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "docs: weekly translation QA report"
|
||||
git push origin HEAD:$BRANCH_NAME --force
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||
else
|
||||
echo "No changes to commit"
|
||||
echo "BRANCH_NAME=" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.generate_report.outputs.report_generated == 'true' && env.BRANCH_NAME != ''
|
||||
run: |
|
||||
CRITICAL="${{ steps.generate_report.outputs.critical_issues }}"
|
||||
|
||||
BODY=$(cat <<EOF
|
||||
## Weekly Translation QA Report
|
||||
|
||||
**Critical issues (excluding spellcheck): $CRITICAL**
|
||||
|
||||
📊 **View in Crowdin**: https://twenty.crowdin.com/u/projects/1/all?filter=qa-issue
|
||||
|
||||
### For AI-Assisted Fixing
|
||||
|
||||
Open this PR in Cursor and say:
|
||||
|
||||
> "Fix the translation QA issues using the Crowdin API"
|
||||
|
||||
The AI can help fix:
|
||||
- ✅ Variables mismatch (missing/wrong placeholders)
|
||||
- ✅ Escaped Unicode sequences
|
||||
- ⚠️ Tags mismatch
|
||||
- ⚠️ Empty translations
|
||||
|
||||
### Available Scripts
|
||||
|
||||
\`\`\`bash
|
||||
# View QA report
|
||||
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/translation-qa-report.ts
|
||||
|
||||
# Fix encoding issues automatically
|
||||
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
*Close without merging after issues are addressed*
|
||||
EOF
|
||||
)
|
||||
|
||||
EXISTING_PR=$(gh pr list --head $BRANCH_NAME --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
gh pr edit $EXISTING_PR --body "$BODY"
|
||||
else
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head $BRANCH_NAME \
|
||||
--title "i18n: Translation QA Report ($CRITICAL critical issues)" \
|
||||
--body "$BODY" || true
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -48,3 +48,4 @@ dump.rdb
|
||||
|
||||
mcp.json
|
||||
/.junie/
|
||||
TRANSLATION_QA_REPORT.md
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#
|
||||
# Crowdin CLI configuration for App translations (twenty-front, twenty-server, twenty-emails)
|
||||
# Project ID: 1
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"preserve_hierarchy": true
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,33 +1,12 @@
|
||||
#
|
||||
# Basic Crowdin CLI configuration
|
||||
# Crowdin CLI configuration for Documentation translations
|
||||
# Project ID: 2
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
# See https://support.crowdin.com/developer/configuration-file/ for all available options
|
||||
#
|
||||
|
||||
#
|
||||
# Defines whether to preserve the original directory structure in the Crowdin project
|
||||
# Recommended to set to true
|
||||
#
|
||||
"preserve_hierarchy": true
|
||||
|
||||
#
|
||||
# Files configuration.
|
||||
# See https://support.crowdin.com/developer/configuration-file/ for all available options
|
||||
#
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter
|
||||
# e.g. "/resources/en/*.json"
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
#
|
||||
# Translation files filter
|
||||
# e.g. "/resources/%two_letters_code%/%original_file_name%"
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - user-guide
|
||||
@@ -61,3 +40,4 @@ files: [
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -125,21 +125,17 @@ msgstr "{0, plural, one {{1} werk kon nie uitgevee word nie} other {{2} werke ko
|
||||
msgid "{0, plural, one {{1} job could not be retried} other {{2} jobs could not be retried}}"
|
||||
msgstr "{0, plural, one {{1} werk kon nie herprobeer word nie} other {{2} werke kon nie herprobeer word nie}}"
|
||||
|
||||
#. js-lingui-id: m6BGdm
|
||||
#. js-lingui-id: 0LMb5P
|
||||
#. placeholder {0}: Math.abs(days)
|
||||
#. placeholder {1}: import { isDate, isNumber, isString } from '@sniptt/guards'; import { differenceInCalendarDays, differenceInDays, differenceInYears, format, formatDistance, formatDistanceToNow, isToday, isValid, parseISO, type Locale, } from 'date-fns'; import { DateFormat } from '@/localization/constants/DateFormat'; import { CustomError, isDefined } from 'twenty-shared/utils'; import { i18n } from '@lingui/core'; import { plural, t } from '@lingui/core/macro'; import { logError } from './logError'; export const parseDate = (dateToParse: Date | string | number): Date => { if (dateToParse === 'now') return new Date(); let formattedDate: Date | null = null; if (!dateToParse) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } else if (isString(dateToParse)) { formattedDate = parseISO(dateToParse); } else if (isDate(dateToParse)) { formattedDate = dateToParse; } else if (isNumber(dateToParse)) { formattedDate = new Date(dateToParse); } if (!formattedDate) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } if (!isValid(formattedDate)) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } return formattedDate; }; export const formatDate = ( dateToFormat: Date | string | number, formatString: string, ) => { try { const parsedDate = parseDate(dateToFormat); return format(parsedDate, formatString); } catch (error) { logError(error); return ''; } }; export const beautifyExactDateTime = ( dateToBeautify: Date | string | number, ) => { const parsedDate = parseDate(dateToBeautify); const isTodayDate = isToday(parsedDate); const dateFormat = isTodayDate ? 'HH:mm' : 'MMM d, yyyy · HH:mm'; return formatDate(dateToBeautify, dateFormat); }; export const beautifyExactDate = (dateToBeautify: Date | string | number) => { const parsedDate = parseDate(dateToBeautify); const isTodayDate = isToday(parsedDate); if (isTodayDate) { return t`Today`; } return formatDate(dateToBeautify, 'MMM d, yyyy'); }; export const beautifyPastDateRelativeToNow = ( pastDate: Date | string | number, locale?: Locale, ) => { try { const parsedDate = parseDate(pastDate); const now = new Date(); const diffInSeconds = Math.abs( (now.getTime() - parsedDate.getTime()) / 1000, ); // For very recent times (less than 30 seconds), show "now" if (diffInSeconds < 30) { return t`now`; } return formatDistanceToNow(parsedDate, { addSuffix: true, locale, includeSeconds: true, }); } catch (error) { logError(error); return ''; } }; export const hasDatePassed = (date: Date | string | number) => { try { const parsedDate = parseDate(date); return differenceInCalendarDays(new Date(), parsedDate) >= 1; } catch (error) { logError(error); return false; } }; export const beautifyDateDiff = ( date: string, dateToCompareWith?: string, short = false, locale?: Locale, ) => { // For simple cases, use date-fns which has excellent locale support if (!short && isDefined(locale)) { const fromDate = new Date(date); const toDate = dateToCompareWith ? new Date(dateToCompareWith) : new Date(); return formatDistance(fromDate, toDate, { locale }); } // Manual implementation for complex cases or when locale is not available const fromDate = parseISO(date); const toDate = dateToCompareWith ? parseISO(dateToCompareWith) : new Date(); const years = differenceInYears(fromDate, toDate); // Calculate remaining days after accounting for full years const startDateForDayCalculation = new Date(toDate); startDateForDayCalculation.setFullYear( startDateForDayCalculation.getFullYear() + years, ); const days = differenceInDays(fromDate, startDateForDayCalculation); let result = ''; if (years !== 0) { result = plural(Math.abs(years), { one: `${years} ${t`year`}`, other: `${years} ${t`years`}`, }); if (short) return result; } if (years !== 0 && days !== 0) { result += ` ${t`and`} `; } if (days !== 0) { const daysPart = plural(Math.abs(days), { one: `${days} ${t`day`}`, other: `${days} ${t`days`}`, }); result += daysPart; } return result; }; export const formatToHumanReadableDate = (date: Date | string) => { const parsedJSDate = parseDate(date); return i18n.date(parsedJSDate, { dateStyle: 'medium' }); }; export const getDateTimeFormatStringFoDatePickerInputMask = ( dateFormat: DateFormat, ): string => { switch (dateFormat) { case DateFormat.DAY_FIRST: return `dd/MM/yyyy HH:mm`; case DateFormat.YEAR_FIRST: return `yyyy-MM-dd HH:mm`; case DateFormat.MONTH_FIRST: default: return `MM/dd/yyyy HH:mm`; } }; export const getDateFormatStringForDatePickerInputMask = ( dateFormat: DateFormat, ): string => { switch (dateFormat) { case DateFormat.DAY_FIRST: return `dd/MM/yyyy`; case DateFormat.YEAR_FIRST: return `yyyy-MM-dd`; case DateFormat.MONTH_FIRST: default: return `MM/dd/yyyy`; } };
|
||||
#. placeholder {2}: import { isDate, isNumber, isString } from '@sniptt/guards'; import { differenceInCalendarDays, differenceInDays, differenceInYears, format, formatDistance, formatDistanceToNow, isToday, isValid, parseISO, type Locale, } from 'date-fns'; import { DateFormat } from '@/localization/constants/DateFormat'; import { CustomError, isDefined } from 'twenty-shared/utils'; import { i18n } from '@lingui/core'; import { plural, t } from '@lingui/core/macro'; import { logError } from './logError'; export const parseDate = (dateToParse: Date | string | number): Date => { if (dateToParse === 'now') return new Date(); let formattedDate: Date | null = null; if (!dateToParse) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } else if (isString(dateToParse)) { formattedDate = parseISO(dateToParse); } else if (isDate(dateToParse)) { formattedDate = dateToParse; } else if (isNumber(dateToParse)) { formattedDate = new Date(dateToParse); } if (!formattedDate) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } if (!isValid(formattedDate)) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } return formattedDate; }; export const formatDate = ( dateToFormat: Date | string | number, formatString: string, ) => { try { const parsedDate = parseDate(dateToFormat); return format(parsedDate, formatString); } catch (error) { logError(error); return ''; } }; export const beautifyExactDateTime = ( dateToBeautify: Date | string | number, ) => { const parsedDate = parseDate(dateToBeautify); const isTodayDate = isToday(parsedDate); const dateFormat = isTodayDate ? 'HH:mm' : 'MMM d, yyyy · HH:mm'; return formatDate(dateToBeautify, dateFormat); }; export const beautifyExactDate = (dateToBeautify: Date | string | number) => { const parsedDate = parseDate(dateToBeautify); const isTodayDate = isToday(parsedDate); if (isTodayDate) { return t`Today`; } return formatDate(dateToBeautify, 'MMM d, yyyy'); }; export const beautifyPastDateRelativeToNow = ( pastDate: Date | string | number, locale?: Locale, ) => { try { const parsedDate = parseDate(pastDate); const now = new Date(); const diffInSeconds = Math.abs( (now.getTime() - parsedDate.getTime()) / 1000, ); // For very recent times (less than 30 seconds), show "now" if (diffInSeconds < 30) { return t`now`; } return formatDistanceToNow(parsedDate, { addSuffix: true, locale, includeSeconds: true, }); } catch (error) { logError(error); return ''; } }; export const hasDatePassed = (date: Date | string | number) => { try { const parsedDate = parseDate(date); return differenceInCalendarDays(new Date(), parsedDate) >= 1; } catch (error) { logError(error); return false; } }; export const beautifyDateDiff = ( date: string, dateToCompareWith?: string, short = false, locale?: Locale, ) => { // For simple cases, use date-fns which has excellent locale support if (!short && isDefined(locale)) { const fromDate = new Date(date); const toDate = dateToCompareWith ? new Date(dateToCompareWith) : new Date(); return formatDistance(fromDate, toDate, { locale }); } // Manual implementation for complex cases or when locale is not available const fromDate = parseISO(date); const toDate = dateToCompareWith ? parseISO(dateToCompareWith) : new Date(); const years = differenceInYears(fromDate, toDate); // Calculate remaining days after accounting for full years const startDateForDayCalculation = new Date(toDate); startDateForDayCalculation.setFullYear( startDateForDayCalculation.getFullYear() + years, ); const days = differenceInDays(fromDate, startDateForDayCalculation); let result = ''; if (years !== 0) { result = plural(Math.abs(years), { one: `${years} ${t`year`}`, other: `${years} ${t`years`}`, }); if (short) return result; } if (years !== 0 && days !== 0) { result += ` ${t`and`} `; } if (days !== 0) { const daysPart = plural(Math.abs(days), { one: `${days} ${t`day`}`, other: `${days} ${t`days`}`, }); result += daysPart; } return result; }; export const formatToHumanReadableDate = (date: Date | string) => { const parsedJSDate = parseDate(date); return i18n.date(parsedJSDate, { dateStyle: 'medium' }); }; export const getDateTimeFormatStringFoDatePickerInputMask = ( dateFormat: DateFormat, ): string => { switch (dateFormat) { case DateFormat.DAY_FIRST: return `dd/MM/yyyy HH:mm`; case DateFormat.YEAR_FIRST: return `yyyy-MM-dd HH:mm`; case DateFormat.MONTH_FIRST: default: return `MM/dd/yyyy HH:mm`; } }; export const getDateFormatStringForDatePickerInputMask = ( dateFormat: DateFormat, ): string => { switch (dateFormat) { case DateFormat.DAY_FIRST: return `dd/MM/yyyy`; case DateFormat.YEAR_FIRST: return `yyyy-MM-dd`; case DateFormat.MONTH_FIRST: default: return `MM/dd/yyyy`; } };
|
||||
#: src/utils/date-utils.ts
|
||||
msgid "{0, plural, one {{days} {1}} other {{days} {2}}}"
|
||||
msgstr "{0, plural, one {{dae} {1}} other {{dae} {2}}}"
|
||||
msgid "{0, plural, one {{days} day} other {{days} days}}"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: FYY5cK
|
||||
#. js-lingui-id: dXYycw
|
||||
#. placeholder {0}: Math.abs(years)
|
||||
#. placeholder {1}: import { isDate, isNumber, isString } from '@sniptt/guards'; import { differenceInCalendarDays, differenceInDays, differenceInYears, format, formatDistance, formatDistanceToNow, isToday, isValid, parseISO, type Locale, } from 'date-fns'; import { DateFormat } from '@/localization/constants/DateFormat'; import { CustomError, isDefined } from 'twenty-shared/utils'; import { i18n } from '@lingui/core'; import { plural, t } from '@lingui/core/macro'; import { logError } from './logError'; export const parseDate = (dateToParse: Date | string | number): Date => { if (dateToParse === 'now') return new Date(); let formattedDate: Date | null = null; if (!dateToParse) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } else if (isString(dateToParse)) { formattedDate = parseISO(dateToParse); } else if (isDate(dateToParse)) { formattedDate = dateToParse; } else if (isNumber(dateToParse)) { formattedDate = new Date(dateToParse); } if (!formattedDate) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } if (!isValid(formattedDate)) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } return formattedDate; }; export const formatDate = ( dateToFormat: Date | string | number, formatString: string, ) => { try { const parsedDate = parseDate(dateToFormat); return format(parsedDate, formatString); } catch (error) { logError(error); return ''; } }; export const beautifyExactDateTime = ( dateToBeautify: Date | string | number, ) => { const parsedDate = parseDate(dateToBeautify); const isTodayDate = isToday(parsedDate); const dateFormat = isTodayDate ? 'HH:mm' : 'MMM d, yyyy · HH:mm'; return formatDate(dateToBeautify, dateFormat); }; export const beautifyExactDate = (dateToBeautify: Date | string | number) => { const parsedDate = parseDate(dateToBeautify); const isTodayDate = isToday(parsedDate); if (isTodayDate) { return t`Today`; } return formatDate(dateToBeautify, 'MMM d, yyyy'); }; export const beautifyPastDateRelativeToNow = ( pastDate: Date | string | number, locale?: Locale, ) => { try { const parsedDate = parseDate(pastDate); const now = new Date(); const diffInSeconds = Math.abs( (now.getTime() - parsedDate.getTime()) / 1000, ); // For very recent times (less than 30 seconds), show "now" if (diffInSeconds < 30) { return t`now`; } return formatDistanceToNow(parsedDate, { addSuffix: true, locale, includeSeconds: true, }); } catch (error) { logError(error); return ''; } }; export const hasDatePassed = (date: Date | string | number) => { try { const parsedDate = parseDate(date); return differenceInCalendarDays(new Date(), parsedDate) >= 1; } catch (error) { logError(error); return false; } }; export const beautifyDateDiff = ( date: string, dateToCompareWith?: string, short = false, locale?: Locale, ) => { // For simple cases, use date-fns which has excellent locale support if (!short && isDefined(locale)) { const fromDate = new Date(date); const toDate = dateToCompareWith ? new Date(dateToCompareWith) : new Date(); return formatDistance(fromDate, toDate, { locale }); } // Manual implementation for complex cases or when locale is not available const fromDate = parseISO(date); const toDate = dateToCompareWith ? parseISO(dateToCompareWith) : new Date(); const years = differenceInYears(fromDate, toDate); // Calculate remaining days after accounting for full years const startDateForDayCalculation = new Date(toDate); startDateForDayCalculation.setFullYear( startDateForDayCalculation.getFullYear() + years, ); const days = differenceInDays(fromDate, startDateForDayCalculation); let result = ''; if (years !== 0) { result = plural(Math.abs(years), { one: `${years} ${t`year`}`, other: `${years} ${t`years`}`, }); if (short) return result; } if (years !== 0 && days !== 0) { result += ` ${t`and`} `; } if (days !== 0) { const daysPart = plural(Math.abs(days), { one: `${days} ${t`day`}`, other: `${days} ${t`days`}`, }); result += daysPart; } return result; }; export const formatToHumanReadableDate = (date: Date | string) => { const parsedJSDate = parseDate(date); return i18n.date(parsedJSDate, { dateStyle: 'medium' }); }; export const getDateTimeFormatStringFoDatePickerInputMask = ( dateFormat: DateFormat, ): string => { switch (dateFormat) { case DateFormat.DAY_FIRST: return `dd/MM/yyyy HH:mm`; case DateFormat.YEAR_FIRST: return `yyyy-MM-dd HH:mm`; case DateFormat.MONTH_FIRST: default: return `MM/dd/yyyy HH:mm`; } }; export const getDateFormatStringForDatePickerInputMask = ( dateFormat: DateFormat, ): string => { switch (dateFormat) { case DateFormat.DAY_FIRST: return `dd/MM/yyyy`; case DateFormat.YEAR_FIRST: return `yyyy-MM-dd`; case DateFormat.MONTH_FIRST: default: return `MM/dd/yyyy`; } };
|
||||
#. placeholder {2}: import { isDate, isNumber, isString } from '@sniptt/guards'; import { differenceInCalendarDays, differenceInDays, differenceInYears, format, formatDistance, formatDistanceToNow, isToday, isValid, parseISO, type Locale, } from 'date-fns'; import { DateFormat } from '@/localization/constants/DateFormat'; import { CustomError, isDefined } from 'twenty-shared/utils'; import { i18n } from '@lingui/core'; import { plural, t } from '@lingui/core/macro'; import { logError } from './logError'; export const parseDate = (dateToParse: Date | string | number): Date => { if (dateToParse === 'now') return new Date(); let formattedDate: Date | null = null; if (!dateToParse) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } else if (isString(dateToParse)) { formattedDate = parseISO(dateToParse); } else if (isDate(dateToParse)) { formattedDate = dateToParse; } else if (isNumber(dateToParse)) { formattedDate = new Date(dateToParse); } if (!formattedDate) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } if (!isValid(formattedDate)) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } return formattedDate; }; export const formatDate = ( dateToFormat: Date | string | number, formatString: string, ) => { try { const parsedDate = parseDate(dateToFormat); return format(parsedDate, formatString); } catch (error) { logError(error); return ''; } }; export const beautifyExactDateTime = ( dateToBeautify: Date | string | number, ) => { const parsedDate = parseDate(dateToBeautify); const isTodayDate = isToday(parsedDate); const dateFormat = isTodayDate ? 'HH:mm' : 'MMM d, yyyy · HH:mm'; return formatDate(dateToBeautify, dateFormat); }; export const beautifyExactDate = (dateToBeautify: Date | string | number) => { const parsedDate = parseDate(dateToBeautify); const isTodayDate = isToday(parsedDate); if (isTodayDate) { return t`Today`; } return formatDate(dateToBeautify, 'MMM d, yyyy'); }; export const beautifyPastDateRelativeToNow = ( pastDate: Date | string | number, locale?: Locale, ) => { try { const parsedDate = parseDate(pastDate); const now = new Date(); const diffInSeconds = Math.abs( (now.getTime() - parsedDate.getTime()) / 1000, ); // For very recent times (less than 30 seconds), show "now" if (diffInSeconds < 30) { return t`now`; } return formatDistanceToNow(parsedDate, { addSuffix: true, locale, includeSeconds: true, }); } catch (error) { logError(error); return ''; } }; export const hasDatePassed = (date: Date | string | number) => { try { const parsedDate = parseDate(date); return differenceInCalendarDays(new Date(), parsedDate) >= 1; } catch (error) { logError(error); return false; } }; export const beautifyDateDiff = ( date: string, dateToCompareWith?: string, short = false, locale?: Locale, ) => { // For simple cases, use date-fns which has excellent locale support if (!short && isDefined(locale)) { const fromDate = new Date(date); const toDate = dateToCompareWith ? new Date(dateToCompareWith) : new Date(); return formatDistance(fromDate, toDate, { locale }); } // Manual implementation for complex cases or when locale is not available const fromDate = parseISO(date); const toDate = dateToCompareWith ? parseISO(dateToCompareWith) : new Date(); const years = differenceInYears(fromDate, toDate); // Calculate remaining days after accounting for full years const startDateForDayCalculation = new Date(toDate); startDateForDayCalculation.setFullYear( startDateForDayCalculation.getFullYear() + years, ); const days = differenceInDays(fromDate, startDateForDayCalculation); let result = ''; if (years !== 0) { result = plural(Math.abs(years), { one: `${years} ${t`year`}`, other: `${years} ${t`years`}`, }); if (short) return result; } if (years !== 0 && days !== 0) { result += ` ${t`and`} `; } if (days !== 0) { const daysPart = plural(Math.abs(days), { one: `${days} ${t`day`}`, other: `${days} ${t`days`}`, }); result += daysPart; } return result; }; export const formatToHumanReadableDate = (date: Date | string) => { const parsedJSDate = parseDate(date); return i18n.date(parsedJSDate, { dateStyle: 'medium' }); }; export const getDateTimeFormatStringFoDatePickerInputMask = ( dateFormat: DateFormat, ): string => { switch (dateFormat) { case DateFormat.DAY_FIRST: return `dd/MM/yyyy HH:mm`; case DateFormat.YEAR_FIRST: return `yyyy-MM-dd HH:mm`; case DateFormat.MONTH_FIRST: default: return `MM/dd/yyyy HH:mm`; } }; export const getDateFormatStringForDatePickerInputMask = ( dateFormat: DateFormat, ): string => { switch (dateFormat) { case DateFormat.DAY_FIRST: return `dd/MM/yyyy`; case DateFormat.YEAR_FIRST: return `yyyy-MM-dd`; case DateFormat.MONTH_FIRST: default: return `MM/dd/yyyy`; } };
|
||||
#: src/utils/date-utils.ts
|
||||
msgid "{0, plural, one {{years} {1}} other {{years} {2}}}"
|
||||
msgstr "{0, plural, one {{jare} {1}} other {{jare} {2}}}"
|
||||
msgid "{0, plural, one {{years} year} other {{years} years}}"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: Qvm3VE
|
||||
#. placeholder {0}: selectedOptions.length
|
||||
@@ -522,12 +518,12 @@ msgstr "A konseps bestaan reeds"
|
||||
#. js-lingui-id: OITESn
|
||||
#: src/modules/workflow/components/OverrideWorkflowDraftConfirmationModal.tsx
|
||||
msgid "A draft already exists for this workflow. Are you sure you want to erase it?"
|
||||
msgstr "E'n konsep bestaan reeds vir hierdie werkuns. Is jy seker jy wil dit uitvee?"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: C6xZqR
|
||||
#: src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx
|
||||
msgid "A role must be selected for the API key"
|
||||
msgstr "7n Rol moet vir die API-sleutel gekies word"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: nMTB1f
|
||||
#: src/pages/onboarding/CreateWorkspace.tsx
|
||||
@@ -1355,7 +1351,7 @@ msgstr "'n Fout het voorgekom tydens die kontrole van gebruiker se bestaan"
|
||||
#. js-lingui-id: I/scZd
|
||||
#: src/modules/ai/components/AIChatErrorMessage.tsx
|
||||
msgid "An error occurred while processing your message"
|
||||
msgstr "9n Fout het voorgekom terwyl jou boodskap verwerk is"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: g6Wfbf
|
||||
#: src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx
|
||||
@@ -3464,11 +3460,6 @@ msgstr "Datumsgraan X"
|
||||
msgid "Date Granularity Y"
|
||||
msgstr "Datumsgraan Y"
|
||||
|
||||
#. js-lingui-id: /ITcnz
|
||||
#: src/utils/date-utils.ts
|
||||
msgid "day"
|
||||
msgstr "dag"
|
||||
|
||||
#. js-lingui-id: H7OUPr
|
||||
#: src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx
|
||||
#: src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCustomView.tsx
|
||||
@@ -3482,7 +3473,6 @@ msgid "Day of the week"
|
||||
msgstr "Dag van die week"
|
||||
|
||||
#. js-lingui-id: J/Upwb
|
||||
#: src/utils/date-utils.ts
|
||||
#: src/modules/command-menu/pages/page-layout/utils/getDateGranularityPluralLabel.ts
|
||||
#: src/modules/command-menu/pages/page-layout/utils/getDateGranularityPluralLabel.ts
|
||||
msgid "days"
|
||||
@@ -4499,7 +4489,7 @@ msgstr "Voer die agentnaam in*"
|
||||
#. js-lingui-id: wgNkIh
|
||||
#: src/modules/object-record/record-field/ui/form-types/components/FormArrayFieldInput.tsx
|
||||
msgid "Enter an item"
|
||||
msgstr "Voer n item in"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: kOybqX
|
||||
#: src/modules/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowEditActionIterator.tsx
|
||||
@@ -10185,7 +10175,7 @@ msgstr "Stel 3.21 vir $3.21"
|
||||
#. js-lingui-id: YZwx1e
|
||||
#: src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx
|
||||
msgid "Set a default role for this workspace"
|
||||
msgstr "Stel | ||||