mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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:
parent
ede261abf4
commit
e6491d6a80
114 changed files with 1668 additions and 976 deletions
184
.github/workflows/ci-breaking-changes.yaml
vendored
184
.github/workflows/ci-breaking-changes.yaml
vendored
|
|
@ -63,9 +63,9 @@ jobs:
|
|||
- 8123:8123
|
||||
- 9000:9000
|
||||
options: >-
|
||||
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
|
|
@ -78,12 +78,12 @@ jobs:
|
|||
id: merge_attempt
|
||||
run: |
|
||||
echo "Attempting to merge main into current branch..."
|
||||
|
||||
|
||||
git fetch origin main
|
||||
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
|
||||
|
||||
if git merge origin/main --no-edit; then
|
||||
echo "✅ Successfully merged main into current branch"
|
||||
echo "merged=true" >> $GITHUB_OUTPUT
|
||||
|
|
@ -91,16 +91,16 @@ jobs:
|
|||
else
|
||||
echo "❌ Merge failed due to conflicts"
|
||||
echo "⚠️ Falling back to comparing current branch against main without merge"
|
||||
|
||||
|
||||
# Abort the failed merge
|
||||
git merge --abort
|
||||
|
||||
|
||||
echo "merged=false" >> $GITHUB_OUTPUT
|
||||
echo "BRANCH_STATE=conflicts" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build shared dependencies
|
||||
run: |
|
||||
|
|
@ -128,22 +128,22 @@ jobs:
|
|||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
local env_file="packages/twenty-server/.env"
|
||||
|
||||
|
||||
echo "" >> "$env_file"
|
||||
|
||||
|
||||
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
|
||||
else
|
||||
echo "${var_name}=${var_value}" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/current_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.CURRENT_SERVER_PORT }}"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379"
|
||||
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
|
||||
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
npx nx run twenty-server:database:migrate:prod
|
||||
|
||||
|
|
@ -166,19 +166,19 @@ jobs:
|
|||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
|
||||
echo "Current branch server is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
|
||||
echo "Current branch server not ready yet, waiting ${interval}s..."
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for current branch server to start"
|
||||
echo "Current server log:"
|
||||
|
|
@ -188,15 +188,15 @@ 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)
|
||||
|
||||
|
||||
# Prepare the query payload
|
||||
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
|
||||
echo "Downloading GraphQL schema from current server..."
|
||||
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
|
@ -205,7 +205,7 @@ jobs:
|
|||
-o current-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
echo "Downloading GraphQL metadata schema from current server..."
|
||||
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/metadata" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
|
@ -214,32 +214,32 @@ jobs:
|
|||
-o current-metadata-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
# Download current branch OpenAPI specs
|
||||
echo "Downloading OpenAPI specifications from current server..."
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o current-rest-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/metadata" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o current-rest-metadata-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
# Verify the downloads
|
||||
echo "Current branch files downloaded:"
|
||||
ls -la current-*
|
||||
|
||||
|
||||
|
||||
- name: Preserve current branch files
|
||||
run: |
|
||||
# Create a temp directory to store current branch files
|
||||
mkdir -p /tmp/current-branch-files
|
||||
|
||||
|
||||
# Move current branch files to temp directory
|
||||
mv current-* /tmp/current-branch-files/ 2>/dev/null || echo "No current-* files to preserve"
|
||||
|
||||
|
||||
echo "Preserved current branch files for later restoration"
|
||||
|
||||
- name: Stop current branch server
|
||||
|
|
@ -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: |
|
||||
|
|
@ -281,22 +281,22 @@ jobs:
|
|||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
local env_file="packages/twenty-server/.env"
|
||||
|
||||
|
||||
echo "" >> "$env_file"
|
||||
|
||||
|
||||
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
|
||||
else
|
||||
echo "${var_name}=${var_value}" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/main_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.MAIN_SERVER_PORT }}"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379"
|
||||
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
|
||||
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
npx nx run twenty-server:database:migrate:prod
|
||||
|
||||
|
|
@ -319,19 +319,19 @@ jobs:
|
|||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
|
||||
echo "Main branch server is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
|
||||
echo "Main branch server not ready yet, waiting ${interval}s..."
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for main branch server to start"
|
||||
echo "Main server log:"
|
||||
|
|
@ -341,15 +341,15 @@ 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)
|
||||
|
||||
|
||||
# Prepare the query payload
|
||||
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
|
||||
echo "Downloading GraphQL schema from main server..."
|
||||
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
|
@ -358,7 +358,7 @@ jobs:
|
|||
-o main-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
echo "Downloading GraphQL metadata schema from main server..."
|
||||
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/metadata" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
|
@ -367,33 +367,33 @@ jobs:
|
|||
-o main-metadata-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
# Download main branch OpenAPI specs
|
||||
echo "Downloading OpenAPI specifications from main server..."
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o main-rest-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/metadata" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o main-rest-metadata-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
# Verify the downloads
|
||||
echo "Main branch files downloaded:"
|
||||
ls -la main-*
|
||||
|
||||
|
||||
|
||||
- name: Restore current branch files
|
||||
run: |
|
||||
# Move current branch files back to working directory
|
||||
mv /tmp/current-branch-files/* . 2>/dev/null || echo "No files to restore"
|
||||
|
||||
|
||||
# Verify all files are present
|
||||
echo "All API files restored:"
|
||||
ls -la current-* main-* 2>/dev/null || echo "Some files may be missing"
|
||||
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf /tmp/current-branch-files
|
||||
|
||||
|
|
@ -406,9 +406,9 @@ jobs:
|
|||
run: |
|
||||
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
|
||||
npm install -g @graphql-inspector/cli
|
||||
|
||||
|
||||
echo "=== GENERATING GRAPHQL DIFF REPORTS ==="
|
||||
|
||||
|
||||
# Check if GraphQL schema has changes
|
||||
echo "Checking GraphQL schema for changes..."
|
||||
if graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >/dev/null 2>&1; then
|
||||
|
|
@ -426,7 +426,7 @@ jobs:
|
|||
echo "\`\`\`" >> graphql-schema-diff.md
|
||||
}
|
||||
fi
|
||||
|
||||
|
||||
# Check if GraphQL metadata schema has changes
|
||||
echo "Checking GraphQL metadata schema for changes..."
|
||||
if graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >/dev/null 2>&1; then
|
||||
|
|
@ -444,7 +444,7 @@ jobs:
|
|||
echo "\`\`\`" >> graphql-metadata-diff.md
|
||||
}
|
||||
fi
|
||||
|
||||
|
||||
# Show summary
|
||||
echo "Generated diff files:"
|
||||
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
|
||||
|
|
@ -452,32 +452,32 @@ jobs:
|
|||
- name: Check REST API Breaking Changes
|
||||
run: |
|
||||
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
|
||||
|
||||
|
||||
# Use the Java-based openapi-diff via Docker
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
|
||||
--json /specs/rest-api-diff.json \
|
||||
/specs/main-rest-api.json /specs/current-rest-api.json || echo "OpenAPI diff completed with exit code $?"
|
||||
|
||||
|
||||
# Check if the output file was created and is valid JSON
|
||||
if [ -f "rest-api-diff.json" ] && jq empty rest-api-diff.json 2>/dev/null; then
|
||||
# Check for breaking changes using Java openapi-diff JSON structure
|
||||
incompatible=$(jq -r '.incompatible // false' rest-api-diff.json)
|
||||
different=$(jq -r '.different // false' rest-api-diff.json)
|
||||
|
||||
|
||||
# Count changes
|
||||
new_endpoints=$(jq -r '.newEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
changed_operations=$(jq -r '.changedOperations | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST API"
|
||||
|
||||
|
||||
# Generate breaking changes report
|
||||
echo "# REST API Breaking Changes" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
jq -r '
|
||||
if (.missingEndpoints | length) > 0 then
|
||||
|
|
@ -493,7 +493,7 @@ jobs:
|
|||
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
|
||||
else "" end
|
||||
' rest-api-diff.json >> rest-api-diff.md
|
||||
|
||||
|
||||
elif [ "$different" = "true" ]; then
|
||||
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed) - no PR comment will be posted"
|
||||
# Don't create markdown file for non-breaking changes to avoid PR comments
|
||||
|
|
@ -503,7 +503,7 @@ jobs:
|
|||
fi
|
||||
else
|
||||
echo "⚠️ OpenAPI diff tool could not process the files"
|
||||
|
||||
|
||||
echo "# REST API Analysis Error" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "⚠️ **Error occurred while analyzing REST API changes**" >> rest-api-diff.md
|
||||
|
|
@ -512,7 +512,7 @@ jobs:
|
|||
echo "\`\`\`" >> rest-api-diff.md
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-api.json /specs/current-rest-api.json 2>&1 >> rest-api-diff.md || echo "Could not capture error output"
|
||||
echo "\`\`\`" >> rest-api-diff.md
|
||||
|
||||
|
||||
# Don't fail the workflow for tool errors
|
||||
echo "::warning::REST API analysis tool error - continuing workflow"
|
||||
fi
|
||||
|
|
@ -520,33 +520,33 @@ jobs:
|
|||
- name: Check REST Metadata API Breaking Changes
|
||||
run: |
|
||||
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
|
||||
|
||||
|
||||
# Use the Java-based openapi-diff for metadata API as well
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
|
||||
--json /specs/rest-metadata-api-diff.json \
|
||||
/specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json || echo "OpenAPI diff completed with exit code $?"
|
||||
|
||||
|
||||
# Check if the output file was created and is valid JSON
|
||||
if [ -f "rest-metadata-api-diff.json" ] && jq empty rest-metadata-api-diff.json 2>/dev/null; then
|
||||
# Check for breaking changes using Java openapi-diff JSON structure
|
||||
incompatible=$(jq -r '.incompatible // false' rest-metadata-api-diff.json)
|
||||
different=$(jq -r '.different // false' rest-metadata-api-diff.json)
|
||||
|
||||
|
||||
# Count changes
|
||||
new_endpoints=$(jq -r '.newEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
changed_operations=$(jq -r '.changedOperations | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST Metadata API"
|
||||
|
||||
|
||||
# Generate breaking changes report (only for breaking changes)
|
||||
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
jq -r '
|
||||
if (.missingEndpoints | length) > 0 then
|
||||
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
|
||||
|
|
@ -570,7 +570,7 @@ jobs:
|
|||
fi
|
||||
else
|
||||
echo "⚠️ OpenAPI diff tool could not process the metadata API files"
|
||||
|
||||
|
||||
echo "# REST Metadata API Analysis Error" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "⚠️ **Error occurred while analyzing REST Metadata API changes**" >> rest-metadata-api-diff.md
|
||||
|
|
@ -579,7 +579,7 @@ jobs:
|
|||
echo "\`\`\`" >> rest-metadata-api-diff.md
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json 2>&1 >> rest-metadata-api-diff.md || echo "Could not capture error output"
|
||||
echo "\`\`\`" >> rest-metadata-api-diff.md
|
||||
|
||||
|
||||
# Don't fail the workflow for tool errors
|
||||
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
|
||||
fi
|
||||
|
|
@ -592,7 +592,7 @@ jobs:
|
|||
const fs = require('fs');
|
||||
let hasChanges = false;
|
||||
let comment = '';
|
||||
|
||||
|
||||
try {
|
||||
if (fs.existsSync('graphql-schema-diff.md')) {
|
||||
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
|
||||
|
|
@ -604,7 +604,7 @@ jobs:
|
|||
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('graphql-metadata-diff.md')) {
|
||||
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
|
||||
if (graphqlMetadataDiff.trim()) {
|
||||
|
|
@ -615,7 +615,7 @@ jobs:
|
|||
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-api-diff.md')) {
|
||||
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
|
||||
if (restDiff.trim()) {
|
||||
|
|
@ -626,7 +626,7 @@ jobs:
|
|||
comment += restDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-metadata-api-diff.md')) {
|
||||
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
|
||||
if (metadataDiff.trim()) {
|
||||
|
|
@ -637,37 +637,37 @@ jobs:
|
|||
comment += metadataDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Only post comment if there are changes
|
||||
if (hasChanges) {
|
||||
// Add branch state information only if there were conflicts
|
||||
const branchState = process.env.BRANCH_STATE || 'unknown';
|
||||
let branchStateNote = '';
|
||||
|
||||
|
||||
if (branchState === 'conflicts') {
|
||||
branchStateNote = '\n\n⚠️ **Note**: Could not merge with `main` due to conflicts. This comparison shows changes between the current branch and `main` as separate states.\n';
|
||||
}
|
||||
// Check if there are any breaking changes detected
|
||||
let hasBreakingChanges = false;
|
||||
let breakingChangeNote = '';
|
||||
|
||||
|
||||
// Check for breaking changes in any of the diff files
|
||||
if (fs.existsSync('rest-api-diff.md')) {
|
||||
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
|
||||
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
|
||||
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
|
||||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-metadata-api-diff.md')) {
|
||||
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
|
||||
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
|
||||
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
|
||||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Also check GraphQL changes for breaking changes indicators
|
||||
if (fs.existsSync('graphql-schema-diff.md')) {
|
||||
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
|
||||
|
|
@ -675,18 +675,18 @@ jobs:
|
|||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('graphql-metadata-diff.md')) {
|
||||
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
|
||||
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check PR title for "breaking"
|
||||
const prTitle = ${{ toJSON(github.event.pull_request.title) }};
|
||||
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
|
||||
|
||||
|
||||
if (hasBreakingChanges) {
|
||||
if (titleContainsBreaking) {
|
||||
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
|
||||
|
|
@ -702,20 +702,20 @@ jobs:
|
|||
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
|
||||
const commentBody = COMMENT_MARKER + '\n' + comment + branchStateNote + '\n⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
|
||||
|
||||
|
||||
// Get all comments to find existing API changes comment
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
|
||||
// Find our existing comment
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
|
|
@ -737,17 +737,17 @@ jobs:
|
|||
}
|
||||
} else {
|
||||
console.log('No API changes detected - skipping PR comment');
|
||||
|
||||
|
||||
// Check if there's an existing comment to remove
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
|
||||
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
|
|
@ -780,7 +780,7 @@ jobs:
|
|||
/tmp/main-server.log
|
||||
/tmp/current-server.log
|
||||
*-api.json
|
||||
*-schema-introspection.json
|
||||
*-schema-introspection.json
|
||||
*-diff.md
|
||||
*-diff.json
|
||||
|
||||
|
|
|
|||
4
.github/workflows/ci-create-app.yaml
vendored
4
.github/workflows/ci-create-app.yaml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
2
.github/workflows/ci-docs.yaml
vendored
2
.github/workflows/ci-docs.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/ci-e2e.yaml
vendored
4
.github/workflows/ci-e2e.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
10
.github/workflows/ci-emails.yaml
vendored
10
.github/workflows/ci-emails.yaml
vendored
|
|
@ -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
|
||||
|
|
@ -40,17 +40,17 @@ jobs:
|
|||
# Start the email server in the background
|
||||
npx nx run twenty-emails:start &
|
||||
SERVER_PID=$!
|
||||
|
||||
|
||||
# Wait for server to start
|
||||
sleep 20
|
||||
|
||||
|
||||
# Check if server is running
|
||||
if ! curl -s http://localhost:4001/preview/test.email > /dev/null; then
|
||||
echo "Email server failed to start"
|
||||
kill $SERVER_PID
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Kill the server
|
||||
kill $SERVER_PID
|
||||
ci-emails-status-check:
|
||||
|
|
@ -61,4 +61,4 @@ jobs:
|
|||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
run: exit 1
|
||||
|
|
|
|||
26
.github/workflows/ci-front.yaml
vendored
26
.github/workflows/ci-front.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/ci-release-create.yaml
vendored
2
.github/workflows/ci-release-create.yaml
vendored
|
|
@ -56,4 +56,4 @@ jobs:
|
|||
title: Release v${{ steps.sanitize.outputs.version }}
|
||||
labels: |
|
||||
release
|
||||
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
|
||||
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
|
||||
|
|
|
|||
6
.github/workflows/ci-sdk.yaml
vendored
6
.github/workflows/ci-sdk.yaml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
20
.github/workflows/ci-server.yaml
vendored
20
.github/workflows/ci-server.yaml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
4
.github/workflows/ci-shared.yaml
vendored
4
.github/workflows/ci-shared.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/ci-utils.yaml
vendored
6
.github/workflows/ci-utils.yaml
vendored
|
|
@ -30,12 +30,12 @@ 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:
|
||||
DANGER_GITHUB_API_TOKEN: ${{ github.token }}
|
||||
|
||||
|
||||
congratulate:
|
||||
timeout-minutes: 3
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/workflows/ci-website.yaml
vendored
2
.github/workflows/ci-website.yaml
vendored
|
|
@ -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";'
|
||||
|
|
|
|||
6
.github/workflows/docs-i18n-pull.yaml
vendored
6
.github/workflows/docs-i18n-pull.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/docs-i18n-push.yaml
vendored
6
.github/workflows/docs-i18n-push.yaml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
10
.github/workflows/i18n-pull.yaml
vendored
10
.github/workflows/i18n-pull.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
9
.github/workflows/i18n-push.yaml
vendored
9
.github/workflows/i18n-push.yaml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
118
.github/workflows/i18n-qa-report.yaml
vendored
Normal file
118
.github/workflows/i18n-qa-report.yaml
vendored
Normal file
|
|
@ -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 }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -48,3 +48,4 @@ dump.rdb
|
|||
|
||||
mcp.json
|
||||
/.junie/
|
||||
TRANSLATION_QA_REPORT.md
|
||||
|
|
|
|||
22
crowdin-app.yml
Normal file
22
crowdin-app.yml
Normal file
|
|
@ -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 | ||||