better i18n checks, better action disabled, fix cert download for ios

This commit is contained in:
Matt Hill
2026-01-30 10:59:27 -07:00
parent 113b09ad01
commit 60875644a1
8 changed files with 152 additions and 72 deletions

View File

@@ -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',
}

View File

@@ -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",

View File

@@ -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 || ''

View File

@@ -1,5 +1,5 @@
// prettier-ignore
export const ENGLISH = {
export const ENGLISH: Record<string, number> = {
'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
}

View File

@@ -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
}
}

View File

@@ -29,6 +29,21 @@ const INACTIVE: PrimaryStatus[] = [
'backing-up',
]
const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
'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)

View File

@@ -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<PackageActionData>(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<PackageActionData>(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()
}
}

View File

@@ -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.')
}