From 60875644a18f972831a0de0c3c6e2bfcc317e1c3 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Fri, 30 Jan 2026 10:59:27 -0700 Subject: [PATCH] better i18n checks, better action disabled, fix cert download for ios --- web/lint-staged.config.js | 1 + web/package.json | 11 ++- .../src/app/pages/success.page.ts | 2 +- .../shared/src/i18n/dictionaries/en.ts | 4 +- web/projects/shared/src/i18n/i18n.pipe.ts | 4 +- .../services/routes/actions.component.ts | 25 ++++- .../ui/src/app/services/action.service.ts | 82 ++++------------ web/scripts/check-i18n.mjs | 95 +++++++++++++++++++ 8 files changed, 152 insertions(+), 72 deletions(-) create mode 100644 web/scripts/check-i18n.mjs diff --git a/web/lint-staged.config.js b/web/lint-staged.config.js index d7097bf6d..87a672a72 100644 --- a/web/lint-staged.config.js +++ b/web/lint-staged.config.js @@ -6,4 +6,5 @@ module.exports = { 'projects/marketplace/**/*.ts': () => 'npm run check:marketplace', 'projects/setup-wizard/**/*.ts': () => 'npm run check:setup', 'projects/start-tunnel/**/*.ts': () => 'npm run check:tunnel', + 'projects/**/*.{ts,html}': () => 'npm run check:i18n', } diff --git a/web/package.json b/web/package.json index 27e3c0f73..e17493b51 100644 --- a/web/package.json +++ b/web/package.json @@ -6,17 +6,18 @@ "license": "MIT", "scripts": { "ng": "ng", - "check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:setup", + "check": "npm run check:i18n && npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:setup", "check:shared": "tsc --project projects/shared/tsconfig.json --noEmit --skipLibCheck", "check:marketplace": "tsc --project projects/marketplace/tsconfig.json --noEmit --skipLibCheck", "check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck", "check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck", "check:tunnel": "tsc --project projects/start-tunnel/tsconfig.json --noEmit --skipLibCheck", + "check:i18n": "node scripts/check-i18n.mjs", "build:deps": "rimraf .angular/cache && (cd ../sdk && make bundle) && (cd ../patch-db/client && npm ci && npm run build)", - "build:setup": "ng run setup-wizard:build", - "build:ui": "ng run ui:build", - "build:ui:dev": "ng run ui:build:development", - "build:tunnel": "ng run start-tunnel:build", + "build:setup": "npm run check:i18n && ng run setup-wizard:build", + "build:ui": "npm run check:i18n && ng run ui:build", + "build:ui:dev": "npm run check:i18n && ng run ui:build:development", + "build:tunnel": "npm run check:i18n && ng run start-tunnel:build", "build:all": "npm run build:deps && npm run build:setup && npm run build:ui", "build:shared": "ng build shared", "build:marketplace": "npm run build:shared && ng build marketplace", diff --git a/web/projects/setup-wizard/src/app/pages/success.page.ts b/web/projects/setup-wizard/src/app/pages/success.page.ts index 11b311cb4..8f84f788b 100644 --- a/web/projects/setup-wizard/src/app/pages/success.page.ts +++ b/web/projects/setup-wizard/src/app/pages/success.page.ts @@ -215,7 +215,7 @@ export default class SuccessPage implements AfterViewInit { .getElementById('cert') ?.setAttribute( 'href', - `data:application/x-x509-ca-cert;base64,${this.result!.rootCa}`, + `data:application/octet-stream;base64,${this.result!.rootCa}`, ) const html = this.documentation?.nativeElement.innerHTML || '' diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index a12f328e4..b7e05c7b6 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -1,5 +1,5 @@ // prettier-ignore -export const ENGLISH = { +export const ENGLISH: Record = { 'Change': 1, // verb 'Update': 2, // verb 'System': 4, // as in, system preferences @@ -680,4 +680,4 @@ export const ENGLISH = { 'Installation Complete!': 714, 'StartOS has been installed successfully.': 715, 'Continue to Setup': 716, -} as const +} diff --git a/web/projects/shared/src/i18n/i18n.pipe.ts b/web/projects/shared/src/i18n/i18n.pipe.ts index 6093c79d1..eea8708bf 100644 --- a/web/projects/shared/src/i18n/i18n.pipe.ts +++ b/web/projects/shared/src/i18n/i18n.pipe.ts @@ -13,6 +13,8 @@ export class i18nPipe implements PipeTransform { transform(englishKey: i18nKey | null | undefined | ''): string { englishKey = englishKey || ('' as i18nKey) - return this.i18n()?.[ENGLISH[englishKey]] || englishKey + const id = ENGLISH[englishKey] + + return (id !== undefined && this.i18n()?.[id]) || englishKey } } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts index 3152d2a52..321423718 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts @@ -29,6 +29,21 @@ const INACTIVE: PrimaryStatus[] = [ 'backing-up', ] +const ALLOWED_STATUSES: Record> = { + 'only-running': new Set(['running']), + 'only-stopped': new Set(['stopped']), + any: new Set([ + 'running', + 'stopped', + 'restarting', + 'restoring', + 'stopping', + 'starting', + 'backing-up', + 'task-required', + ]), +} + @Component({ template: ` @if (package(); as pkg) { @@ -92,8 +107,9 @@ export default class ServiceActionsRoute { const specialGroup = Object.values(pkg.actions).some(a => !!a.group) ? 'Other' : 'General' + const status = renderPkgStatus(pkg).primary return { - status: renderPkgStatus(pkg).primary, + status, icon: pkg.icon, manifest: getManifest(pkg), actions: Object.entries(pkg.actions) @@ -102,6 +118,13 @@ export default class ServiceActionsRoute { ...action, id, group: action.group || specialGroup, + visibility: ALLOWED_STATUSES[action.allowedStatuses].has( + status, + ) + ? action.visibility + : ({ + disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`, + } as T.ActionVisibility), })) .sort((a, b) => { if (a.group === specialGroup && b.group !== specialGroup) diff --git a/web/projects/ui/src/app/services/action.service.ts b/web/projects/ui/src/app/services/action.service.ts index ec76642ba..f5b4b6340 100644 --- a/web/projects/ui/src/app/services/action.service.ts +++ b/web/projects/ui/src/app/services/action.service.ts @@ -3,7 +3,6 @@ import { DialogService, ErrorService, i18nKey, - i18nPipe, LoadingService, } from '@start9labs/shared' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' @@ -16,21 +15,6 @@ import { ActionSuccessPage } from 'src/app/routes/portal/routes/services/modals/ import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' -const allowedStatuses = { - 'only-running': new Set(['running']), - 'only-stopped': new Set(['stopped']), - any: new Set([ - 'running', - 'stopped', - 'restarting', - 'restoring', - 'stopping', - 'starting', - 'backing-up', - 'task-required', - ]), -} - @Injectable({ providedIn: 'root', }) @@ -40,58 +24,32 @@ export class ActionService { private readonly errorService = inject(ErrorService) private readonly loader = inject(LoadingService) private readonly formDialog = inject(FormDialogService) - private readonly i18n = inject(i18nPipe) async present(data: PackageActionData) { const { pkgInfo, actionInfo } = data - if ( - allowedStatuses[actionInfo.metadata.allowedStatuses].has(pkgInfo.status) - ) { - if (actionInfo.metadata.hasInput) { - this.formDialog.open(ActionInputModal, { - label: actionInfo.metadata.name as i18nKey, - data, - }) - } else { - if (actionInfo.metadata.warning) { - this.dialog - .openConfirm({ - label: 'Warning', - size: 's', - data: { - no: 'Cancel', - yes: 'Run', - content: actionInfo.metadata.warning as i18nKey, - }, - }) - .pipe(filter(Boolean)) - .subscribe(() => this.execute(pkgInfo.id, null, actionInfo.id)) - } else { - this.execute(pkgInfo.id, null, actionInfo.id) - } - } + if (actionInfo.metadata.hasInput) { + this.formDialog.open(ActionInputModal, { + label: actionInfo.metadata.name as i18nKey, + data, + }) } else { - const statuses = [...allowedStatuses[actionInfo.metadata.allowedStatuses]] - const last = statuses.pop() - let statusesStr = statuses.join(', ') - if (statuses.length) { - if (statuses.length > 1) { - // oxford comma - statusesStr += ',' - } - statusesStr += ` or ${last}` - } else if (last) { - statusesStr = last + if (actionInfo.metadata.warning) { + this.dialog + .openConfirm({ + label: 'Warning', + size: 's', + data: { + no: 'Cancel', + yes: 'Run', + content: actionInfo.metadata.warning as i18nKey, + }, + }) + .pipe(filter(Boolean)) + .subscribe(() => this.execute(pkgInfo.id, null, actionInfo.id)) + } else { + this.execute(pkgInfo.id, null, actionInfo.id) } - - this.dialog - .openAlert( - `${this.i18n.transform('Action can only be executed when service is')} ${statusesStr}` as i18nKey, - { label: 'Forbidden' }, - ) - .pipe(filter(Boolean)) - .subscribe() } } diff --git a/web/scripts/check-i18n.mjs b/web/scripts/check-i18n.mjs new file mode 100644 index 000000000..6868d18d4 --- /dev/null +++ b/web/scripts/check-i18n.mjs @@ -0,0 +1,95 @@ +import { readFileSync, readdirSync, statSync } from 'fs' +import { join, relative } from 'path' +import { fileURLToPath } from 'url' +import { dirname } from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const root = join(__dirname, '..') + +// Extract dictionary keys from en.ts +const enPath = join( + root, + 'projects/shared/src/i18n/dictionaries/en.ts', +) +const enSource = readFileSync(enPath, 'utf-8') +const validKeys = new Set() + +for (const match of enSource.matchAll(/^\s+'(.+?)':\s*\d+/gm)) { + validKeys.add(match[1]) +} + +if (validKeys.size === 0) { + console.error('ERROR: Could not parse any keys from en.ts') + process.exit(1) +} + +console.log(`Loaded ${validKeys.size} i18n keys from en.ts`) + +// Collect all .ts and .html files under projects/ +function walk(dir, files = []) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (entry === 'node_modules' || entry === 'dist') continue + const stat = statSync(full) + if (stat.isDirectory()) { + walk(full, files) + } else if (full.endsWith('.ts') || full.endsWith('.html')) { + files.push(full) + } + } + return files +} + +const projectsDir = join(root, 'projects') +const files = walk(projectsDir) + +const errors = [] + +for (const file of files) { + // Skip the dictionary files themselves + if (file.includes('/i18n/dictionaries/')) continue + + const source = readFileSync(file, 'utf-8') + const lines = source.split('\n') + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Pattern 1: i18n.transform('Key') or i18n.transform("Key") + for (const m of line.matchAll(/i18n\.transform\(\s*'([^']+)'\s*\)/g)) { + if (!validKeys.has(m[1])) { + errors.push({ file, line: i + 1, key: m[1] }) + } + } + for (const m of line.matchAll(/i18n\.transform\(\s*"([^"]+)"\s*\)/g)) { + if (!validKeys.has(m[1])) { + errors.push({ file, line: i + 1, key: m[1] }) + } + } + + // Pattern 2: 'Key' | i18n or "Key" | i18n (Angular templates) + for (const m of line.matchAll(/'([^']+)'\s*\|\s*i18n/g)) { + if (!validKeys.has(m[1])) { + errors.push({ file, line: i + 1, key: m[1] }) + } + } + for (const m of line.matchAll(/"([^"]+)"\s*\|\s*i18n/g)) { + if (!validKeys.has(m[1])) { + errors.push({ file, line: i + 1, key: m[1] }) + } + } + } +} + +if (errors.length > 0) { + console.error(`\nFound ${errors.length} invalid i18n key(s):\n`) + for (const { file, line, key } of errors) { + const rel = relative(root, file) + console.error(` ${rel}:${line} "${key}"`) + } + console.error() + process.exit(1) +} else { + console.log('All i18n keys are valid.') +}