fix(i18n): translate pet import and repo icon upload refusals by code (#35861)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 02:00:15 -07:00
committed by GitHub
co-authored by OrcaWin
parent b28f54c5f3
commit f06cc6e554
29 changed files with 999 additions and 140 deletions
+12 -7
View File
@@ -1,4 +1,5 @@
import { translateMain } from '../i18n/main-i18n'
import { PET_BUNDLE_DEFAULT_LABEL, PetImportError } from '../../shared/pet-import-failure-copy'
import { BrowserWindow, dialog } from 'electron'
import { mkdir, readFile, rename, rm, stat } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
@@ -34,7 +35,7 @@ export async function importPetBundle(
const pickedStat = await stat(picked)
bundleDir = pickedStat.isDirectory() ? picked : dirname(picked)
} catch {
throw new Error('Could not read the selected path.')
throw new PetImportError('pathUnreadable')
}
const manifestPath = join(bundleDir, 'pet.json')
@@ -42,13 +43,13 @@ export async function importPetBundle(
try {
manifestStat = await stat(manifestPath)
} catch {
throw new Error('Bundle is missing pet.json.')
throw new PetImportError('manifestMissing')
}
if (!manifestStat.isFile() || manifestStat.size > MAX_MANIFEST_BYTES) {
throw new Error('pet.json is invalid.')
throw new PetImportError('manifestInvalid')
}
if (await isSymlink(manifestPath)) {
throw new Error('pet.json must not be a symlink.')
throw new PetImportError('manifestSymlink')
}
let manifest: ResolvedPetManifest<PetManifest>
@@ -60,7 +61,11 @@ export async function importPetBundle(
}
manifest = applyCodexPetDefaults(PetManifestSchema.parse(JSON.parse(raw)))
} catch (error) {
throw new Error(`Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}`)
throw new PetImportError(
'manifestParseFailed',
{},
error instanceof Error ? error.message : 'parse error'
)
}
const { sheetSrc, sheetClass } = await resolveBundleSpritesheetSource(manifest, bundleDir)
@@ -84,11 +89,11 @@ export async function importPetBundle(
await rename(tmpDir, destDir)
} catch {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {})
throw new Error('Could not save the pet bundle.')
throw new PetImportError('bundleSaveFailed')
}
const rawLabel = (manifest.displayName ?? manifest.id ?? basename(bundleDir)).trim()
const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Pet bundle'
const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : PET_BUNDLE_DEFAULT_LABEL
return {
id,
label,
+21 -13
View File
@@ -1,4 +1,5 @@
import { nativeImage } from 'electron'
import { PetImportError } from '../../shared/pet-import-failure-copy'
import { readFile } from 'node:fs/promises'
import type { CustomPet } from '../../shared/pet-types'
import { MAX_BYTES } from './pet-import-size-limits'
@@ -36,38 +37,45 @@ export async function buildBundleSprite<T extends PetManifestLike>(
const sheetBuf = await readFile(sheetSrc)
// Recheck the read size because the file may have grown after stat.
if (sheetBuf.byteLength > MAX_BYTES) {
throw new Error('Spritesheet exceeded the size limit.')
throw new PetImportError('sheetSizeLimitExceeded')
}
const dims = await readSheetDimensions(sheetBuf)
if (!dims) {
throw new Error('Could not decode the spritesheet image.')
throw new PetImportError('sheetUndecodable')
}
const { width: fw, height: fh } = manifest.frame
if (dims.width % fw !== 0 || dims.height % fh !== 0) {
throw new Error(
`Spritesheet ${dims.width}×${dims.height} is not a clean multiple of frame ${fw}×${fh}.`
)
throw new PetImportError('sheetFrameMismatch', {
sheetWidth: dims.width,
sheetHeight: dims.height,
frameWidth: fw,
frameHeight: fh
})
}
const columns = dims.width / fw
const rows = dims.height / fh
if (manifest.animations) {
for (const [name, anim] of Object.entries(manifest.animations)) {
if (anim.row >= rows) {
throw new Error(`Animation "${name}" references row ${anim.row} but sheet has ${rows}.`)
throw new PetImportError('animationRowOutOfRange', { name, row: anim.row, rows })
}
if (anim.frames > columns) {
throw new Error(
`Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.`
)
throw new PetImportError('animationTooManyFrames', {
name,
frames: anim.frames,
columns
})
}
if (anim.frameDurationsMs && anim.frameDurationsMs.length !== anim.frames) {
throw new Error(
`Animation "${name}" declares ${anim.frameDurationsMs.length} frame durations but ${anim.frames} frames.`
)
throw new PetImportError('animationDurationMismatch', {
name,
durations: anim.frameDurationsMs.length,
frames: anim.frames
})
}
}
if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) {
throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`)
throw new PetImportError('defaultAnimationMissing', { name: manifest.defaultAnimation })
}
}
sprite = {
@@ -1,4 +1,5 @@
import { stat } from 'node:fs/promises'
import { PetImportError } from '../../shared/pet-import-failure-copy'
import { isAbsolute, resolve, sep } from 'node:path'
import { classifyFile } from './pet-image-formats'
import { MAX_BYTES } from './pet-import-size-limits'
@@ -16,39 +17,39 @@ export async function resolveBundleSpritesheetSource<T extends PetManifestLike>(
isAbsolute(normalizedSpritePath) ||
/^[a-zA-Z]:/.test(manifest.spritesheetPath)
) {
throw new Error('spritesheetPath must be relative to the bundle.')
throw new PetImportError('sheetPathNotRelative')
}
// Accept Windows-exported bundles on macOS/Linux.
const sheetSrc = resolve(bundleDir, normalizedSpritePath)
const bundleResolved = resolve(bundleDir)
if (sheetSrc === bundleResolved) {
throw new Error('spritesheetPath must point to a file, not the bundle root.')
throw new PetImportError('sheetPathIsRoot')
}
const bundleRoot = bundleResolved + sep
// Match paths case-insensitively on Windows.
const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s
if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) {
throw new Error('spritesheetPath escapes the bundle.')
throw new PetImportError('sheetPathEscapes')
}
if (await isSymlink(sheetSrc)) {
throw new Error('spritesheet must not be a symlink.')
throw new PetImportError('sheetSymlink')
}
const sheetClass = classifyFile(sheetSrc)
if (!sheetClass || sheetClass.ext === '.svg') {
// Sprite layouts require raster dimensions.
throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.')
throw new PetImportError('sheetUnsupportedType')
}
let sheetStat: Awaited<ReturnType<typeof stat>>
try {
sheetStat = await stat(sheetSrc)
} catch {
throw new Error('Spritesheet file not found.')
throw new PetImportError('sheetNotFound')
}
if (!sheetStat.isFile()) {
throw new Error('Spritesheet path is not a file.')
throw new PetImportError('sheetNotAFile')
}
if (sheetStat.size > MAX_BYTES) {
throw new Error(`Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).`)
throw new PetImportError('sheetTooLarge', { size: (sheetStat.size / (1024 * 1024)).toFixed(1) })
}
return { sheetSrc, sheetClass }
}
+6 -3
View File
@@ -168,8 +168,11 @@ describe('registerPetHandlers', () => {
})
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] })
await expect(getHandler('pet:importPetBundle')({ sender: {} })).rejects.toThrow(
'declares 1 frame durations but 2 frames'
)
await expect(getHandler('pet:importPetBundle')({ sender: {} })).resolves.toEqual({
ok: false,
error: 'Animation "idle" declares 1 frame durations but 2 frames.',
errorCode: 'animationDurationMismatch',
errorParams: { name: 'idle', durations: 1, frames: 2 }
})
})
})
+85 -63
View File
@@ -1,4 +1,9 @@
import { translateMain } from '../i18n/main-i18n'
import {
CUSTOM_PET_DEFAULT_LABEL,
PetImportError,
type PetImportFailure
} from '../../shared/pet-import-failure-copy'
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
@@ -17,73 +22,90 @@ const PetFileRequestSchema = z.object({
kind: z.enum(['image', 'bundle']).optional()
})
// Known refusals return as data so the renderer can translate them by code.
async function withPetImportFailure(
run: () => Promise<CustomPet | null>
): Promise<CustomPet | PetImportFailure | null> {
try {
return await run()
} catch (error) {
if (error instanceof PetImportError) {
return error.failure
}
throw error
}
}
export function registerPetHandlers(): void {
ipcMain.handle('pet:import', async (event): Promise<CustomPet | null> => {
const senderWindow =
BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow()
const options: Electron.OpenDialogOptions = {
title: translateMain('native.dialog.pickPet', 'Pick pet'),
properties: ['openFile'],
// APNG uses .png; its missing macOS UTI can hide other formats when listed separately.
filters: [
{
name: translateMain('native.dialog.petImage', 'Pet image'),
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']
}
]
}
const result = senderWindow
? await dialog.showOpenDialog(senderWindow, options)
: await dialog.showOpenDialog(options)
if (result.canceled || result.filePaths.length === 0) {
return null
}
const src = result.filePaths[0]
const classified = classifyFile(src)
if (!classified) {
throw new Error('Unsupported file. Pick a PNG, APNG, JPG, GIF, WebP, or SVG.')
}
let srcStat: Awaited<ReturnType<typeof stat>>
try {
srcStat = await stat(src)
} catch {
throw new Error('Could not read the selected file.')
}
if (!srcStat.isFile()) {
throw new Error('Selected path is not a file')
}
if (srcStat.size > MAX_BYTES) {
throw new Error(
`File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_BYTES / (1024 * 1024)} MB.`
)
}
ipcMain.handle('pet:import', (event) =>
withPetImportFailure(async (): Promise<CustomPet | null> => {
const senderWindow =
BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow()
const options: Electron.OpenDialogOptions = {
title: translateMain('native.dialog.pickPet', 'Pick pet'),
properties: ['openFile'],
// APNG uses .png; its missing macOS UTI can hide other formats when listed separately.
filters: [
{
name: translateMain('native.dialog.petImage', 'Pet image'),
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']
}
]
}
const result = senderWindow
? await dialog.showOpenDialog(senderWindow, options)
: await dialog.showOpenDialog(options)
if (result.canceled || result.filePaths.length === 0) {
return null
}
const src = result.filePaths[0]
const classified = classifyFile(src)
if (!classified) {
throw new PetImportError('unsupportedFile')
}
let srcStat: Awaited<ReturnType<typeof stat>>
try {
srcStat = await stat(src)
} catch {
throw new PetImportError('fileUnreadable')
}
if (!srcStat.isFile()) {
throw new PetImportError('notAFile')
}
if (srcStat.size > MAX_BYTES) {
throw new PetImportError('fileTooLarge', {
size: (srcStat.size / (1024 * 1024)).toFixed(1),
max: MAX_BYTES / (1024 * 1024)
})
}
const dir = getPetsDir()
await mkdir(dir, { recursive: true })
const id = randomUUID()
// Keep the extension so pet:read can recover MIME type without another lookup.
const fileName = `${id}${classified.ext}`
const dest = join(dir, fileName)
try {
await copyFile(src, dest)
} catch {
await rm(dest, { force: true }).catch(() => {})
throw new Error('Could not save the pet.')
}
const dir = getPetsDir()
await mkdir(dir, { recursive: true })
const id = randomUUID()
// Keep the extension so pet:read can recover MIME type without another lookup.
const fileName = `${id}${classified.ext}`
const dest = join(dir, fileName)
try {
await copyFile(src, dest)
} catch {
await rm(dest, { force: true }).catch(() => {})
throw new PetImportError('saveFailed')
}
const rawLabel = basename(src, extname(src)).trim()
const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Custom pet'
return {
id,
label,
fileName,
mimeType: classified.mimeType,
kind: 'image'
}
})
const rawLabel = basename(src, extname(src)).trim()
const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : CUSTOM_PET_DEFAULT_LABEL
return {
id,
label,
fileName,
mimeType: classified.mimeType,
kind: 'image'
}
})
)
ipcMain.handle('pet:importPetBundle', async (event): Promise<CustomPet | null> =>
importPetBundle(event)
ipcMain.handle('pet:importPetBundle', (event) =>
withPetImportFailure(() => importPetBundle(event))
)
ipcMain.handle(
+35 -34
View File
@@ -13,6 +13,10 @@ import type {
ShellOpenLocalPathResult
} from '../../shared/shell-open-types'
import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon'
import {
repoIconPickFailure,
type RepoIconPickResult
} from '../../shared/repo-icon-pick-failure-copy'
import type { Store } from '../persistence'
import {
EXTERNAL_EDITOR_CLI_COMMAND,
@@ -270,41 +274,38 @@ export function registerShellHandlers(
return result.filePaths[0]
})
ipcMain.handle(
'shell:pickRepoIconImage',
async (): Promise<{ dataUrl: string; fileName: string } | null> => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: translateMain('native.dialog.repoIconImages', 'Repo icon images'),
extensions: ['png']
}
]
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
const filePath = result.filePaths[0]
const extension = extname(filePath).toLowerCase()
const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extension]
if (!mimeType) {
throw new Error('Repo icons must be PNG files.')
}
const stats = await stat(filePath)
if (stats.size > MAX_REPO_ICON_UPLOAD_BYTES) {
throw new Error('Repo icon image must be 256KB or smaller.')
}
const buffer = await readFile(filePath)
return {
dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`,
fileName: basename(filePath)
}
ipcMain.handle('shell:pickRepoIconImage', async (): Promise<RepoIconPickResult> => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: translateMain('native.dialog.repoIconImages', 'Repo icon images'),
extensions: ['png']
}
]
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
)
const filePath = result.filePaths[0]
const extension = extname(filePath).toLowerCase()
const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extension]
if (!mimeType) {
return repoIconPickFailure('notPng')
}
const stats = await stat(filePath)
if (stats.size > MAX_REPO_ICON_UPLOAD_BYTES) {
return repoIconPickFailure('tooLarge')
}
const buffer = await readFile(filePath)
return {
dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`,
fileName: basename(filePath)
}
})
ipcMain.handle('shell:pickAudio', async (): Promise<string | null> => {
const result = await dialog.showOpenDialog({
+3 -2
View File
@@ -1,8 +1,9 @@
import type { CustomPet } from '../../shared/pet-types'
import type { PetImportFailure } from '../../shared/pet-import-failure-copy'
export type PetApi = {
import: () => Promise<CustomPet | null>
importPetBundle: () => Promise<CustomPet | null>
import: () => Promise<CustomPet | PetImportFailure | null>
importPetBundle: () => Promise<CustomPet | PetImportFailure | null>
read: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise<ArrayBuffer | null>
delete: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise<void>
}
+4 -2
View File
@@ -1,10 +1,12 @@
import { ipcRenderer } from 'electron'
import type { CustomPet } from '../../shared/pet-types'
import type { PetImportFailure } from '../../shared/pet-import-failure-copy'
import type { PreloadApi } from '../api-types'
export const petApi = {
import: (): Promise<CustomPet | null> => ipcRenderer.invoke('pet:import'),
importPetBundle: (): Promise<CustomPet | null> => ipcRenderer.invoke('pet:importPetBundle'),
import: (): Promise<CustomPet | PetImportFailure | null> => ipcRenderer.invoke('pet:import'),
importPetBundle: (): Promise<CustomPet | PetImportFailure | null> =>
ipcRenderer.invoke('pet:importPetBundle'),
read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise<ArrayBuffer | null> =>
ipcRenderer.invoke('pet:read', id, fileName, kind),
delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise<void> =>
+2 -4
View File
@@ -1,3 +1,4 @@
import type { RepoIconPickResult } from '../../shared/repo-icon-pick-failure-copy'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { ShellPathExistence } from '../../shared/path-existence-batch'
import type {
@@ -30,10 +31,7 @@ export type ShellApi = {
pickAttachment: () => Promise<string | null>
pickAttachments: () => Promise<string[]>
pickImage: () => Promise<string | null>
pickRepoIconImage: () => Promise<{
dataUrl: string
fileName: string
} | null>
pickRepoIconImage: () => Promise<RepoIconPickResult>
pickAudio: () => Promise<string | null>
pickDirectory: (args: { defaultPath?: string }) => Promise<string | null>
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>
+2 -1
View File
@@ -1,3 +1,4 @@
import type { RepoIconPickResult } from '../../shared/repo-icon-pick-failure-copy'
import { ipcRenderer } from 'electron'
import type {
ShellOpenExternalEditorRequest,
@@ -41,7 +42,7 @@ export const shellApi = {
pickImage: (): Promise<string | null> => ipcRenderer.invoke('shell:pickImage'),
pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> =>
pickRepoIconImage: (): Promise<RepoIconPickResult> =>
ipcRenderer.invoke('shell:pickRepoIconImage'),
pickAudio: (): Promise<string | null> => ipcRenderer.invoke('shell:pickAudio'),
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it } from 'vitest'
import { i18n } from '@/i18n/i18n'
import es from '@/i18n/locales/es.json'
import { PetImportError } from '../../../../shared/pet-import-failure-copy'
import { customPetLabel, petImportFailureText } from './pet-import-failure-text'
const originalLanguage = i18n.language
afterEach(async () => {
await i18n.changeLanguage(originalLanguage)
})
describe('pet import failure text', () => {
it('localizes a coded failure while main keeps the English error', async () => {
i18n.addResourceBundle('es', 'translation', es)
await i18n.changeLanguage('es')
const { failure } = new PetImportError('fileTooLarge', { size: '12.5', max: 10 })
expect(failure.error).toBe('File is too large (12.5 MB). Max is 10 MB.')
expect(petImportFailureText(failure)).toBe(
es.pet.importErrors.fileTooLarge.replace('{{size}}', '12.5').replace('{{max}}', '10')
)
const parse = new PetImportError('manifestParseFailed', {}, 'Unexpected token }').failure
expect(petImportFailureText(parse)).toContain('Unexpected token }')
})
it('shows main English for unknown codes or missing params', async () => {
i18n.addResourceBundle('es', 'translation', es)
await i18n.changeLanguage('es')
expect(petImportFailureText({ ok: false, error: 'Newer', errorCode: 'future' })).toBe('Newer')
expect(petImportFailureText({ ok: false, error: 'English', errorCode: 'fileTooLarge' })).toBe(
'English'
)
})
it('translates only the stored default names', async () => {
i18n.addResourceBundle('es', 'translation', es)
await i18n.changeLanguage('es')
expect(customPetLabel({ label: 'Custom pet', kind: 'image' })).toBe(es.pet.defaultLabels.image)
expect(customPetLabel({ label: 'Custom pet' })).toBe(es.pet.defaultLabels.image)
expect(customPetLabel({ label: 'Pet bundle', kind: 'bundle' })).toBe(
es.pet.defaultLabels.bundle
)
expect(customPetLabel({ label: 'Mochi', kind: 'bundle' })).toBe('Mochi')
})
})
@@ -0,0 +1,156 @@
import { translate } from '@/i18n/i18n'
import type { CustomPet } from '../../../../shared/pet-types'
import {
CUSTOM_PET_DEFAULT_LABEL,
PET_BUNDLE_DEFAULT_LABEL,
PET_IMPORT_FAILURE_COPY as EN,
type PetImportFailure,
type PetImportFailureCode
} from '../../../../shared/pet-import-failure-copy'
type Values = {
text: (name: string) => string | null
count: (name: string) => number | null
}
// null = required values missing; caller shows main's English `error`.
const COPY: Record<PetImportFailureCode, (values: Values) => string | null> = {
unsupportedFile: () => translate('pet.importErrors.unsupportedFile', EN.unsupportedFile),
fileUnreadable: () => translate('pet.importErrors.fileUnreadable', EN.fileUnreadable),
notAFile: () => translate('pet.importErrors.notAFile', EN.notAFile),
fileTooLarge: ({ text, count }) => {
const size = text('size')
const max = count('max')
return size === null || max === null
? null
: translate('pet.importErrors.fileTooLarge', EN.fileTooLarge, { size, max })
},
saveFailed: () => translate('pet.importErrors.saveFailed', EN.saveFailed),
pathUnreadable: () => translate('pet.importErrors.pathUnreadable', EN.pathUnreadable),
manifestMissing: () => translate('pet.importErrors.manifestMissing', EN.manifestMissing),
manifestInvalid: () => translate('pet.importErrors.manifestInvalid', EN.manifestInvalid),
manifestSymlink: () => translate('pet.importErrors.manifestSymlink', EN.manifestSymlink),
manifestParseFailed: ({ text }) => {
const detail = text('detail')
return detail === null
? null
: translate('pet.importErrors.manifestParseFailed', EN.manifestParseFailed, { detail })
},
bundleSaveFailed: () => translate('pet.importErrors.bundleSaveFailed', EN.bundleSaveFailed),
sheetPathNotRelative: () =>
translate('pet.importErrors.sheetPathNotRelative', EN.sheetPathNotRelative),
sheetPathIsRoot: () => translate('pet.importErrors.sheetPathIsRoot', EN.sheetPathIsRoot),
sheetPathEscapes: () => translate('pet.importErrors.sheetPathEscapes', EN.sheetPathEscapes),
sheetSymlink: () => translate('pet.importErrors.sheetSymlink', EN.sheetSymlink),
sheetUnsupportedType: () =>
translate('pet.importErrors.sheetUnsupportedType', EN.sheetUnsupportedType),
sheetNotFound: () => translate('pet.importErrors.sheetNotFound', EN.sheetNotFound),
sheetNotAFile: () => translate('pet.importErrors.sheetNotAFile', EN.sheetNotAFile),
sheetTooLarge: ({ text }) => {
const size = text('size')
return size === null
? null
: translate('pet.importErrors.sheetTooLarge', EN.sheetTooLarge, { size })
},
sheetSizeLimitExceeded: () =>
translate('pet.importErrors.sheetSizeLimitExceeded', EN.sheetSizeLimitExceeded),
sheetUndecodable: () => translate('pet.importErrors.sheetUndecodable', EN.sheetUndecodable),
sheetFrameMismatch: ({ count }) => {
const sheetWidth = count('sheetWidth')
const sheetHeight = count('sheetHeight')
const frameWidth = count('frameWidth')
const frameHeight = count('frameHeight')
return sheetWidth === null ||
sheetHeight === null ||
frameWidth === null ||
frameHeight === null
? null
: translate('pet.importErrors.sheetFrameMismatch', EN.sheetFrameMismatch, {
sheetWidth,
sheetHeight,
frameWidth,
frameHeight
})
},
animationRowOutOfRange: ({ text, count }) => {
const name = text('name')
const row = count('row')
const rows = count('rows')
return name === null || row === null || rows === null
? null
: translate('pet.importErrors.animationRowOutOfRange', EN.animationRowOutOfRange, {
name,
row,
rows
})
},
animationTooManyFrames: ({ text, count }) => {
const name = text('name')
const frames = count('frames')
const columns = count('columns')
return name === null || frames === null || columns === null
? null
: translate('pet.importErrors.animationTooManyFrames', EN.animationTooManyFrames, {
name,
frames,
columns
})
},
animationDurationMismatch: ({ text, count }) => {
const name = text('name')
const durations = count('durations')
const frames = count('frames')
return name === null || durations === null || frames === null
? null
: translate('pet.importErrors.animationDurationMismatch', EN.animationDurationMismatch, {
name,
durations,
frames
})
},
defaultAnimationMissing: ({ text }) => {
const name = text('name')
return name === null
? null
: translate('pet.importErrors.defaultAnimationMissing', EN.defaultAnimationMissing, {
name
})
}
}
function isPetImportFailureCode(code: unknown): code is PetImportFailureCode {
return typeof code === 'string' && Object.hasOwn(COPY, code)
}
/** Unknown or uncoded failures show main's English text. */
export function petImportFailureText(failure: PetImportFailure): string {
if (!isPetImportFailureCode(failure.errorCode)) {
return failure.error
}
const params = failure.errorParams ?? {}
const read = (name: string): unknown => (Object.hasOwn(params, name) ? params[name] : undefined)
return (
COPY[failure.errorCode]({
text: (name) => {
const value = name === 'detail' ? failure.errorDetail : read(name)
return typeof value === 'string' ? value : null
},
count: (name) => {
const value = read(name)
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
}) ?? failure.error
)
}
/** Default names are stored in English; show them in the UI language. */
export function customPetLabel(pet: Pick<CustomPet, 'label' | 'kind'>): string {
if (pet.kind === 'bundle') {
return pet.label === PET_BUNDLE_DEFAULT_LABEL
? translate('pet.defaultLabels.bundle', PET_BUNDLE_DEFAULT_LABEL)
: pet.label
}
return pet.label === CUSTOM_PET_DEFAULT_LABEL
? translate('pet.defaultLabels.image', CUSTOM_PET_DEFAULT_LABEL)
: pet.label
}
@@ -11,6 +11,7 @@ import { getRepoLucideIconOptions } from '../../../repo/repo-icon'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import { repoIconPickFailureText } from './repo-icon-pick-failure-text'
const RepositoryIconEmojiPicker = lazyWithRetry(
() =>
@@ -46,6 +47,10 @@ export function RepositoryIconTabs({
if (!result || !mountedRef.current) {
return
}
if ('error' in result) {
toast.error(repoIconPickFailureText(result))
return
}
onSetIcon({
type: 'image',
src: result.dataUrl,
@@ -0,0 +1,20 @@
import { translate } from '@/i18n/i18n'
import {
REPO_ICON_PICK_FAILURE_COPY as EN,
type RepoIconPickFailure,
type RepoIconPickFailureCode
} from '../../../../../../shared/repo-icon-pick-failure-copy'
const COPY: Record<RepoIconPickFailureCode, () => string> = {
notPng: () => translate('settings.repository.iconPickErrors.notPng', EN.notPng),
tooLarge: () => translate('settings.repository.iconPickErrors.tooLarge', EN.tooLarge)
}
function isRepoIconPickFailureCode(code: unknown): code is RepoIconPickFailureCode {
return typeof code === 'string' && Object.hasOwn(COPY, code)
}
/** Unknown or uncoded failures show main's English text. */
export function repoIconPickFailureText(failure: RepoIconPickFailure): string {
return isRepoIconPickFailureCode(failure.errorCode) ? COPY[failure.errorCode]() : failure.error
}
@@ -18,6 +18,8 @@ import { BUNDLED_PET, BUNDLED_PETS, findBundledPet, isBundledPetId } from '../pe
import { PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/pet-types'
import { translate } from '@/i18n/i18n'
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
import { isPetImportFailure } from '../../../../shared/pet-import-failure-copy'
import { customPetLabel, petImportFailureText } from '../pet/pet-import-failure-text'
// Keep visibility separate from experimental enablement so users can dismiss the pet without revisiting Settings.
function PetStatusSegmentInner(): React.JSX.Element {
@@ -37,7 +39,11 @@ function PetStatusSegmentInner(): React.JSX.Element {
const bundled = isBundledPetId(petId)
const activeBundled = bundled ? (findBundledPet(petId) ?? BUNDLED_PET) : null
const activeCustom = bundled ? null : customPets.find((m) => m.id === petId)
const activeLabel = activeBundled ? activeBundled.label : (activeCustom?.label ?? 'Pet')
const activeLabel = activeBundled
? activeBundled.label
: activeCustom
? customPetLabel(activeCustom)
: 'Pet'
const label = petVisible ? activeLabel : `${activeLabel} hidden`
const handleImport = async (): Promise<void> => {
@@ -58,6 +64,10 @@ function PetStatusSegmentInner(): React.JSX.Element {
if (!model) {
return
}
if (isPetImportFailure(model)) {
toast.error(petImportFailureText(model))
return
}
addCustomPet(model)
if (!petVisible) {
setPetVisible(true)
@@ -91,6 +101,10 @@ function PetStatusSegmentInner(): React.JSX.Element {
if (!model) {
return
}
if (isPetImportFailure(model)) {
toast.error(petImportFailureText(model))
return
}
addCustomPet(model)
if (!petVisible) {
setPetVisible(true)
@@ -214,14 +228,14 @@ function PetStatusSegmentInner(): React.JSX.Element {
<span className="flex w-4 items-center justify-center">
{selected ? <Check className="size-3.5" aria-hidden /> : null}
</span>
<span className="flex-1 truncate">{model.label}</span>
<span className="flex-1 truncate">{customPetLabel(model)}</span>
<button
type="button"
className="ml-2 flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-destructive/15 hover:text-destructive"
aria-label={translate(
'auto.components.status.bar.PetStatusSegment.3668339495',
'Remove {{value0}}',
{ value0: model.label }
{ value0: customPetLabel(model) }
)}
onClick={(event) => {
event.stopPropagation()
+40
View File
@@ -1996,6 +1996,40 @@
"menu": {
"quitApp": "Quit {{appName}}"
},
"pet": {
"defaultLabels": {
"bundle": "Pet bundle",
"image": "Custom pet"
},
"importErrors": {
"animationDurationMismatch": "Animation \"{{name}}\" declares {{durations}} frame durations but {{frames}} frames.",
"animationRowOutOfRange": "Animation \"{{name}}\" references row {{row}} but sheet has {{rows}}.",
"animationTooManyFrames": "Animation \"{{name}}\" has {{frames}} frames but sheet only has {{columns}} columns.",
"bundleSaveFailed": "Could not save the pet bundle.",
"defaultAnimationMissing": "defaultAnimation \"{{name}}\" not in animations.",
"fileTooLarge": "File is too large ({{size}} MB). Max is {{max}} MB.",
"fileUnreadable": "Could not read the selected file.",
"manifestInvalid": "pet.json is invalid.",
"manifestMissing": "Bundle is missing pet.json.",
"manifestParseFailed": "Invalid pet.json: {{detail}}",
"manifestSymlink": "pet.json must not be a symlink.",
"notAFile": "Selected path is not a file",
"pathUnreadable": "Could not read the selected path.",
"saveFailed": "Could not save the pet.",
"sheetFrameMismatch": "Spritesheet {{sheetWidth}}×{{sheetHeight}} is not a clean multiple of frame {{frameWidth}}×{{frameHeight}}.",
"sheetNotAFile": "Spritesheet path is not a file.",
"sheetNotFound": "Spritesheet file not found.",
"sheetPathEscapes": "spritesheetPath escapes the bundle.",
"sheetPathIsRoot": "spritesheetPath must point to a file, not the bundle root.",
"sheetPathNotRelative": "spritesheetPath must be relative to the bundle.",
"sheetSizeLimitExceeded": "Spritesheet exceeded the size limit.",
"sheetSymlink": "spritesheet must not be a symlink.",
"sheetTooLarge": "Spritesheet is too large ({{size}} MB).",
"sheetUndecodable": "Could not decode the spritesheet image.",
"sheetUnsupportedType": "Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.",
"unsupportedFile": "Unsupported file. Pick a PNG, APNG, JPG, GIF, WebP, or SVG."
}
},
"projectViews": {
"error": {
"tooLargeCount_one": "This view has {{count}} item — too large to render in Orca. Narrow the view's filter on GitHub.",
@@ -2085,6 +2119,12 @@
"unsupportedProtocol": "Use an http, https, socks, socks4, or socks5 proxy URL."
}
},
"repository": {
"iconPickErrors": {
"notPng": "Repo icons must be PNG files.",
"tooLarge": "Repo icon image must be 256KB or smaller."
}
},
"shortcuts": {
"diagnostics": {
"conflictingOverrides": "Conflicting custom shortcuts were ignored: {{actions}}.",
+40
View File
@@ -874,6 +874,12 @@
"discardFailed": "Datei zur Prüfung konnte nicht verworfen werden",
"deleteFailed": "Gesendete Diagnosedaten konnten nicht gelöscht werden"
}
},
"repository": {
"iconPickErrors": {
"notPng": "Repository-Symbole müssen PNG-Dateien sein.",
"tooLarge": "Das Bild für das Repository-Symbol darf höchstens 256 KB groß sein."
}
}
},
"menu": {
@@ -22326,5 +22332,39 @@
"repairing": "Orca repariert gerade die Berechtigungen.",
"repairFailed": "Orca konnte sie nicht reparieren. Meist sind für den Ordner Administratorrechte erforderlich.",
"runCommands": "Diese Befehle in einer Eingabeaufforderung mit Administratorrechten ausführen und Orca anschließend erneut starten:"
},
"pet": {
"importErrors": {
"unsupportedFile": "Nicht unterstützte Datei. PNG, APNG, JPG, GIF, WebP oder SVG auswählen.",
"fileUnreadable": "Die ausgewählte Datei konnte nicht gelesen werden.",
"notAFile": "Der ausgewählte Pfad ist keine Datei",
"fileTooLarge": "Die Datei ist zu groß ({{size}} MB). Maximal {{max}} MB.",
"saveFailed": "Das Haustier konnte nicht gespeichert werden.",
"pathUnreadable": "Der ausgewählte Pfad konnte nicht gelesen werden.",
"manifestMissing": "Im Paket fehlt pet.json.",
"manifestInvalid": "pet.json ist ungültig.",
"manifestSymlink": "pet.json darf kein symbolischer Link sein.",
"manifestParseFailed": "Ungültige pet.json: {{detail}}",
"bundleSaveFailed": "Das Haustierpaket konnte nicht gespeichert werden.",
"sheetPathNotRelative": "spritesheetPath muss relativ zum Paket sein.",
"sheetPathIsRoot": "spritesheetPath muss auf eine Datei verweisen, nicht auf das Stammverzeichnis des Pakets.",
"sheetPathEscapes": "spritesheetPath liegt außerhalb des Pakets.",
"sheetSymlink": "Das Spritesheet darf kein symbolischer Link sein.",
"sheetUnsupportedType": "Das Spritesheet muss eine PNG-, APNG-, JPG-, GIF- oder WebP-Datei sein.",
"sheetNotFound": "Spritesheet-Datei nicht gefunden.",
"sheetNotAFile": "Der Spritesheet-Pfad ist keine Datei.",
"sheetTooLarge": "Das Spritesheet ist zu groß ({{size}} MB).",
"sheetSizeLimitExceeded": "Das Spritesheet überschreitet die Größenbegrenzung.",
"sheetUndecodable": "Das Spritesheet-Bild konnte nicht dekodiert werden.",
"sheetFrameMismatch": "Spritesheet {{sheetWidth}}×{{sheetHeight}} ist kein ganzzahliges Vielfaches der Frame-Größe {{frameWidth}}×{{frameHeight}}.",
"animationRowOutOfRange": "Animation „{{name}}“ verweist auf Zeile {{row}}, aber die Zeilenanzahl im Spritesheet beträgt nur {{rows}}.",
"animationTooManyFrames": "Die Frame-Anzahl der Animation „{{name}}“ ({{frames}}) übersteigt die Spaltenanzahl des Spritesheets ({{columns}}).",
"animationDurationMismatch": "Bei der Animation „{{name}}“ stimmt die Anzahl der angegebenen Frame-Dauern ({{durations}}) nicht mit der Frame-Anzahl ({{frames}}) überein.",
"defaultAnimationMissing": "defaultAnimation „{{name}}“ ist nicht in animations enthalten."
},
"defaultLabels": {
"image": "Benutzerdefiniertes Haustier",
"bundle": "Haustierpaket"
}
}
}
+40
View File
@@ -892,6 +892,12 @@
"discardFailed": "Could not discard review file",
"deleteFailed": "Could not delete sent diagnostics"
}
},
"repository": {
"iconPickErrors": {
"notPng": "Repo icons must be PNG files.",
"tooLarge": "Repo icon image must be 256KB or smaller."
}
}
},
"menu": {
@@ -22790,5 +22796,39 @@
"repairing": "Orca is repairing the permissions now.",
"repairFailed": "Orca could not repair them, which usually means the folder needs an administrator.",
"runCommands": "Run these in an Administrator Command Prompt, then relaunch Orca:"
},
"pet": {
"importErrors": {
"unsupportedFile": "Unsupported file. Pick a PNG, APNG, JPG, GIF, WebP, or SVG.",
"fileUnreadable": "Could not read the selected file.",
"notAFile": "Selected path is not a file",
"fileTooLarge": "File is too large ({{size}} MB). Max is {{max}} MB.",
"saveFailed": "Could not save the pet.",
"pathUnreadable": "Could not read the selected path.",
"manifestMissing": "Bundle is missing pet.json.",
"manifestInvalid": "pet.json is invalid.",
"manifestSymlink": "pet.json must not be a symlink.",
"manifestParseFailed": "Invalid pet.json: {{detail}}",
"bundleSaveFailed": "Could not save the pet bundle.",
"sheetPathNotRelative": "spritesheetPath must be relative to the bundle.",
"sheetPathIsRoot": "spritesheetPath must point to a file, not the bundle root.",
"sheetPathEscapes": "spritesheetPath escapes the bundle.",
"sheetSymlink": "spritesheet must not be a symlink.",
"sheetUnsupportedType": "Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.",
"sheetNotFound": "Spritesheet file not found.",
"sheetNotAFile": "Spritesheet path is not a file.",
"sheetTooLarge": "Spritesheet is too large ({{size}} MB).",
"sheetSizeLimitExceeded": "Spritesheet exceeded the size limit.",
"sheetUndecodable": "Could not decode the spritesheet image.",
"sheetFrameMismatch": "Spritesheet {{sheetWidth}}×{{sheetHeight}} is not a clean multiple of frame {{frameWidth}}×{{frameHeight}}.",
"animationRowOutOfRange": "Animation \"{{name}}\" references row {{row}} but sheet has {{rows}}.",
"animationTooManyFrames": "Animation \"{{name}}\" has {{frames}} frames but sheet only has {{columns}} columns.",
"animationDurationMismatch": "Animation \"{{name}}\" declares {{durations}} frame durations but {{frames}} frames.",
"defaultAnimationMissing": "defaultAnimation \"{{name}}\" not in animations."
},
"defaultLabels": {
"image": "Custom pet",
"bundle": "Pet bundle"
}
}
}
+40
View File
@@ -478,6 +478,12 @@
"discardFailed": "No se pudo descartar el archivo de revisión",
"deleteFailed": "No se pudieron eliminar los diagnósticos enviados"
}
},
"repository": {
"iconPickErrors": {
"notPng": "Los iconos del repositorio deben ser archivos PNG.",
"tooLarge": "La imagen del icono del repositorio debe tener un tamaño máximo de 256 KB."
}
}
},
"menu": {
@@ -22944,5 +22950,39 @@
"repairing": "Orca está reparando los permisos.",
"repairFailed": "Orca no pudo repararlos, lo que normalmente significa que la carpeta requiere permisos de administrador.",
"runCommands": "Ejecuta estos comandos en un Símbolo del sistema abierto como administrador y luego vuelve a iniciar Orca:"
},
"pet": {
"importErrors": {
"unsupportedFile": "Archivo no compatible. Elige un PNG, APNG, JPG, GIF, WebP o SVG.",
"fileUnreadable": "No se pudo leer el archivo seleccionado.",
"notAFile": "La ruta seleccionada no es un archivo",
"fileTooLarge": "El archivo es demasiado grande ({{size}} MB). El máximo es {{max}} MB.",
"saveFailed": "No se pudo guardar la mascota.",
"pathUnreadable": "No se pudo leer la ruta seleccionada.",
"manifestMissing": "Falta pet.json en el bundle.",
"manifestInvalid": "pet.json no es válido.",
"manifestSymlink": "pet.json no puede ser un enlace simbólico.",
"manifestParseFailed": "pet.json no válido: {{detail}}",
"bundleSaveFailed": "No se pudo guardar el bundle de mascota.",
"sheetPathNotRelative": "spritesheetPath debe ser una ruta relativa al bundle.",
"sheetPathIsRoot": "spritesheetPath debe apuntar a un archivo, no a la raíz del bundle.",
"sheetPathEscapes": "spritesheetPath apunta fuera del bundle.",
"sheetSymlink": "La hoja de sprites no puede ser un enlace simbólico.",
"sheetUnsupportedType": "La hoja de sprites debe ser un archivo PNG, APNG, JPG, GIF o WebP.",
"sheetNotFound": "No se encontró el archivo de la hoja de sprites.",
"sheetNotAFile": "La ruta de la hoja de sprites no es un archivo.",
"sheetTooLarge": "La hoja de sprites es demasiado grande ({{size}} MB).",
"sheetSizeLimitExceeded": "La hoja de sprites superó el límite de tamaño.",
"sheetUndecodable": "No se pudo decodificar la imagen de la hoja de sprites.",
"sheetFrameMismatch": "La hoja de sprites de {{sheetWidth}}×{{sheetHeight}} no es un múltiplo exacto del fotograma de {{frameWidth}}×{{frameHeight}}.",
"animationRowOutOfRange": "La animación «{{name}}» hace referencia a la fila {{row}}, pero la hoja solo tiene {{rows}}.",
"animationTooManyFrames": "El número de fotogramas de la animación «{{name}}» ({{frames}}) supera el número de columnas de la hoja ({{columns}}).",
"animationDurationMismatch": "En la animación «{{name}}», el número de duraciones de fotograma declaradas ({{durations}}) no coincide con el número de fotogramas ({{frames}}).",
"defaultAnimationMissing": "defaultAnimation «{{name}}» no está en animations."
},
"defaultLabels": {
"image": "Mascota personalizada",
"bundle": "Bundle de mascota"
}
}
}
+40
View File
@@ -698,6 +698,12 @@
"discardFailed": "Impossible d’abandonner le fichier de révision",
"deleteFailed": "Impossible de supprimer les diagnostics envoyés"
}
},
"repository": {
"iconPickErrors": {
"notPng": "Les icônes de dépôt doivent être des fichiers PNG.",
"tooLarge": "L’image de l’icône du dépôt doit faire 256 Ko maximum."
}
}
},
"menu": {
@@ -22944,5 +22950,39 @@
"repairing": "Orca corrige les autorisations.",
"repairFailed": "Orca n'a pas pu les corriger, ce qui signifie généralement que le dossier nécessite des droits d'administrateur.",
"runCommands": "Exécutez ces commandes dans une invite de commandes ouverte en tant qu'administrateur, puis relancez Orca :"
},
"pet": {
"importErrors": {
"unsupportedFile": "Fichier non pris en charge. Choisissez un PNG, APNG, JPG, GIF, WebP ou SVG.",
"fileUnreadable": "Impossible de lire le fichier sélectionné.",
"notAFile": "Le chemin sélectionné n’est pas un fichier",
"fileTooLarge": "Le fichier est trop volumineux ({{size}} Mo). Maximum : {{max}} Mo.",
"saveFailed": "Impossible d’enregistrer le compagnon.",
"pathUnreadable": "Impossible de lire le chemin sélectionné.",
"manifestMissing": "Le bundle ne contient pas pet.json.",
"manifestInvalid": "pet.json n’est pas valide.",
"manifestSymlink": "pet.json ne doit pas être un lien symbolique.",
"manifestParseFailed": "pet.json non valide : {{detail}}",
"bundleSaveFailed": "Impossible d’enregistrer le bundle de compagnon.",
"sheetPathNotRelative": "spritesheetPath doit être relatif au bundle.",
"sheetPathIsRoot": "spritesheetPath doit pointer vers un fichier, pas vers la racine du bundle.",
"sheetPathEscapes": "spritesheetPath pointe en dehors du bundle.",
"sheetSymlink": "La feuille de sprites ne doit pas être un lien symbolique.",
"sheetUnsupportedType": "La feuille de sprites doit être un fichier PNG, APNG, JPG, GIF ou WebP.",
"sheetNotFound": "Fichier de feuille de sprites introuvable.",
"sheetNotAFile": "Le chemin de la feuille de sprites n’est pas un fichier.",
"sheetTooLarge": "La feuille de sprites est trop volumineuse ({{size}} Mo).",
"sheetSizeLimitExceeded": "La feuille de sprites a dépassé la taille limite.",
"sheetUndecodable": "Impossible de décoder l’image de la feuille de sprites.",
"sheetFrameMismatch": "La feuille de sprites {{sheetWidth}}×{{sheetHeight}} n’est pas un multiple exact du format d’image {{frameWidth}}×{{frameHeight}}.",
"animationRowOutOfRange": "L’animation « {{name}} » fait référence à la ligne {{row}}, mais la feuille n’en a que {{rows}}.",
"animationTooManyFrames": "Le nombre d’images de l’animation « {{name}} » ({{frames}}) dépasse le nombre de colonnes de la feuille ({{columns}}).",
"animationDurationMismatch": "Pour l’animation « {{name}} », le nombre de durées d’image déclarées ({{durations}}) ne correspond pas au nombre d’images ({{frames}}).",
"defaultAnimationMissing": "defaultAnimation « {{name}} » n’est pas dans animations."
},
"defaultLabels": {
"image": "Compagnon personnalisé",
"bundle": "Bundle de compagnon"
}
}
}
+40
View File
@@ -474,6 +474,12 @@
"discardFailed": "確認用ファイルを破棄できませんでした",
"deleteFailed": "送信済みの診断情報を削除できませんでした"
}
},
"repository": {
"iconPickErrors": {
"notPng": "リポジトリアイコンには PNG ファイルを使用してください。",
"tooLarge": "リポジトリアイコンの画像は 256 KB 以下にしてください。"
}
}
},
"menu": {
@@ -22322,5 +22328,39 @@
"repairing": "Orca が権限を修復しています。",
"repairFailed": "Orca は権限を修復できませんでした。通常、フォルダーへの操作に管理者権限が必要な場合に発生します。",
"runCommands": "管理者権限のコマンドプロンプトで次のコマンドを実行し、Orca を再起動してください:"
},
"pet": {
"importErrors": {
"unsupportedFile": "未対応のファイルです。PNG、APNG、JPG、GIF、WebP、SVG のいずれかを選択してください。",
"fileUnreadable": "選択したファイルを読み取れませんでした。",
"notAFile": "選択したパスはファイルではありません",
"fileTooLarge": "ファイルが大きすぎます({{size}} MB)。上限は {{max}} MB です。",
"saveFailed": "ペットを保存できませんでした。",
"pathUnreadable": "選択したパスを読み取れませんでした。",
"manifestMissing": "バンドルに pet.json がありません。",
"manifestInvalid": "pet.json が無効です。",
"manifestSymlink": "pet.json はシンボリックリンクにできません。",
"manifestParseFailed": "無効な pet.json: {{detail}}",
"bundleSaveFailed": "ペットバンドルを保存できませんでした。",
"sheetPathNotRelative": "spritesheetPath はバンドルからの相対パスである必要があります。",
"sheetPathIsRoot": "spritesheetPath はバンドルのルートではなく、ファイルを指す必要があります。",
"sheetPathEscapes": "spritesheetPath がバンドルの外を指しています。",
"sheetSymlink": "スプライトシートはシンボリックリンクにできません。",
"sheetUnsupportedType": "スプライトシートは PNG、APNG、JPG、GIF、WebP のいずれかである必要があります。",
"sheetNotFound": "スプライトシートのファイルが見つかりません。",
"sheetNotAFile": "スプライトシートのパスはファイルではありません。",
"sheetTooLarge": "スプライトシートが大きすぎます({{size}} MB)。",
"sheetSizeLimitExceeded": "スプライトシートがサイズ上限を超えました。",
"sheetUndecodable": "スプライトシート画像をデコードできませんでした。",
"sheetFrameMismatch": "スプライトシートのサイズ {{sheetWidth}}×{{sheetHeight}} はフレームサイズ {{frameWidth}}×{{frameHeight}} の整数倍ではありません。",
"animationRowOutOfRange": "アニメーション「{{name}}」は {{row}} 行目を参照していますが、シートには {{rows}} 行しかありません。",
"animationTooManyFrames": "アニメーション「{{name}}」には {{frames}} フレームありますが、シートには {{columns}} 列しかありません。",
"animationDurationMismatch": "アニメーション「{{name}}」で指定されたフレーム時間は {{durations}} 個ですが、フレーム数は {{frames}} です。",
"defaultAnimationMissing": "defaultAnimation「{{name}}」が animations にありません。"
},
"defaultLabels": {
"image": "カスタムペット",
"bundle": "ペットバンドル"
}
}
}
+40
View File
@@ -474,6 +474,12 @@
"discardFailed": "검토 파일을 버리지 못했습니다",
"deleteFailed": "전송한 진단 정보를 삭제하지 못했습니다"
}
},
"repository": {
"iconPickErrors": {
"notPng": "저장소 아이콘은 PNG 파일이어야 합니다.",
"tooLarge": "저장소 아이콘 이미지는 256 KB 이하여야 합니다."
}
}
},
"menu": {
@@ -22322,5 +22328,39 @@
"repairing": "Orca가 권한을 복구하고 있습니다.",
"repairFailed": "Orca가 권한을 복구하지 못했습니다. 일반적으로 해당 폴더에 관리자 권한이 필요하다는 뜻입니다.",
"runCommands": "관리자 권한으로 연 명령 프롬프트에서 다음 명령을 실행한 후 Orca를 다시 실행하세요:"
},
"pet": {
"importErrors": {
"unsupportedFile": "지원하지 않는 파일입니다. PNG, APNG, JPG, GIF, WebP 또는 SVG를 선택하세요.",
"fileUnreadable": "선택한 파일을 읽지 못했습니다.",
"notAFile": "선택한 경로가 파일이 아닙니다",
"fileTooLarge": "파일이 너무 큽니다({{size}} MB). 최대 크기는 {{max}} MB입니다.",
"saveFailed": "애완동물을 저장하지 못했습니다.",
"pathUnreadable": "선택한 경로를 읽지 못했습니다.",
"manifestMissing": "번들에 pet.json이 없습니다.",
"manifestInvalid": "pet.json이 유효하지 않습니다.",
"manifestSymlink": "pet.json은 심볼릭 링크일 수 없습니다.",
"manifestParseFailed": "유효하지 않은 pet.json: {{detail}}",
"bundleSaveFailed": "애완동물 번들을 저장하지 못했습니다.",
"sheetPathNotRelative": "spritesheetPath는 번들 기준 상대 경로여야 합니다.",
"sheetPathIsRoot": "spritesheetPath는 번들 루트가 아닌 파일을 가리켜야 합니다.",
"sheetPathEscapes": "spritesheetPath가 번들 외부를 가리킵니다.",
"sheetSymlink": "스프라이트 시트는 심볼릭 링크일 수 없습니다.",
"sheetUnsupportedType": "스프라이트 시트는 PNG, APNG, JPG, GIF 또는 WebP 파일이어야 합니다.",
"sheetNotFound": "스프라이트 시트 파일을 찾을 수 없습니다.",
"sheetNotAFile": "스프라이트 시트 경로가 파일이 아닙니다.",
"sheetTooLarge": "스프라이트 시트가 너무 큽니다({{size}} MB).",
"sheetSizeLimitExceeded": "스프라이트 시트가 크기 제한을 초과했습니다.",
"sheetUndecodable": "스프라이트 시트 이미지를 디코딩하지 못했습니다.",
"sheetFrameMismatch": "스프라이트 시트({{sheetWidth}}×{{sheetHeight}})의 크기가 프레임({{frameWidth}}×{{frameHeight}}) 크기의 정수배가 아닙니다.",
"animationRowOutOfRange": "\"{{name}}\" 애니메이션은 {{row}}행을 참조하지만 시트에는 {{rows}}행만 있습니다.",
"animationTooManyFrames": "애니메이션 \"{{name}}\"에 {{frames}}개의 프레임이 있지만 시트에는 {{columns}}개의 열만 있습니다.",
"animationDurationMismatch": "애니메이션 \"{{name}}\"에 프레임 지속 시간이 {{durations}}개 지정되어 있지만 프레임은 {{frames}}개입니다.",
"defaultAnimationMissing": "defaultAnimation으로 지정된 \"{{name}}\" 애니메이션이 animations에 없습니다."
},
"defaultLabels": {
"image": "사용자 정의 애완동물",
"bundle": "애완동물 번들"
}
}
}
+40
View File
@@ -1483,6 +1483,12 @@
"discardFailed": "Não foi possível descartar o arquivo de revisão",
"deleteFailed": "Não foi possível excluir os diagnósticos enviados"
}
},
"repository": {
"iconPickErrors": {
"notPng": "Os ícones do repositório devem ser arquivos PNG.",
"tooLarge": "A imagem do ícone do repositório deve ter no máximo 256 KB."
}
}
},
"auto": {
@@ -22757,5 +22763,39 @@
"repairing": "O Orca está corrigindo as permissões.",
"repairFailed": "O Orca não conseguiu corrigi-las, o que geralmente significa que a pasta exige permissões de administrador.",
"runCommands": "Execute estes comandos em um Prompt de Comando como administrador e abra o Orca novamente:"
},
"pet": {
"importErrors": {
"unsupportedFile": "Arquivo não compatível. Escolha um PNG, APNG, JPG, GIF, WebP ou SVG.",
"fileUnreadable": "Não foi possível ler o arquivo selecionado.",
"notAFile": "O caminho selecionado não é um arquivo",
"fileTooLarge": "O arquivo é muito grande ({{size}} MB). O máximo é {{max}} MB.",
"saveFailed": "Não foi possível salvar o bichinho.",
"pathUnreadable": "Não foi possível ler o caminho selecionado.",
"manifestMissing": "O pacote não contém pet.json.",
"manifestInvalid": "pet.json é inválido.",
"manifestSymlink": "pet.json não pode ser um link simbólico.",
"manifestParseFailed": "pet.json inválido: {{detail}}",
"bundleSaveFailed": "Não foi possível salvar o pacote do bichinho.",
"sheetPathNotRelative": "spritesheetPath deve ser relativo ao pacote.",
"sheetPathIsRoot": "spritesheetPath deve apontar para um arquivo, não para a raiz do pacote.",
"sheetPathEscapes": "spritesheetPath aponta para fora do pacote.",
"sheetSymlink": "A folha de sprites não pode ser um link simbólico.",
"sheetUnsupportedType": "A folha de sprites deve ser um arquivo PNG, APNG, JPG, GIF ou WebP.",
"sheetNotFound": "Arquivo da folha de sprites não encontrado.",
"sheetNotAFile": "O caminho da folha de sprites não é um arquivo.",
"sheetTooLarge": "A folha de sprites é muito grande ({{size}} MB).",
"sheetSizeLimitExceeded": "A folha de sprites excedeu o limite de tamanho.",
"sheetUndecodable": "Não foi possível decodificar a imagem da folha de sprites.",
"sheetFrameMismatch": "A folha de sprites {{sheetWidth}}×{{sheetHeight}} não é um múltiplo exato do quadro {{frameWidth}}×{{frameHeight}}.",
"animationRowOutOfRange": "A animação \"{{name}}\" faz referência à linha {{row}}, mas a folha tem apenas {{rows}}.",
"animationTooManyFrames": "O número de quadros da animação \"{{name}}\" ({{frames}}) excede o número de colunas da folha ({{columns}}).",
"animationDurationMismatch": "Na animação \"{{name}}\", o número de durações de quadro declaradas ({{durations}}) não corresponde ao número de quadros ({{frames}}).",
"defaultAnimationMissing": "defaultAnimation \"{{name}}\" não está em animations."
},
"defaultLabels": {
"image": "Bichinho personalizado",
"bundle": "Pacote do bichinho"
}
}
}
+40
View File
@@ -868,6 +868,12 @@
"discardFailed": "Не удалось удалить файл для проверки",
"deleteFailed": "Не удалось удалить отправленную диагностику"
}
},
"repository": {
"iconPickErrors": {
"notPng": "Значки репозитория должны быть файлами PNG.",
"tooLarge": "Размер изображения значка репозитория не должен превышать 256 КБ."
}
}
},
"menu": {
@@ -23007,5 +23013,39 @@
"repairing": "Orca исправляет права доступа.",
"repairFailed": "Orca не удалось их исправить. Обычно это означает, что для изменения прав доступа к папке нужны права администратора.",
"runCommands": "Выполните эти команды в командной строке от имени администратора, затем снова запустите Orca:"
},
"pet": {
"importErrors": {
"unsupportedFile": "Неподдерживаемый файл. Выберите PNG, APNG, JPG, GIF, WebP или SVG.",
"fileUnreadable": "Не удалось прочитать выбранный файл.",
"notAFile": "Выбранный путь не указывает на файл",
"fileTooLarge": "Файл слишком большой ({{size}} МБ). Максимум — {{max}} МБ.",
"saveFailed": "Не удалось сохранить питомца.",
"pathUnreadable": "Не удалось прочитать выбранный путь.",
"manifestMissing": "В пакете отсутствует pet.json.",
"manifestInvalid": "Файл pet.json некорректен.",
"manifestSymlink": "pet.json не должен быть символической ссылкой.",
"manifestParseFailed": "Некорректный pet.json: {{detail}}",
"bundleSaveFailed": "Не удалось сохранить пакет питомца.",
"sheetPathNotRelative": "spritesheetPath должен быть путём относительно пакета.",
"sheetPathIsRoot": "spritesheetPath должен указывать на файл, а не на корневую папку пакета.",
"sheetPathEscapes": "spritesheetPath выходит за пределы пакета.",
"sheetSymlink": "Спрайт-лист не должен быть символической ссылкой.",
"sheetUnsupportedType": "Спрайт-лист должен быть файлом PNG, APNG, JPG, GIF или WebP.",
"sheetNotFound": "Файл спрайт-листа не найден.",
"sheetNotAFile": "Путь к спрайт-листу не указывает на файл.",
"sheetTooLarge": "Спрайт-лист слишком большой ({{size}} МБ).",
"sheetSizeLimitExceeded": "Размер спрайт-листа превышает допустимый предел.",
"sheetUndecodable": "Не удалось декодировать изображение спрайт-листа.",
"sheetFrameMismatch": "Размер спрайт-листа {{sheetWidth}}×{{sheetHeight}} не кратен размеру кадра {{frameWidth}}×{{frameHeight}}.",
"animationRowOutOfRange": "Анимация «{{name}}» ссылается на строку {{row}}, но число строк в спрайт-листе — {{rows}}.",
"animationTooManyFrames": "В анимации «{{name}}» кадров: {{frames}}, но в спрайт-листе столбцов всего {{columns}}.",
"animationDurationMismatch": "В анимации «{{name}}» число длительностей кадров — {{durations}}, а число кадров — {{frames}}.",
"defaultAnimationMissing": "defaultAnimation «{{name}}» отсутствует в animations."
},
"defaultLabels": {
"image": "Пользовательский питомец",
"bundle": "Пакет питомца"
}
}
}
+40
View File
@@ -852,6 +852,12 @@
"discardFailed": "İnceleme dosyası atılamadı",
"deleteFailed": "Gönderilen tanılama bilgileri silinemedi"
}
},
"repository": {
"iconPickErrors": {
"notPng": "Repo simgeleri PNG dosyaları olmalıdır.",
"tooLarge": "Repo simgesi görseli 256 KB veya daha küçük olmalıdır."
}
}
},
"menu": {
@@ -22307,5 +22313,39 @@
"repairing": "Orca şu anda izinleri onarıyor.",
"repairFailed": "İzinler Orca tarafından onarılamadı. Bu genellikle klasör için yönetici yetkisi gerektiği anlamına gelir.",
"runCommands": "Yönetici olarak açılan Komut İstemi penceresinde bu komutları çalıştırın, ardından Orca'yı yeniden başlatın:"
},
"pet": {
"importErrors": {
"unsupportedFile": "Desteklenmeyen dosya. PNG, APNG, JPG, GIF, WebP veya SVG seçin.",
"fileUnreadable": "Seçilen dosya okunamadı.",
"notAFile": "Seçilen yol bir dosya değil",
"fileTooLarge": "Dosya çok büyük ({{size}} MB). En fazla {{max}} MB olabilir.",
"saveFailed": "Evcil hayvan kaydedilemedi.",
"pathUnreadable": "Seçilen yol okunamadı.",
"manifestMissing": "Pakette pet.json eksik.",
"manifestInvalid": "pet.json geçersiz.",
"manifestSymlink": "pet.json sembolik bağlantı olmamalıdır.",
"manifestParseFailed": "Geçersiz pet.json: {{detail}}",
"bundleSaveFailed": "Evcil hayvan paketi kaydedilemedi.",
"sheetPathNotRelative": "spritesheetPath, pakete göre göreli bir yol olmalıdır.",
"sheetPathIsRoot": "spritesheetPath, paket kökü yerine bir dosyayı göstermelidir.",
"sheetPathEscapes": "spritesheetPath paket dışına çıkıyor.",
"sheetSymlink": "Sprite sayfası sembolik bağlantı olmamalıdır.",
"sheetUnsupportedType": "Sprite sayfası PNG, APNG, JPG, GIF veya WebP olmalıdır.",
"sheetNotFound": "Sprite sayfası dosyası bulunamadı.",
"sheetNotAFile": "Sprite sayfasının yolu bir dosya değil.",
"sheetTooLarge": "Sprite sayfası çok büyük ({{size}} MB).",
"sheetSizeLimitExceeded": "Sprite sayfası boyut sınırını aştı.",
"sheetUndecodable": "Sprite sayfası görüntüsünün kodu çözülemedi.",
"sheetFrameMismatch": "Sprite sayfası boyutu {{sheetWidth}}×{{sheetHeight}}, kare boyutu {{frameWidth}}×{{frameHeight}} değerinin tam katı değil.",
"animationRowOutOfRange": "\"{{name}}\" animasyonu {{row}}. satıra başvuruyor, ancak sayfada {{rows}} satır var.",
"animationTooManyFrames": "\"{{name}}\" animasyonunda {{frames}} kare var, ancak sayfada yalnızca {{columns}} sütun var.",
"animationDurationMismatch": "\"{{name}}\" animasyonu {{durations}} kare süresi tanımlıyor, ancak {{frames}} kare içeriyor.",
"defaultAnimationMissing": "defaultAnimation \"{{name}}\", animations içinde yok."
},
"defaultLabels": {
"image": "Özel evcil hayvan",
"bundle": "Evcil hayvan paketi"
}
}
}
@@ -848,6 +848,12 @@
"discardFailed": "無法丟棄檢視檔案",
"deleteFailed": "無法刪除已傳送的診斷資訊"
}
},
"repository": {
"iconPickErrors": {
"notPng": "存放庫圖示必須是 PNG 檔案。",
"tooLarge": "存放庫圖示圖片不得超過 256 KB。"
}
}
},
"menu": {
@@ -22026,5 +22032,39 @@
"repairing": "Orca 正在修復權限。",
"repairFailed": "Orca 無法修復權限,這通常表示該資料夾需要系統管理員權限。",
"runCommands": "在以系統管理員身分開啟的命令提示字元中執行以下指令,然後重新啟動 Orca:"
},
"pet": {
"importErrors": {
"unsupportedFile": "不支援此檔案。請選擇 PNG、APNG、JPG、GIF、WebP 或 SVG。",
"fileUnreadable": "無法讀取所選檔案。",
"notAFile": "所選路徑不是檔案",
"fileTooLarge": "檔案過大({{size}} MB)。上限為 {{max}} MB。",
"saveFailed": "無法儲存寵物。",
"pathUnreadable": "無法讀取所選路徑。",
"manifestMissing": "套件缺少 pet.json。",
"manifestInvalid": "pet.json 無效。",
"manifestSymlink": "pet.json 不得為符號連結。",
"manifestParseFailed": "無效的 pet.json:{{detail}}",
"bundleSaveFailed": "無法儲存寵物套件。",
"sheetPathNotRelative": "spritesheetPath 必須是相對於套件的路徑。",
"sheetPathIsRoot": "spritesheetPath 必須指向檔案,而非套件的根目錄。",
"sheetPathEscapes": "spritesheetPath 指向套件以外的路徑。",
"sheetSymlink": "精靈圖集不得為符號連結。",
"sheetUnsupportedType": "精靈圖集必須是 PNG、APNG、JPG、GIF 或 WebP 檔案。",
"sheetNotFound": "找不到精靈圖集檔案。",
"sheetNotAFile": "精靈圖集路徑不是檔案。",
"sheetTooLarge": "精靈圖集過大({{size}} MB)。",
"sheetSizeLimitExceeded": "精靈圖集超過大小限制。",
"sheetUndecodable": "無法解碼精靈圖集影像。",
"sheetFrameMismatch": "精靈圖集尺寸 {{sheetWidth}}×{{sheetHeight}} 不是影格尺寸 {{frameWidth}}×{{frameHeight}} 的整數倍。",
"animationRowOutOfRange": "動畫「{{name}}」參照第 {{row}} 列,但精靈圖集只有 {{rows}} 列。",
"animationTooManyFrames": "動畫「{{name}}」有 {{frames}} 個影格,但精靈圖集只有 {{columns}} 欄。",
"animationDurationMismatch": "動畫「{{name}}」宣告了 {{durations}} 個影格持續時間,但有 {{frames}} 個影格。",
"defaultAnimationMissing": "defaultAnimation 「{{name}}」不在 animations 中。"
},
"defaultLabels": {
"image": "自訂寵物",
"bundle": "寵物套件"
}
}
}
+40
View File
@@ -474,6 +474,12 @@
"discardFailed": "无法丢弃查看文件",
"deleteFailed": "无法删除已发送的诊断信息"
}
},
"repository": {
"iconPickErrors": {
"notPng": "仓库图标必须是 PNG 文件。",
"tooLarge": "仓库图标图片不得超过 256 KB。"
}
}
},
"menu": {
@@ -22323,5 +22329,39 @@
"repairing": "Orca 正在修复权限。",
"repairFailed": "Orca 无法修复权限,这通常意味着该文件夹需要管理员权限。",
"runCommands": "在以管理员身份运行的命令提示符中执行以下命令,然后重新启动 Orca:"
},
"pet": {
"importErrors": {
"unsupportedFile": "不支持此文件。请选择 PNG、APNG、JPG、GIF、WebP 或 SVG。",
"fileUnreadable": "无法读取所选文件。",
"notAFile": "所选路径不是文件",
"fileTooLarge": "文件过大({{size}} MB)。最大允许 {{max}} MB。",
"saveFailed": "无法保存宠物。",
"pathUnreadable": "无法读取所选路径。",
"manifestMissing": "包中缺少 pet.json。",
"manifestInvalid": "pet.json 无效。",
"manifestSymlink": "pet.json 不能是符号链接。",
"manifestParseFailed": "无效的 pet.json:{{detail}}",
"bundleSaveFailed": "无法保存宠物包。",
"sheetPathNotRelative": "spritesheetPath 必须是相对于包的路径。",
"sheetPathIsRoot": "spritesheetPath 必须指向文件,而不是包的根目录。",
"sheetPathEscapes": "spritesheetPath 指向包之外的路径。",
"sheetSymlink": "精灵图不能是符号链接。",
"sheetUnsupportedType": "精灵图必须是 PNG、APNG、JPG、GIF 或 WebP 文件。",
"sheetNotFound": "找不到精灵图文件。",
"sheetNotAFile": "精灵图路径不是文件。",
"sheetTooLarge": "精灵图过大({{size}} MB)。",
"sheetSizeLimitExceeded": "精灵图超出了大小限制。",
"sheetUndecodable": "无法解码精灵图图像。",
"sheetFrameMismatch": "精灵图尺寸 {{sheetWidth}}×{{sheetHeight}} 不是帧尺寸 {{frameWidth}}×{{frameHeight}} 的整数倍。",
"animationRowOutOfRange": "动画“{{name}}”引用了第 {{row}} 行,但精灵图只有 {{rows}} 行。",
"animationTooManyFrames": "动画“{{name}}”有 {{frames}} 帧,但精灵图只有 {{columns}} 列。",
"animationDurationMismatch": "动画“{{name}}”声明了 {{durations}} 个帧时长,但有 {{frames}} 帧。",
"defaultAnimationMissing": "defaultAnimation “{{name}}” 不在 animations 中。"
},
"defaultLabels": {
"image": "自定义宠物",
"bundle": "宠物包"
}
}
}
+81
View File
@@ -0,0 +1,81 @@
// One English table for pet import refusals: main builds the wire `error`, the renderer translates by code.
export const PET_IMPORT_FAILURE_COPY = {
unsupportedFile: 'Unsupported file. Pick a PNG, APNG, JPG, GIF, WebP, or SVG.',
fileUnreadable: 'Could not read the selected file.',
notAFile: 'Selected path is not a file',
fileTooLarge: 'File is too large ({{size}} MB). Max is {{max}} MB.',
saveFailed: 'Could not save the pet.',
pathUnreadable: 'Could not read the selected path.',
manifestMissing: 'Bundle is missing pet.json.',
manifestInvalid: 'pet.json is invalid.',
manifestSymlink: 'pet.json must not be a symlink.',
manifestParseFailed: 'Invalid pet.json: {{detail}}',
bundleSaveFailed: 'Could not save the pet bundle.',
sheetPathNotRelative: 'spritesheetPath must be relative to the bundle.',
sheetPathIsRoot: 'spritesheetPath must point to a file, not the bundle root.',
sheetPathEscapes: 'spritesheetPath escapes the bundle.',
sheetSymlink: 'spritesheet must not be a symlink.',
sheetUnsupportedType: 'Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.',
sheetNotFound: 'Spritesheet file not found.',
sheetNotAFile: 'Spritesheet path is not a file.',
sheetTooLarge: 'Spritesheet is too large ({{size}} MB).',
sheetSizeLimitExceeded: 'Spritesheet exceeded the size limit.',
sheetUndecodable: 'Could not decode the spritesheet image.',
sheetFrameMismatch:
'Spritesheet {{sheetWidth}}×{{sheetHeight}} is not a clean multiple of frame {{frameWidth}}×{{frameHeight}}.',
animationRowOutOfRange: 'Animation "{{name}}" references row {{row}} but sheet has {{rows}}.',
animationTooManyFrames:
'Animation "{{name}}" has {{frames}} frames but sheet only has {{columns}} columns.',
animationDurationMismatch:
'Animation "{{name}}" declares {{durations}} frame durations but {{frames}} frames.',
defaultAnimationMissing: 'defaultAnimation "{{name}}" not in animations.'
} as const
export type PetImportFailureCode = keyof typeof PET_IMPORT_FAILURE_COPY
export type PetImportFailureParams = Record<string, string | number>
/** Wire shape: `error` stays English; `errorDetail` is verbatim diagnostic text. */
export type PetImportFailure = {
ok: false
error: string
errorCode?: string
errorParams?: PetImportFailureParams
errorDetail?: string
}
/** Stored as the label when a pet has no usable name; the renderer shows it translated. */
export const CUSTOM_PET_DEFAULT_LABEL = 'Custom pet'
export const PET_BUNDLE_DEFAULT_LABEL = 'Pet bundle'
/** Thrown inside main's import steps; the IPC handler returns it as a PetImportFailure. */
export class PetImportError extends Error {
readonly failure: PetImportFailure
constructor(code: PetImportFailureCode, params: PetImportFailureParams = {}, detail?: string) {
const values = detail === undefined ? params : { ...params, detail }
// One pass, so a value containing "{{detail}}" stays verbatim.
const error = PET_IMPORT_FAILURE_COPY[code].replace(/\{\{(\w+)\}\}/g, (slot, name: string) =>
Object.hasOwn(values, name) ? String(values[name]) : slot
)
super(error)
this.failure = {
ok: false,
error,
errorCode: code,
...(Object.keys(params).length > 0 ? { errorParams: params } : {}),
...(detail !== undefined ? { errorDetail: detail } : {})
}
}
}
export function isPetImportFailure(value: unknown): value is PetImportFailure {
return (
typeof value === 'object' &&
value !== null &&
'ok' in value &&
value.ok === false &&
'error' in value &&
typeof value.error === 'string'
)
}
+16
View File
@@ -0,0 +1,16 @@
// One English table for repo icon upload refusals: main builds the wire `error`, the renderer translates by code.
export const REPO_ICON_PICK_FAILURE_COPY = {
notPng: 'Repo icons must be PNG files.',
tooLarge: 'Repo icon image must be 256KB or smaller.'
} as const
export type RepoIconPickFailureCode = keyof typeof REPO_ICON_PICK_FAILURE_COPY
/** Wire shape: `error` stays English; the renderer translates known codes. */
export type RepoIconPickFailure = { ok: false; error: string; errorCode?: string }
export type RepoIconPickResult = { dataUrl: string; fileName: string } | RepoIconPickFailure | null
export function repoIconPickFailure(errorCode: RepoIconPickFailureCode): RepoIconPickFailure {
return { ok: false, error: REPO_ICON_PICK_FAILURE_COPY[errorCode], errorCode }
}