Merge remote-tracking branch 'origin/main' into SaaS-update

# Conflicts:
#	frontend/editor/src/proprietary/components/chat/ChatContext.tsx
#	frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx
This commit is contained in:
James Brunton
2026-06-12 09:58:40 +01:00
59 changed files with 10405 additions and 227 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en-GB">
<html lang="en-US">
<head>
<meta charset="UTF-8" />
<base href="%BASE_URL%" />
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ export function useChat() {
progress: null,
progressLog: [] as never[],
sendMessage: async (_content: string) => {},
cancelMessage: () => {},
clearChat: () => {},
};
}
@@ -243,7 +243,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
const currentLanguage =
supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages["en-GB"] ||
supportedLanguages["en-US"] ||
"English"; // Fallback if supportedLanguages lookup fails
// Hide the language selector if there's only one language option
+10 -9
View File
@@ -5,7 +5,8 @@ import TomlBackend from "@app/i18n/tomlBackend";
// Define supported languages (based on your existing translations)
export const supportedLanguages = {
"en-GB": "English",
"en-US": "English (US)",
"en-GB": "English (UK)",
"ar-AR": "العربية",
"az-AZ": "Azərbaycan Dili",
"bg-BG": "Български",
@@ -72,7 +73,7 @@ i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en-GB",
fallbackLng: "en-US",
supportedLngs: Object.keys(supportedLanguages),
load: "currentOnly",
nonExplicitSupportedLngs: false,
@@ -100,8 +101,8 @@ i18n
order: ["localStorage", "navigator", "htmlTag"],
caches: [], // Don't cache auto-detected language - only cache when user manually selects
convertDetectedLanguage: (lng: string) => {
// Map en and en-US to en-GB
if (lng === "en" || lng === "en-US") return "en-GB";
// Map bare en to en-US
if (lng === "en") return "en-US";
return lng;
},
},
@@ -154,7 +155,7 @@ export function normalizeLanguageCode(languageCode: string): string {
}
/**
* Convert language codes to underscore format (e.g., en-GB → en_GB)
* Convert language codes to underscore format (e.g., en-US → en_US)
* Used for backend API communication which expects underscore format
*/
export function toUnderscoreFormat(languageCode: string): string {
@@ -212,7 +213,7 @@ export function setUserLanguage(language: string): void {
* Updates the supported languages list dynamically based on config
* If configLanguages is null/empty, all languages remain available
* Otherwise, only the specified languages are enabled with the first valid
* option (preferring en-GB when present) used as the fallback language.
* option (preferring en-US when present) used as the fallback language.
*
* @param configLanguages - Optional array of language codes from server config (ui.languages)
* @param defaultLocale - Optional default language for new users (system.defaultLocale)
@@ -248,12 +249,12 @@ export function updateSupportedLanguages(
return;
}
// Determine fallback: prefer validDefault if in the list, then en-GB, then first valid language
// Determine fallback: prefer validDefault if in the list, then en-US, then first valid language
const fallback =
validDefault && validLanguages.includes(validDefault)
? validDefault
: validLanguages.includes("en-GB")
? "en-GB"
: validLanguages.includes("en-US")
? "en-US"
: validLanguages[0];
i18n.options.supportedLngs = validLanguages;
@@ -154,8 +154,8 @@ export async function mockAppApis(
email: "[email protected]",
roles: ["ROLE_USER"],
},
languages = ["en-GB"],
defaultLocale = "en-GB",
languages = ["en-US"],
defaultLocale = "en-US",
endpointsAvailability = {},
backendStatus = "UP",
} = opts;
@@ -6,9 +6,9 @@ import { parse } from "smol-toml";
const REPO_ROOT = path.join(__dirname, "../../../..");
const SRC_ROOT = path.join(__dirname, "../..");
const EN_GB_FILE = path.join(
const EN_US_FILE = path.join(
__dirname,
"../../../public/locales/en-GB/translation.toml",
"../../../public/locales/en-US/translation.toml",
);
const IGNORED_DIRS = new Set(["tests", "__mocks__"]);
@@ -174,14 +174,14 @@ const extractKeys = (file: string): FoundKey[] => {
describe("Missing translation coverage", () => {
test(
"fails if any en-GB translation key used in source is missing",
"fails if any en-US translation key used in source is missing",
{ timeout: 10000 },
() => {
expect(fs.existsSync(EN_GB_FILE)).toBe(true);
expect(fs.existsSync(EN_US_FILE)).toBe(true);
const localeContent = fs.readFileSync(EN_GB_FILE, "utf8");
const enGb = parse(localeContent);
const availableKeys = flattenKeys(enGb);
const localeContent = fs.readFileSync(EN_US_FILE, "utf8");
const enUs = parse(localeContent);
const availableKeys = flattenKeys(enUs);
const usedKeys = listSourceFiles()
.flatMap(extractKeys)
@@ -211,7 +211,7 @@ describe("Missing translation coverage", () => {
// Output errors in GitHub Annotations format so they appear tagged in the code in CI
for (const { key, fallback, file, line, column } of annotations) {
process.stderr.write(
`::error file=${file},line=${line},col=${column}::Missing en-GB translation for ${key} (${fallback})\n`,
`::error file=${file},line=${line},col=${column}::Missing en-US translation for ${key} (${fallback})\n`,
);
}
@@ -28,8 +28,8 @@ test.describe("OAuth/SSO login buttons", () => {
route.fulfill({
json: {
enableLogin: true,
languages: ["en-GB"],
defaultLocale: "en-GB",
languages: ["en-US"],
defaultLocale: "en-US",
oauth2: {
enabled: true,
providers: [
@@ -6,9 +6,9 @@ import { parse } from "smol-toml";
const REPO_ROOT = path.join(__dirname, "../../../..");
const SRC_ROOT = path.join(__dirname, "../..");
const EN_GB_FILE = path.join(
const EN_US_FILE = path.join(
__dirname,
"../../../public/locales/en-GB/translation.toml",
"../../../public/locales/en-US/translation.toml",
);
const IGNORED_DIRS = new Set(["tests", "__mocks__"]);
@@ -151,13 +151,13 @@ const isIgnored = (key: string): boolean => {
describe("Unused translation coverage", () => {
test(
"fails if any en-GB translation key has no source references",
"fails if any en-US translation key has no source references",
{ timeout: 30_000 },
() => {
expect(fs.existsSync(EN_GB_FILE)).toBe(true);
expect(fs.existsSync(EN_US_FILE)).toBe(true);
const enGb = parse(fs.readFileSync(EN_GB_FILE, "utf8"));
const availableKeys = Array.from(flattenKeys(enGb));
const enUs = parse(fs.readFileSync(EN_US_FILE, "utf8"));
const availableKeys = Array.from(flattenKeys(enUs));
expect(availableKeys.length).toBeGreaterThan(100); // sanity check
const sourceFiles = listSourceFiles();
@@ -184,20 +184,20 @@ describe("Unused translation coverage", () => {
});
const localeRelative = path
.relative(REPO_ROOT, EN_GB_FILE)
.relative(REPO_ROOT, EN_US_FILE)
.replace(/\\/g, "/");
// GitHub Annotations format so unused keys show up tagged on the
// translation file in CI.
for (const key of unused) {
process.stderr.write(
`::error file=${localeRelative}::Unused en-GB translation: ${key}\n`,
`::error file=${localeRelative}::Unused en-US translation: ${key}\n`,
);
}
expect(
unused,
`Found ${unused.length} unused en-GB translation key(s). ` +
`Found ${unused.length} unused en-US translation key(s). ` +
`Remove them from ${localeRelative}, or (if the usage is too ` +
`dynamic for the heuristic to spot) add to IGNORED_KEY_PATTERNS ` +
`in this test.`,
@@ -13,7 +13,16 @@ const languageDefinitions: LanguageDefinition[] = [
{
ocrCode: "eng",
displayName: "English",
browserCodes: ["en", "en-GB", "en-AU", "en-CA", "en-IE", "en-NZ", "en-ZA"],
browserCodes: [
"en",
"en-US",
"en-GB",
"en-AU",
"en-CA",
"en-IE",
"en-NZ",
"en-ZA",
],
},
// Spanish
@@ -908,12 +917,12 @@ languageDefinitions.forEach((lang) => {
* Maps a browser language code to an OCR language code
* Handles exact matches and similar language fallbacks
*
* @param browserLanguage - The browser language code (e.g., 'en-GB', 'fr-FR')
* @param browserLanguage - The browser language code (e.g., 'en-US', 'fr-FR')
* @returns OCR language code if found, null if no match
*
* @example
* mapBrowserLanguageToOcr('de-DE') // Returns 'deu'
* mapBrowserLanguageToOcr('en-GB') // Returns 'eng'
* mapBrowserLanguageToOcr('en-US') // Returns 'eng'
* mapBrowserLanguageToOcr('zh-CN') // Returns 'chi_sim'
*/
export function mapBrowserLanguageToOcr(
@@ -940,7 +949,7 @@ export function mapBrowserLanguageToOcr(
if (match) return match;
}
// Try base language code (e.g., 'en' from 'en-GB')
// Try base language code (e.g., 'en' from 'en-US')
const baseLanguage = normalizedInput.split("-")[0];
const baseMatch = browserToOcrMap.get(baseLanguage);
if (baseMatch) return baseMatch;
@@ -1002,7 +1011,7 @@ export function getBrowserLanguagesForOcr(ocrCode: string): string[] {
*
* @example
* getAutoOcrLanguage('de-DE') // Returns ['deu']
* getAutoOcrLanguage('en-GB') // Returns ['eng']
* getAutoOcrLanguage('en-US') // Returns ['eng']
* getAutoOcrLanguage('unknown') // Returns []
*/
export function getAutoOcrLanguage(currentLanguage: string): string[] {
@@ -361,6 +361,7 @@ interface ChatContextValue {
/** Ordered log of every progress event for the current in-flight request. */
progressLog: AiWorkflowProgress[];
sendMessage: (content: string) => Promise<void>;
cancelMessage: () => void;
/** Abort any in-flight request and reset the chat to an empty conversation. */
clearChat: () => void;
}
@@ -422,38 +423,42 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const files = await Promise.all(descriptors.map(downloadFile));
const operation: ToolOperation = {
toolId: "ai-workflow",
timestamp: Date.now(),
};
const isVersionMapping =
sourceStubs.length > 0 && files.length === sourceStubs.length;
const stubs = files.map((file, i) =>
isVersionMapping
? createChildStub(sourceStubs[i], operation, file)
: createNewStirlingFileStub(file),
);
const stirlingFiles = files.map((file, i) =>
createStirlingFile(file, stubs[i].id),
);
if (sourceStubs.length > 0) {
// Always consume the inputs so merge/split inputs are removed from the workbench.
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
// merge/split they're fresh roots.
const operation: ToolOperation = {
toolId: "ai-workflow",
timestamp: Date.now(),
};
const isVersionMapping = files.length === sourceStubs.length;
const stubs = files.map((file, i) =>
isVersionMapping
? createChildStub(sourceStubs[i], operation, file)
: createNewStirlingFileStub(file),
);
const stirlingFiles = files.map((file, i) =>
createStirlingFile(file, stubs[i].id),
);
await fileActions.consumeFiles(
sourceStubs.map((s) => s.id),
stirlingFiles,
stubs,
);
} else {
// No inputs were provided (unlikely for completed workflows, but handle it safely).
// No inputs: pass raw files so addFiles assigns consistent IDs. Pre-assigning stub IDs
// here would cause a fileId mismatch in filesRef, making getFiles() clone the file
// on every render and breaking useFileWithUrl's memoisation (continuous PDF reloads).
await fileActions.addFiles(files, { selectFiles: true });
}
},
[fileActions, downloadFile],
);
const cancelMessage = useCallback(() => {
abortRef.current?.abort();
}, []);
const clearChat = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
@@ -494,7 +499,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
formData.append(`conversationHistory[${i}].role`, message.role);
formData.append(`conversationHistory[${i}].content`, message.content);
});
const response = await fetch(
`${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`,
{
@@ -637,6 +641,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
progress: state.progress,
progressLog: state.progressLog,
sendMessage,
cancelMessage,
clearChat,
}}
>
@@ -625,7 +625,7 @@ export default function AdminGeneralSection() {
data={defaultLocaleOptions}
searchable
clearable
placeholder="en_GB"
placeholder="en_US"
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
disabled={!loginEnabled}
/>
@@ -26,10 +26,10 @@ vi.mock("react-i18next", () => ({
// Mock i18n module to avoid initialization
vi.mock("@app/i18n", () => ({
updateSupportedLanguages: vi.fn(),
supportedLanguages: { "en-GB": "English" },
supportedLanguages: { "en-US": "English (US)" },
rtlLanguages: [],
default: {
language: "en-GB",
language: "en-US",
changeLanguage: vi.fn(),
options: {},
},
@@ -366,32 +366,32 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const files = await Promise.all(descriptors.map(downloadFile));
const operation: ToolOperation = {
toolId: "ai-workflow",
timestamp: Date.now(),
};
const isVersionMapping =
sourceStubs.length > 0 && files.length === sourceStubs.length;
const stubs = files.map((file, i) =>
isVersionMapping
? createChildStub(sourceStubs[i], operation, file)
: createNewStirlingFileStub(file),
);
const stirlingFiles = files.map((file, i) =>
createStirlingFile(file, stubs[i].id),
);
if (sourceStubs.length > 0) {
// Always consume the inputs so merge/split inputs are removed from the workbench.
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
// merge/split they're fresh roots.
const operation: ToolOperation = {
toolId: "ai-workflow",
timestamp: Date.now(),
};
const isVersionMapping = files.length === sourceStubs.length;
const stubs = files.map((file, i) =>
isVersionMapping
? createChildStub(sourceStubs[i], operation, file)
: createNewStirlingFileStub(file),
);
const stirlingFiles = files.map((file, i) =>
createStirlingFile(file, stubs[i].id),
);
await fileActions.consumeFiles(
sourceStubs.map((s) => s.id),
stirlingFiles,
stubs,
);
} else {
// No inputs were provided (unlikely for completed workflows, but handle it safely).
// No inputs: pass raw files so addFiles assigns consistent IDs. Pre-assigning stub IDs
// here would cause a fileId mismatch in filesRef, making getFiles() clone the file
// on every render and breaking useFileWithUrl's memoisation (continuous PDF reloads).
await fileActions.addFiles(files, { selectFiles: true });
}
},