diff --git a/.changeset/de-quote-pairing-3876.md b/.changeset/de-quote-pairing-3876.md new file mode 100644 index 000000000..bd888ef02 --- /dev/null +++ b/.changeset/de-quote-pairing-3876.md @@ -0,0 +1,57 @@ +--- +"@object-ui/i18n": patch +--- + +German pack: the 20 values that closed the German opening quote with an ASCII straight quote now close it with `“` + +`packages/i18n/src/locales/de.ts` opened a quoted span with the German low-9 +quote `„` (U+201E) and closed it with the ASCII typewriter quote `"` (U+0022) in +20 keys, so a German user read `Registerkarte „Alle Datensätze" anzeigen` — a +mismatched pair. `search.resultsCount` showed it most plainly: the value ended +`„{{query}}""`, the mismatched closer immediately followed by the TS string +terminator. + +This was never a pack convention. Measured on `main@2937bcf7d` before the fix, +the same file already spelled 23 spans the correct German way, `„…“` — the two +styles sat side by side, in sibling keys of the same namespace. Every mismatched +closer is now `“` (U+201C), which is what German orthography (DUDEN R11) and the +majority of the file already used. Affected surfaces: the four empty states +(object / page / dashboard / report not found), the search results header, the +lookup "create named" action, four `console.objectView` strings, the home +getting-started hint, the six `navigationSync` toasts, the local marketplace +install toast, and the preview not-ready title. + +No other language pack and no `en` value changed — the counts are unchanged for +every other locale, and the mismatch was measured only in `de`. + +### Counted at landing, in two units + +The card reported "20 values" and the triage re-scan reported "22 mismatched +pairs"; both are correct about different units, and the difference is now +recorded rather than left to the next reader. 20 keys carried a mismatch; +22 mismatch occurrences lived in them, because `navigationSync.renamedPage` and +`navigationSync.renamedDashboard` each quote two names in one sentence +("Seite „alt“ in „neu“ umbenannt") and so contribute two each. Full-file counts +before → after: `„` 45 → 45, `“` 25 → 47, `”` 2 → 2, `"` 28 → 6. + +### The durable half, and why it is not the count equality the card proposed + +Three i18n gates run over these packs and none can see a wrong quote: +`all-locales-key-parity` compares key sets and placeholder shapes, +`check-i18n-call-site-keys.mjs` only asks whether a key resolves, and +`check-i18n-en-drift.mjs` fires on `en` **value changes** — these values were +wrong from the day they landed, so no drift event ever existed. Without a +value-domain assertion the next backfill copies the mismatch from a neighbour +again, which is exactly how these 20 accumulated. + +The card proposed asserting `count(„) === count(“)`. That is **false on the +correctly fixed file** (45 vs 47) and would have sent the next reader hunting a +bug that is not there: two values in this pack, `grid.import.savedMappingHint` +and `grid.import.savedMappingPreviewNote`, are still untranslated English prose +and quote in the English style `“…”`, so their two `“` are legitimate *openers*. +The invariant that is pinned instead is the pairing itself — for every `„`, the +first quote character that follows must be `“` — backed by the arithmetic +identity `count(“) === count(„) + count(”)`, which stays true if those two values +are later translated (47 === 47 + 0) and breaks the moment a mismatch returns. +Both carry a presence guard so a broken import cannot make them pass by scanning +nothing. diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts new file mode 100644 index 000000000..062e6f0d2 --- /dev/null +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -0,0 +1,265 @@ +/** + * objectui#3876 — the `de` pack paired the German **opening** quote + * `„` („, low-9) with an **ASCII straight quote** `"` (") as its + * closer, so German users read a typewriter quote where the typography of the + * rest of the pack (and of German orthography, DUDEN R11) puts `“` (“). + * + * ## Census taken at landing (main@2937bcf7d), not inherited + * + * The card said "20 values"; the triage re-scan on an earlier main said "22 + * mismatched pairs". **Both are right about different units** and this file + * exists partly to stop that ambiguity from recurring: + * + * - **20 keys** carried a mismatch, and + * - **22 mismatch occurrences** lived in them — `navigationSync.renamedPage` + * and `navigationSync.renamedDashboard` each quote *two* names in one + * sentence ("Seite „alt“ in „neu“ umbenannt"), so they contribute two each. + * + * Full-file counts, before → after: `„` 45 → 45, `“` 25 → 47, + * `”` 2 → 2, `"` 28 → 6. + * + * ## Why the invariant is NOT `count(„) === count(“)` + * + * The card proposed that assertion. It is **false on the correctly fixed file** + * (45 vs 47) and would have sent the next reader hunting a bug that isn't + * there. Two values in this pack are still untranslated English prose and quote + * in the *English* style — `“…”`: + * + * - `grid.import.savedMappingHint` + * - `grid.import.savedMappingPreviewNote` + * + * Their two `“` are legitimate **openers**, not German closers, which is + * the whole 45/47 gap. The durable form that survives both this fix and a later + * translation of those two values is + * + * count(“) === count(„) + count(”) + * + * — every German `„` is closed by a `“`, and every *extra* `“` is + * an English opener answered by a `”`. Germanising those two values keeps + * it true (47 === 47 + 0); re-introducing one mismatch breaks it (46 !== 47). + * The scan below is the primary check; the identity is the cheap arithmetic + * backstop that also notices a `„` closed by nothing at all. + * + * ## Why the three i18n gates cannot see any of this + * + * `all-locales-key-parity` compares key sets and placeholder shapes, + * `check-i18n-call-site-keys.mjs` only asks whether a key resolves, and + * `check-i18n-en-drift.mjs` fires on **`en` value changes** — these values were + * wrong from the day they landed, so no drift event ever existed. All three are + * value-blind by design, which is why the invariant has to live in a test. + */ +import { describe, expect, it } from 'vitest'; + +import { builtInLocales } from '../locales/index'; + +// The four quote characters this file is about, each named next to its literal +// so a reader can tell them apart at a glance — U+201E and U+201C in particular +// are one pixel apart in most editor fonts, which is how the bug survived. +const OPEN = '„'; // „ U+201E German opening quote (low-9) +const CLOSE = '“'; // “ U+201C German closing quote +const RDQ = '”'; // ” U+201D English closing quote +const STRAIGHT = '"'; // " U+0022 ASCII straight quote +const QUOTEISH = [OPEN, CLOSE, RDQ, STRAIGHT]; + +const at = (pack: unknown, path: string): unknown => + path.split('.').reduce((n, k) => (n as Record | undefined)?.[k], pack); + +/** Every string leaf of a pack, as `[dotted.path, value]`. */ +function flatten(pack: unknown, prefix = ''): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (const [k, v] of Object.entries(pack as Record)) { + const path = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'string') out.push([path, v]); + else if (v && typeof v === 'object') out.push(...flatten(v, path)); + } + return out; +} + +const count = (s: string, ch: string) => [...s].filter((c) => c === ch).length; + +/** + * For every `„` in `value`, the first quote-ish character that follows it — + * `null` when the opener is never closed. Deliberately a scanner and not a + * `„.*"` regex: a greedy/lazy dot can hop over an intervening correct + * closer and mis-attribute the pair. + */ +function closersOf(value: string): Array { + const out: Array = []; + for (let i = 0; i < value.length; i++) { + if (value[i] !== OPEN) continue; + let closer: string | null = null; + for (let j = i + 1; j < value.length; j++) { + if (QUOTEISH.includes(value[j])) { + closer = value[j]; + break; + } + } + out.push(closer); + } + return out; +} + +const DE = flatten(builtInLocales.de); + +/** + * The 20 keys the fix touched, with the exact quoted span each must now carry. + * Byte-exact on the span under test rather than on the whole sentence, so a + * legitimate German copy edit does not have to fight this file — while any + * change to the *quotes* still fails here by key name. Four whole values are + * additionally pinned byte-for-byte below. + */ +const FIXED: Array<[string, string[]]> = [ + ['lookup.createNamed', ['„{{name}}“']], + ['console.objectView.objectNotFoundDescription', ['„{{objectName}}“']], + ['console.objectView.deleteViewConfirm', ['„{{name}}“']], + ['console.objectView.viewTypeUnavailable', ['„{{field}}“']], + ['console.objectView.ufShowAllRecords', ['„Alle Datensätze“']], + ['home.gettingStarted.description', ['„Zuletzt geöffnet“']], + ['search.resultsCount', ['„{{query}}“']], + ['search.resultsCountPlural', ['„{{query}}“']], + ['empty.objectNotFoundDescription', ['„{{name}}“']], + ['empty.pageNotFoundDescription', ['„{{name}}“']], + ['empty.dashboardNotFoundDescription', ['„{{name}}“']], + ['empty.reportNotFoundDescription', ['„{{name}}“']], + ['navigationSync.addedPage', ['„{{name}}“']], + ['navigationSync.addedDashboard', ['„{{name}}“']], + ['navigationSync.removedPage', ['„{{name}}“']], + ['navigationSync.removedDashboard', ['„{{name}}“']], + ['navigationSync.renamedPage', ['„{{oldName}}“', '„{{newName}}“']], + ['navigationSync.renamedDashboard', ['„{{oldName}}“', '„{{newName}}“']], + ['marketplace.install.localSuccess', ['„{{name}}“']], + ['preview.empty.notReadyTitle', ['„{{app}}“']], +]; + +describe('objectui#3876 — de pack closes „ with “ and not with a straight quote', () => { + it('pins the 20 fixed keys: 22 spans, each byte-exact, none holding a straight quote', () => { + expect(FIXED).toHaveLength(20); + expect(FIXED.reduce((n, [, spans]) => n + spans.length, 0)).toBe(22); + + for (const [key, spans] of FIXED) { + const value = at(builtInLocales.de, key); + // presence: a renamed or removed key must fail loudly here rather than + // let `toContain` run against undefined and read as "nothing to check". + expect(typeof value, `de ${key} missing`).toBe('string'); + const v = value as string; + for (const span of spans) { + expect(v.includes(span), `de ${key} lost the paired span ${span}`).toBe(true); + } + expect(count(v, OPEN), `de ${key} opener count`).toBe(spans.length); + expect(count(v, CLOSE), `de ${key} closer count`).toBe(spans.length); + expect(v.includes(STRAIGHT), `de ${key} still holds a straight quote`).toBe(false); + } + }); + + it('pins the four values the issue showcased, whole and byte-for-byte', () => { + // The sentences a German user actually reads on the empty states, the tab + // toggle, the search header and the home hint. + expect(at(builtInLocales.de, 'empty.pageNotFoundDescription')).toBe( + 'Die Seite „{{name}}“ wurde nicht gefunden. Sie wurde möglicherweise entfernt oder umbenannt.', + ); + expect(at(builtInLocales.de, 'console.objectView.ufShowAllRecords')).toBe( + 'Registerkarte „Alle Datensätze“ anzeigen', + ); + expect(at(builtInLocales.de, 'search.resultsCount')).toBe('{{count}} Ergebnis für „{{query}}“'); + expect(at(builtInLocales.de, 'home.gettingStarted.description')).toBe( + 'Markieren Sie eine App, um sie hier anzuheften und mit einem Klick darauf zuzugreifen. ' + + 'Alles, was Sie öffnen, wird automatisch unter „Zuletzt geöffnet“ angezeigt.', + ); + }); + + it('holds pack-wide: no „ in de is closed by anything but “ (the durable invariant)', () => { + const offenders: string[] = []; + let openers = 0; + let paired = 0; + for (const [key, value] of DE) { + for (const closer of closersOf(value)) { + openers++; + if (closer === CLOSE) { + paired++; + continue; + } + const label = + closer === null ? 'never closed' : `closed by U+${closer.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`; + offenders.push(`${key}: „ ${label} — ${JSON.stringify(value)}`); + } + } + + // Anti-vacuous presence guard: a broken import, an emptied pack or a + // renamed file would otherwise scan nothing and pass. Measured at landing: + // 2914 string values, 45 openers, all 45 correctly paired. + expect(DE.length, 'de pack looks empty — the scan would be vacuous').toBeGreaterThan(2000); + expect(openers, 'no „ found at all — the scan would be vacuous').toBeGreaterThanOrEqual(40); + // Offenders first: this is the assertion that names the key and shows the + // sentence. The arithmetic below only counts, so it must not fire ahead of it. + expect(offenders, `de values closing „ with the wrong character:\n${offenders.join('\n')}`).toEqual([]); + expect(paired).toBe(openers); + }); + + it('holds under an independent \\p{L}-aware regex formulation (objectui#3866 lesson)', () => { + // #3866's lesson: a census regex written with `\w`/`[A-Za-z]` silently + // skips the umlauts and ß that German quoted spans are full of + // ("Alle Datensätze", "Zuletzt geöffnet"), so the method under-reports and + // the file reads clean. The class below is unicode-property based and the + // `u` flag is mandatory for `\p{…}` to mean anything at all. + const INNER = '[\\p{L}\\p{M}\\p{N}\\p{P}\\p{Zs}]*?'; + const mismatch = new RegExp(`${OPEN}${INNER}${STRAIGHT}`, 'gu'); + const ok = new RegExp(`${OPEN}${INNER}${CLOSE}`, 'gu'); + + // The formulation itself must be able to see a mismatch, or its zero below + // proves nothing. Umlaut inside the span on purpose. + expect('Registerkarte „Alle Datensätze" anzeigen'.match(mismatch)).toHaveLength(1); + expect('Registerkarte „Alle Datensätze“ anzeigen'.match(mismatch)).toBeNull(); + + const found: string[] = []; + let okSpans = 0; + for (const [key, value] of DE) { + const bad = value.match(mismatch); + if (bad) found.push(`${key}: ${bad.join(' | ')}`); + okSpans += (value.match(ok) ?? []).length; + } + expect(found, `„…" mismatches:\n${found.join('\n')}`).toEqual([]); + expect(okSpans, 'correctly paired spans').toBe(45); + }); + + it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { + const whole = DE.map(([, v]) => v).join(String.fromCharCode(10)); + const open = count(whole, OPEN); + const close = count(whole, CLOSE); + const rdq = count(whole, RDQ); + + // Measured at landing: 45 / 47 / 2. See the header for why the naive + // equality is false on a correctly fixed file. + expect({ open, close, rdq }).toEqual({ open: 45, close: 47, rdq: 2 }); + // The durable shape: every „ closed by a “, every surplus “ an English + // opener answered by a ”. Survives translating the two English values. + expect(close).toBe(open + rdq); + + // And the surplus is exactly those two, still English, still tracked + // separately — not German values with a stray closer. + const rdqKeys = DE.filter(([, v]) => v.includes(RDQ)).map(([k]) => k); + expect(rdqKeys).toEqual(['grid.import.savedMappingHint', 'grid.import.savedMappingPreviewNote']); + }); + + it('leaves `en` alone — the fix must not leak across packs', () => { + // en quotes with ASCII on both sides; the drift gate only fires on en + // changes, so this is the cheap local proof that none happened. + expect(at(builtInLocales.en, 'empty.pageNotFoundDescription')).toContain('"{{name}}"'); + expect(at(builtInLocales.en, 'console.objectView.ufShowAllRecords')).toContain('"All records"'); + expect(count(JSON.stringify(builtInLocales.en), OPEN)).toBe(0); + }); + + it('records the remaining straight quotes in de: the approvalsInbox trio, filed separately', () => { + // A different defect shape, deliberately NOT fixed here (this issue is the + // mismatched pair): these three quote with ASCII on BOTH sides while their + // own sibling `approvalsInbox.approveOneTitle` is correctly „…“. Pinned so + // the number cannot drift unnoticed while that finding waits its turn — and + // so a future fix has to come back and update this list. + const straight = DE.filter(([, v]) => v.includes(STRAIGHT)).map(([k]) => k); + expect(straight).toEqual([ + 'approvalsInbox.rejectOneTitle', + 'approvalsInbox.inlineApproved', + 'approvalsInbox.inlineRejected', + ]); + expect(at(builtInLocales.de, 'approvalsInbox.approveOneTitle')).toBe('„{{title}}“ genehmigen?'); + }); +}); diff --git a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx index ef82dabd7..d62f9d905 100644 --- a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx +++ b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -700,11 +700,15 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { for (const lang of ['ko', 'fr', 'es', 'pt', 'ru', 'ar'] as const) { expect(at(builtInLocales[lang], 'empty.interfacePageSourceMissing'), `${lang} quotes`).toContain('"{{name}}"'); } - // de is the deliberate divergence: its sibling values pair the German opening - // low quote with an ASCII straight quote (20 values do this, measured), which - // is a typo rather than a convention, so this slice writes the correctly - // paired „…“ and the mismatch is filed instead of copied. - expect(at(builtInLocales.de, 'empty.objectNotFoundDescription')).toContain('„{{name}}"'); + // de WAS the deliberate divergence: at slice-seven time its sibling values + // paired the German opening low quote with an ASCII straight quote (20 keys / + // 22 spans, measured), which was a typo rather than a convention, so this + // slice wrote the correctly paired „…“ and filed the mismatch instead of + // copying it. objectui#3876 then fixed the 20 siblings, and this assertion is + // the one the card named as "the place to look back at when that lands" — so + // it now pins the *convergence*: both values carry the same paired „…“. The + // durable pack-wide invariant lives in `de-quote-pairing-3876.test.ts`. + expect(at(builtInLocales.de, 'empty.objectNotFoundDescription')).toContain('„{{name}}“'); expect(at(builtInLocales.de, 'empty.interfacePageSourceMissing')).toContain('„{{name}}“'); }); diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 7c16de212..051847e32 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -40,7 +40,7 @@ const de = { recentlyUsed: 'Zuletzt verwendet', allResults: 'Alle Ergebnisse', createNew: 'Neu erstellen', - createNamed: '„{{name}}" erstellen', + createNamed: '„{{name}}“ erstellen', showingResults: '{{shown}} von {{total}} Ergebnissen', showAllResults: 'Alle Ergebnisse anzeigen ({{count}})', selectedBadge: 'Ausgewählt', @@ -1626,7 +1626,7 @@ const de = { cannotDeleteMetaView: "Diese Ansicht ist integriert und im Code definiert – sie kann nicht gelöscht werden.", expandToPage: "Als ganze Seite öffnen", objectNotFound: "Objekt nicht gefunden", - objectNotFoundDescription: "Das Objekt „{{objectName}}\" existiert in der aktuellen Konfiguration nicht.", + objectNotFoundDescription: "Das Objekt „{{objectName}}“ existiert in der aktuellen Konfiguration nicht.", objectNotFoundHint: "Überprüfen Sie Ihre App-Navigationseinstellungen oder wählen Sie ein anderes Objekt aus der Seitenleiste.", filterOrNotSavable: "Dieser Filter verknüpft Bedingungen mit ODER, was eine gespeicherte Ansicht noch nicht ablegen kann. Er gilt weiterhin für diese Liste – entfernen Sie die ODER-Gruppierung, um ihn in der Ansicht zu speichern.", filterNestedNotSavable: "Dieser Filter verwendet verschachtelte Bedingungsgruppen, die eine gespeicherte Ansicht nicht ablegen kann. Reduzieren Sie ihn auf eine einzelne Bedingungsliste, um ihn in der Ansicht zu speichern.", @@ -1731,7 +1731,7 @@ const de = { create: "Erstellen", delete: "Löschen", deleteViewTitle: "Ansicht löschen", - deleteViewConfirm: "Möchten Sie die Ansicht „{{name}}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.", + deleteViewConfirm: "Möchten Sie die Ansicht „{{name}}“ wirklich löschen? Dies kann nicht rückgängig gemacht werden.", bulkDeleteConfirm: "{{count}} ausgewählte Datensätze löschen? Dies kann nicht rückgängig gemacht werden.", duplicateViewName: "Eine Ansicht mit diesem Namen existiert bereits.", viewTypeGrid: "Raster", @@ -1760,7 +1760,7 @@ const de = { imageFieldHelp: "Das Galerie-Cover kommt aus diesem Bild- oder Anhangfeld.", selectOption: "Auswählen...", noEligibleFieldForType: "Kein geeignetes Feld dieses Typs. Fügen Sie zuerst eines zum Objekt hinzu.", - viewTypeUnavailable: "Dieses Objekt hat kein Feld, das als „{{field}}\" dienen kann.", + viewTypeUnavailable: "Dieses Objekt hat kein Feld, das als „{{field}}“ dienen kann.", viewTypeUnavailableShort: "Nicht von diesem Objekt unterstützt.", chartType: "Diagrammtyp", chartTypeHelp: "Bestimmt, wie die Daten gerendert werden.", @@ -1812,7 +1812,7 @@ const de = { ufNoFields: "Keine Felder verfügbar", ufAddTab: "+ Registerkarte hinzufügen", ufTabLabel: "Bezeichnung", - ufShowAllRecords: "Registerkarte „Alle Datensätze\" anzeigen", + ufShowAllRecords: "Registerkarte „Alle Datensätze“ anzeigen", ufAllowAddTab: "Registerkarten hinzufügen erlauben", showRecordCount: "Datensatzanzahl anzeigen", allowPrinting: "Drucken erlauben", @@ -2307,7 +2307,7 @@ const de = { }, gettingStarted: { title: "Startseite anpassen", - description: "Markieren Sie eine App, um sie hier anzuheften und mit einem Klick darauf zuzugreifen. Alles, was Sie öffnen, wird automatisch unter „Zuletzt geöffnet\" angezeigt.", + description: "Markieren Sie eine App, um sie hier anzuheften und mit einem Klick darauf zuzugreifen. Alles, was Sie öffnen, wird automatisch unter „Zuletzt geöffnet“ angezeigt.", cta: "Alle Anwendungen durchsuchen", }, }, @@ -2366,8 +2366,8 @@ const de = { back: "Zurück", placeholder: "Objekte, Dashboards, Seiten, Berichte suchen...", inputAriaLabel: "Objekte, Dashboards, Seiten, Berichte suchen", - resultsCount: "{{count}} Ergebnis für „{{query}}\"", - resultsCountPlural: "{{count}} Ergebnisse für „{{query}}\"", + resultsCount: "{{count}} Ergebnis für „{{query}}“", + resultsCountPlural: "{{count}} Ergebnisse für „{{query}}“", itemsAvailable: "{{count}} Elemente verfügbar", noResults: "Keine Ergebnisse gefunden", noResultsHint: "Versuchen Sie, Ihre Suchbegriffe anzupassen", @@ -2382,14 +2382,14 @@ const de = { }, empty: { objectNotFound: "Objekt nicht gefunden", - objectNotFoundDescription: "Definition des Objekts „{{name}}\" fehlt. Überprüfen Sie Ihre Konfiguration oder navigieren Sie zurück.", + objectNotFoundDescription: "Definition des Objekts „{{name}}“ fehlt. Überprüfen Sie Ihre Konfiguration oder navigieren Sie zurück.", interfacePageSourceMissing: "Diese Interface-Seite verweist auf „{{name}}“, das nicht verfügbar ist.", pageNotFound: "Seite nicht gefunden", - pageNotFoundDescription: "Die Seite „{{name}}\" wurde nicht gefunden. Sie wurde möglicherweise entfernt oder umbenannt.", + pageNotFoundDescription: "Die Seite „{{name}}“ wurde nicht gefunden. Sie wurde möglicherweise entfernt oder umbenannt.", dashboardNotFound: "Dashboard nicht gefunden", - dashboardNotFoundDescription: "Das Dashboard „{{name}}\" wurde nicht gefunden. Es wurde möglicherweise entfernt oder umbenannt.", + dashboardNotFoundDescription: "Das Dashboard „{{name}}“ wurde nicht gefunden. Es wurde möglicherweise entfernt oder umbenannt.", reportNotFound: "Bericht nicht gefunden", - reportNotFoundDescription: "Der Bericht „{{name}}\" wurde nicht gefunden. Er wurde möglicherweise entfernt oder umbenannt.", + reportNotFoundDescription: "Der Bericht „{{name}}“ wurde nicht gefunden. Er wurde möglicherweise entfernt oder umbenannt.", noAppsConfigured: "Keine Apps konfiguriert", noAppsConfiguredDescription: "Es sind keine Anwendungen registriert. Erstellen Sie Ihre erste App oder besuchen Sie die Systemeinstellungen.", appNotAvailable: "App nicht verfügbar", @@ -2439,12 +2439,12 @@ const de = { delete: "Löschen", }, navigationSync: { - addedPage: "Navigation aktualisiert: Seite „{{name}}\" hinzugefügt", - addedDashboard: "Navigation aktualisiert: Dashboard „{{name}}\" hinzugefügt", - removedPage: "Navigation aktualisiert: Seite „{{name}}\" entfernt", - removedDashboard: "Navigation aktualisiert: Dashboard „{{name}}\" entfernt", - renamedPage: "Navigation aktualisiert: Seite „{{oldName}}\" in „{{newName}}\" umbenannt", - renamedDashboard: "Navigation aktualisiert: Dashboard „{{oldName}}\" in „{{newName}}\" umbenannt", + addedPage: "Navigation aktualisiert: Seite „{{name}}“ hinzugefügt", + addedDashboard: "Navigation aktualisiert: Dashboard „{{name}}“ hinzugefügt", + removedPage: "Navigation aktualisiert: Seite „{{name}}“ entfernt", + removedDashboard: "Navigation aktualisiert: Dashboard „{{name}}“ entfernt", + renamedPage: "Navigation aktualisiert: Seite „{{oldName}}“ in „{{newName}}“ umbenannt", + renamedDashboard: "Navigation aktualisiert: Dashboard „{{oldName}}“ in „{{newName}}“ umbenannt", undoLabel: "Rückgängig", undone: "Navigationsänderung rückgängig gemacht", undoFailed: "Navigationsänderung konnte nicht rückgängig gemacht werden", @@ -3053,7 +3053,7 @@ const de = { noPermission: "Sie haben keine Berechtigung, Apps in einer Umgebung zu installieren. Nur Organisationsinhaber und Administratoren können installieren.", signInFirst: "Sie müssen sich zuerst bei ObjectStack Cloud anmelden.", success: "Erfolgreich installiert. Öffnen Sie die Umgebung, um die neue App zu sehen.", - localSuccess: "v{{version}} in diesem Laufzeitsystem installiert. „{{name}}\" sollte jetzt im App-Wechsler erscheinen.", + localSuccess: "v{{version}} in diesem Laufzeitsystem installiert. „{{name}}“ sollte jetzt im App-Wechsler erscheinen.", localManifestConflict: "{{message}}\nHinweis: Eine lokale App besitzt bereits diese manifest_id. Entfernen Sie sie zuerst aus objectstack.config.ts.", localUnauthorized: "Melden Sie sich zuerst in diesem Laufzeitsystem an.", localMarketplaceUnavailable: "Dieses Laufzeitsystem hat keine OS_CLOUD_URL konfiguriert.", @@ -3306,7 +3306,7 @@ const de = { empty: { loadFailedTitle: 'Entwurfsvorschau konnte nicht geladen werden', loadFailedDescription: 'Das Entwurfs-Overlay konnte nicht gelesen werden. Erneut versuchen oder Verbindung prüfen.', - notReadyTitle: '„{{app}}" ist noch nicht im Entwurf', + notReadyTitle: '„{{app}}“ ist noch nicht im Entwurf', notReadyDescription: 'Der Build läuft möglicherweise noch oder ist fehlgeschlagen, bevor diese App bereitgestellt wurde. Prüfen Sie den Build-Status in der Konversation — dieser Bereich aktualisiert sich, sobald Entwürfe eintreffen.', nothingTitle: 'Noch nichts zur Vorschau', retry: 'Erneut versuchen',