diff --git a/.github/workflows/crowdin-stats.yml b/.github/workflows/crowdin-stats.yml new file mode 100644 index 000000000..91ea09f7b --- /dev/null +++ b/.github/workflows/crowdin-stats.yml @@ -0,0 +1,43 @@ +# Collects translation statistics from Crowdin for the last calendar month +# and posts them to Slack grouped by project, language, and translator + +name: Crowdin Translation Statistics + +on: + workflow_dispatch: + schedule: + # Run on the first day of each month at 9:00 AM UTC + - cron: 0 9 1 * * + +jobs: + collect-stats: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - project_id_var: CROWDIN_DOCS_PROJECT_ID + project_name: appium-documentation + - project_id_var: CROWDIN_INSPECTOR_PROJECT_ID + project_name: appium-inspector + + steps: + - uses: actions/checkout@v5 + - name: Use Node.js LTS + uses: actions/setup-node@v5 + with: + node-version: lts/* + cache: 'npm' + - name: Install Dependencies + run: npm ci + - name: Collect Crowdin Stats for ${{ matrix.project_name }} + id: crowdin-stats + run: node ./scripts/crowdin-stats.mjs > crowdin-stats-${{ matrix.project_name }}.json + env: + CROWDIN_PROJECT_ID: ${{ vars[matrix.project_id_var] }} + CROWDIN_TOKEN: ${{ secrets.CROWDIN_DOCS_TOKEN }} + - name: Post to Slack for ${{ matrix.project_name }} + uses: slackapi/slack-github-action@v2.0.0 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload-file-path: crowdin-stats-${{ matrix.project_name }}.json diff --git a/scripts/crowdin-stats.mjs b/scripts/crowdin-stats.mjs new file mode 100644 index 000000000..fbfe64adf --- /dev/null +++ b/scripts/crowdin-stats.mjs @@ -0,0 +1,331 @@ +import {log, performApiRequest} from './crowdin-common.mjs'; +import {net, fs, tempDir} from '@appium/support'; +import {waitForCondition} from 'asyncbox'; + +const REPORT_TIMEOUT_MS = 1000 * 60 * 5; // 5 minutes +const REPORT_STATUS = { + finished: 'finished', + created: 'created', + inProgress: 'in_progress', +}; + +/** + * Get the date range for the last calendar month + * @returns {{from: string, to: string}} + */ +function getLastMonthDateRange() { + const now = new Date(); + // First day of last month at 00:00:00 UTC + const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0)); + // Last day of last month at 23:59:59 UTC + const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0, 23, 59, 59)); + + return { + from: from.toISOString(), // Full ISO 8601 format with UTC: YYYY-MM-DDTHH:mm:ss.sssZ + to: to.toISOString(), + }; +} + +/** + * Generate a translation activity report + * @param {string} from - Start date in ISO 8601 format (e.g., 2025-09-01T00:00:00.000Z) + * @param {string} to - End date in ISO 8601 format (e.g., 2025-09-30T23:59:59.000Z) + * @returns {Promise} Report ID + */ +async function generateReport(from, to) { + log.info(`Generating translation report for ${from} to ${to}`); + + const {data: reportData} = await performApiRequest('/reports', { + method: 'POST', + payload: { + name: 'top-members', + schema: { + unit: 'strings', + format: 'json', + dateFrom: from, + dateTo: to, + }, + }, + }); + + return reportData.identifier; +} + +/** + * Check report generation status + * @param {string} reportId + * @returns {Promise<{status: string, progress: number}>} + */ +async function checkReportStatus(reportId) { + const {data: statusData} = await performApiRequest(`/reports/${reportId}`); + return { + status: statusData.status, + progress: statusData.progress, + }; +} + +/** + * Wait for report to be ready and download it + * @param {string} reportId - The unique identifier of the generated report + * @returns {Promise} Parsed report data from Crowdin + */ +async function downloadReport(reportId) { + log.info(`Waiting up to ${REPORT_TIMEOUT_MS / 1000}s for report ${reportId} to finish`); + + await waitForCondition( + async () => { + const {status, progress} = await checkReportStatus(reportId); + log.debug(`Report status: ${status}, progress: ${progress}%`); + + switch (status) { + case REPORT_STATUS.finished: + return true; + case REPORT_STATUS.inProgress: + case REPORT_STATUS.created: + return false; + default: + throw new Error(`Report generation failed with status '${status}'`); + } + }, + { + waitMs: REPORT_TIMEOUT_MS, + intervalMs: 2000, + }, + ); + + const {data: downloadData} = await performApiRequest(`/reports/${reportId}/download`); + log.info('Downloading report data'); + + // Download the report file + const tmpFile = await tempDir.path({prefix: 'crowdin-report', suffix: '.json'}); + try { + await net.downloadFile(downloadData.url, tmpFile, {isMetered: false}); + const content = await fs.readFile(tmpFile, 'utf8'); + return JSON.parse(content); + } finally { + await fs.rimraf(tmpFile); + } +} + +/** + * Process report data and group by language and translator + * @param {CrowdinReportData} reportData - Raw report data from Crowdin API + * @returns {ProcessedStats} Grouped statistics by language and user + */ +function processReportData(reportData) { + const stats = {}; + + if (reportData.data && Array.isArray(reportData.data)) { + for (const record of reportData.data) { + const user = record.user?.username || record.user?.fullName || 'Unknown'; + const translated = record.translated || 0; + const approved = record.approved || 0; + + // Each record has a languages array + const languages = record.languages || []; + if (languages.length === 0) { + // If no languages specified, use "Unknown" + languages.push({name: 'Unknown'}); + } + + // Add this user's stats to each language they contributed to + for (const lang of languages) { + const languageName = lang.name || 'Unknown'; + + if (!stats[languageName]) { + stats[languageName] = {}; + } + if (!stats[languageName][user]) { + stats[languageName][user] = {translated: 0, approved: 0}; + } + stats[languageName][user].translated += translated; + stats[languageName][user].approved += approved; + } + } + } + + return stats; +} + +/** + * Get project details + * @returns {Promise<{name: string, id: number}>} + */ +async function getProjectInfo() { + const {data: projectData} = await performApiRequest('', { + method: 'GET', + }); + return { + name: projectData.name, + id: projectData.id, + }; +} + +/** + * Format statistics as Slack message blocks + * @param {ProcessedStats} stats - Grouped statistics by language and user + * @param {string} projectName - Project name + * @param {string} from - Start date in ISO 8601 format + * @param {string} to - End date in ISO 8601 format + * @param {string} generatedAt - Generation timestamp in ISO 8601 format + * @returns {object} Slack message payload with blocks + */ +function formatSlackMessage(stats, projectName, from, to, generatedAt) { + const monthName = new Date(from).toLocaleString('en-US', { month: 'long', year: 'numeric' }); + // Format dates as YYYY-MM-DD for display + const fromDate = from.split('T')[0]; + const toDate = to.split('T')[0]; + + const blocks = [ + { + type: 'header', + text: { + type: 'plain_text', + text: `📊 Crowdin Translation Stats for ${monthName}`, + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Project:* ${projectName}\n*Period:* ${fromDate} to ${toDate}`, + }, + }, + { + type: 'divider', + }, + ]; + + if (Object.keys(stats).length === 0) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: '❌ No translation activity found for this period.', + }, + }); + } else { + for (const [language, users] of Object.entries(stats)) { + const userEntries = Object.entries(users) + .filter(([, counts]) => counts.translated > 0 || counts.approved > 0) + .sort(([, a], [, b]) => (b.translated + b.approved) - (a.translated + a.approved)); + + // Skip this language if no users have activity + if (userEntries.length === 0) { + continue; + } + + const userLines = userEntries + .map(([user, counts]) => + `• *${user}*: ${counts.translated} translated, ${counts.approved} approved` + ) + .join('\n'); + + const totalTranslated = userEntries.reduce((sum, [, u]) => sum + u.translated, 0); + const totalApproved = userEntries.reduce((sum, [, u]) => sum + u.approved, 0); + + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*${language}* (Total: ${totalTranslated} translated, ${totalApproved} approved)\n${userLines}`, + }, + }); + } + } + + blocks.push( + { + type: 'divider', + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Generated on ${generatedAt.split('T')[0]} | `, + }, + ], + }, + ); + + return {blocks}; +} + +async function main() { + const dateRange = getLastMonthDateRange(); + log.info(`Collecting translation statistics for ${dateRange.from} to ${dateRange.to}`); + + // Get project info + const projectInfo = await getProjectInfo(); + log.info(`Project: ${projectInfo.name} (ID: ${projectInfo.id})`); + + // Generate and download report + const reportId = await generateReport(dateRange.from, dateRange.to); + const reportData = await downloadReport(reportId); + + // Process the data + const stats = processReportData(reportData); + const generatedAt = new Date().toISOString(); + + // Output Slack-formatted message to stdout + const slackMessage = formatSlackMessage( + stats, + projectInfo.name, + dateRange.from, + dateRange.to, + generatedAt + ); + + // eslint-disable-next-line no-console + console.log(JSON.stringify(slackMessage, null, 2)); + log.info('All done!'); +} + +(async () => await main())(); + +// Type definitions + +/** + * @typedef {Object} CrowdinUser + * @property {string} id - User ID + * @property {string} username - Username + * @property {string} fullName - Full name + * @property {string} avatarUrl - Avatar URL + * @property {string} joined - Join date + */ + +/** + * @typedef {Object} CrowdinLanguage + * @property {string} id - Language ID (e.g., "zh-CN") + * @property {string} name - Language name (e.g., "Chinese Simplified") + */ + +/** + * @typedef {Object} CrowdinReportRecord + * @property {CrowdinUser} user - User information + * @property {CrowdinLanguage[]} languages - Languages the user contributed to + * @property {number} translated - Number of strings translated + * @property {number} approved - Number of strings approved + * @property {number} voted - Number of votes cast + * @property {number} positiveVotes - Number of positive votes received + * @property {number} negativeVotes - Number of negative votes received + * @property {number} winning - Number of winning translations + */ + +/** + * @typedef {Object} CrowdinReportData + * @property {CrowdinReportRecord[]} data - Array of report records + */ + +/** + * @typedef {Object} UserStats + * @property {number} translated - Number of strings translated + * @property {number} approved - Number of strings approved + */ + +/** + * @typedef {Record>} ProcessedStats + * Language name mapped to username mapped to stats + */