4 Commits
Author SHA1 Message Date
zemion 52314b9f90 chore(release): bump translator-tools to 0.1.3
Verify / verify (push) Canceled after 0s
2026-09-03 15:09:03 +02:00
zemion 62c723dc2f feat: filter translator rows by missing segments
Verify / verify (push) Canceled after 0s
2026-09-03 13:53:55 +02:00
zemion 3690c46436 fix translator parser and move controls to sticky sidebar
Verify / verify (push) Canceled after 0s
2026-09-03 09:34:52 +02:00
zemion f4fdbff6ba translator-tools: parse multi-const inputs and infer source locale
Verify / verify (push) Canceled after 0s
2026-09-02 23:55:42 +02:00
13 changed files with 624 additions and 355 deletions
+20
View File
@@ -6,6 +6,26 @@
- Added language pair selection, glossary storage and fuzzy in-context suggestions. - Added language pair selection, glossary storage and fuzzy in-context suggestions.
- Added local-only import/export pipeline with portal handoff integration. - Added local-only import/export pipeline with portal handoff integration.
## 0.1.3 - 2026-09-03
- Added a missing-segments filter in the translation review table.
## 0.1.2 - 2026-09-03
## 0.1.2 - 2026-09-03
- Fixed parsing of multiple JS-like const/let/var translations in one file so
both locales are discovered (e.g. `const de = ...; const en = ...;`).
- Added language handling sidebar with merged language-selection/workspace controls.
- Kept glossary and output panels in the sticky right sidebar to reduce layout
drift while reviewing long translation lists.
## 0.1.1 - 2026-09-02
- Improved parser robustness for JS-like translation files with multiple declarations.
- Added automatic source-language inference from const/let/var declaration names (e.g. `const de = ...`).
- Added defensive parser fallback to avoid intermediate empty states when loading translation files.
## 0.1.0 - 2026-09-02 ## 0.1.0 - 2026-09-02
- Initial local-first translator review implementation. - Initial local-first translator review implementation.
+1 -1
View File
@@ -33,7 +33,7 @@ npm run test:browser
## Release ## Release
`npm run release:artifact` creates a deterministic `release/translator-tools-0.1.0.zip` and checksum sidecar. `npm run release:artifact` creates a deterministic `release/translator-tools-0.1.2.zip` and checksum sidecar.
## Licence ## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # Corresponding source
The corresponding source for Text Tools 0.2.0 is available at: The corresponding source for Translator Tools 0.1.1 is available at:
https://git.add-ideas.de/lotobo/text-tools/src/tag/v0.2.0 https://git.add-ideas.de/lotobo/translator-tools/src/tag/v0.1.1
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "translator-tools", "name": "translator-tools",
"version": "0.1.0", "version": "0.1.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "translator-tools", "name": "translator-tools",
"version": "0.1.0", "version": "0.1.3",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.3.0", "@add-ideas/toolbox-contract": "0.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "translator-tools", "name": "translator-tools",
"version": "0.1.0", "version": "0.1.3",
"description": "Review and translate i18next-like JSON language files locally.", "description": "Review and translate i18next-like JSON language files locally.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
+20
View File
@@ -6,6 +6,26 @@
- Added language pair selection, glossary storage and fuzzy in-context suggestions. - Added language pair selection, glossary storage and fuzzy in-context suggestions.
- Added local-only import/export pipeline with portal handoff integration. - Added local-only import/export pipeline with portal handoff integration.
## 0.1.3 - 2026-09-03
- Added a missing-segments filter in the translation review table.
## 0.1.2 - 2026-09-03
## 0.1.2 - 2026-09-03
- Fixed parsing of multiple JS-like const/let/var translations in one file so
both locales are discovered (e.g. `const de = ...; const en = ...;`).
- Added language handling sidebar with merged language-selection/workspace controls.
- Kept glossary and output panels in the sticky right sidebar to reduce layout
drift while reviewing long translation lists.
## 0.1.1 - 2026-09-02
- Improved parser robustness for JS-like translation files with multiple declarations.
- Added automatic source-language inference from const/let/var declaration names (e.g. `const de = ...`).
- Added defensive parser fallback to avoid intermediate empty states when loading translation files.
## 0.1.0 - 2026-09-02 ## 0.1.0 - 2026-09-02
- Initial local-first translator review implementation. - Initial local-first translator review implementation.
+1 -1
View File
@@ -33,7 +33,7 @@ npm run test:browser
## Release ## Release
`npm run release:artifact` creates a deterministic `release/translator-tools-0.1.0.zip` and checksum sidecar. `npm run release:artifact` creates a deterministic `release/translator-tools-0.1.2.zip` and checksum sidecar.
## Licence ## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # Corresponding source
The corresponding source for Translator Tools 0.1.0 is available at: The corresponding source for Translator Tools 0.1.1 is available at:
https://git.add-ideas.de/lotobo/translator-tools/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/translator-tools/src/tag/v0.1.1
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+1 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.translator-tools", "id": "de.add-ideas.translator-tools",
"name": "Translator Tools", "name": "Translator Tools",
"version": "0.1.0", "version": "0.1.3",
"description": "Review and edit translation bundles in the browser with glossary suggestions.", "description": "Review and edit translation bundles in the browser with glossary suggestions.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
+530 -325
View File
@@ -120,16 +120,196 @@ function guessLocaleFromFileName(name: string): string {
return match?.[2]?.toLowerCase() ?? "en"; return match?.[2]?.toLowerCase() ?? "en";
} }
function parseTranslationInput(value: string): unknown { function parseJsLikeValue(
source: string,
start: number,
): { end: number; value: string } | undefined {
let cursor = start;
while (cursor < source.length && /\s/uy.test(source[cursor]!)) {
cursor += 1;
}
const first = source[cursor];
if (first !== "{" && first !== "[") return undefined;
const close = first === "{" ? "}" : "]";
let depth = 0;
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let inLineComment = false;
let inBlockComment = false;
let escaped = false;
for (let index = cursor; index < source.length; index += 1) {
const char = source[index]!;
if (inLineComment) {
if (char === "\n") inLineComment = false;
continue;
}
if (inBlockComment) {
if (char === "*" && source[index + 1] === "/") {
inBlockComment = false;
index += 1;
}
continue;
}
if (escaped) {
escaped = false;
continue;
}
if (inSingle) {
if (char === "\\") {
escaped = true;
} else if (char === "'") {
inSingle = false;
}
continue;
}
if (inDouble) {
if (char === "\\") {
escaped = true;
} else if (char === '"') {
inDouble = false;
}
continue;
}
if (inBacktick) {
if (char === "\\") {
escaped = true;
} else if (char === "`") {
inBacktick = false;
}
continue;
}
if (char === "'" && !inSingle && !inDouble && !inBacktick) {
inSingle = true;
continue;
}
if (char === '"' && !inSingle && !inDouble && !inBacktick) {
inDouble = true;
continue;
}
if (char === "`" && !inSingle && !inDouble && !inBacktick) {
inBacktick = true;
continue;
}
if (char === "/" && source[index + 1] === "/") {
inLineComment = true;
index += 1;
continue;
}
if (char === "/" && source[index + 1] === "*") {
inBlockComment = true;
index += 1;
continue;
}
if (char === first) {
depth += 1;
continue;
}
if (char === close) {
depth -= 1;
if (depth === 0) {
return { value: source.slice(cursor, index + 1), end: index + 1 };
}
}
}
return undefined;
}
function parseNamedAssignments(
source: string,
): Array<{ name: string; value: unknown }> {
const values: Array<{ name: string; value: unknown }> = [];
const declarationRegex =
/(?:^|[\s;])(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][\w$]*)\s*=\s*/gu;
for (let cursor = 0; cursor < source.length;) {
declarationRegex.lastIndex = cursor;
const match = declarationRegex.exec(source);
if (!match) break;
const name = match[1];
if (!name) {
cursor = declarationRegex.lastIndex;
continue;
}
const matchedEnd = declarationRegex.lastIndex;
const extracted = parseJsLikeValue(source, matchedEnd);
if (!extracted) {
cursor = matchedEnd;
continue;
}
try {
const parsed = JSON5.parse(extracted.value);
values.push({ name, value: parsed });
} catch {
// Ignore declarations that are not JSON-like values.
}
declarationRegex.lastIndex = extracted.end;
cursor = extracted.end;
}
return values;
}
type ParsedTranslationInput = {
value: unknown;
sourceLanguageHint?: string;
};
function parseTranslationInput(value: string): ParsedTranslationInput {
const trimmed = value.trim();
if (!trimmed) {
throw new Error("The translation input is empty.");
}
try { try {
return JSON.parse(value); return { value: JSON.parse(trimmed) };
} catch { } catch {
const trimmed = value.trim();
const asAssignment = const asAssignment =
/^(?:export\s+)?(?:const|let|var)\s+[a-zA-Z_$][\w$]*\s*=\s*([\s\S]*?)\s*;?\s*$/u.exec( /^(?:export\s+)?(?:const|let|var)\s+[a-zA-Z_$][\w$]*\s*=\s*([\s\S]*?)\s*;?\s*$/u.exec(
trimmed, trimmed,
)?.[1]; )?.[1];
return JSON5.parse(asAssignment ?? trimmed); const assignments = parseNamedAssignments(trimmed);
if (assignments.length > 0) {
const first = assignments[0];
if (!first) return { value: JSON5.parse(asAssignment ?? trimmed) };
if (assignments.length === 1) {
const name = normalizeLocaleCode(first.name);
return {
value: first.value,
sourceLanguageHint: isLikelyLocaleCode(name) ? name : undefined,
};
}
const localeCount = assignments.filter((entry) =>
isLikelyLocaleCode(normalizeLocaleCode(entry.name)),
).length;
if (localeCount > 1) {
const mapped = assignments.reduce<Record<string, unknown>>(
(acc, entry) => {
acc[normalizeLocaleCode(entry.name)] = entry.value;
return acc;
},
{},
);
return { value: mapped };
}
const mapped = assignments.reduce<Record<string, unknown>>(
(acc, entry) => {
acc[entry.name] = entry.value;
return acc;
},
{},
);
return { value: mapped };
}
return { value: JSON5.parse(asAssignment ?? trimmed) };
} }
} }
@@ -164,6 +344,7 @@ function levenshtein(a: string, b: string): number {
function extractWorkspace( function extractWorkspace(
value: unknown, value: unknown,
sourceFileName: string, sourceFileName: string,
sourceLanguageHint?: string,
): Workspace & { warning?: string } { ): Workspace & { warning?: string } {
if (!isRecord(value)) throw new Error("Translation input must be an object."); if (!isRecord(value)) throw new Error("Translation input must be an object.");
@@ -195,16 +376,24 @@ function extractWorkspace(
}; };
} }
const normalizedHint = sourceLanguageHint
? normalizeLocaleCode(sourceLanguageHint)
: "";
const guessed = normalizeLocaleCode(guessLocaleFromFileName(sourceFileName)); const guessed = normalizeLocaleCode(guessLocaleFromFileName(sourceFileName));
const inferredSourceLanguage = isLikelyLocaleCode(normalizedHint)
? normalizedHint
: isLikelyLocaleCode(guessed)
? guessed
: "en";
const flat: FlatLocale = {}; const flat: FlatLocale = {};
flattenLocale(value, "", flat, (path) => { flattenLocale(value, "", flat, (path) => {
warnings.push(`Skipped non-string value at ${path}`); warnings.push(`Skipped non-string value at ${path}`);
}); });
localeMap[guessed] = flat; localeMap[inferredSourceLanguage] = flat;
return { return {
mode: "single-locale", mode: "single-locale",
sourceLanguage: guessed, sourceLanguage: inferredSourceLanguage,
targetLanguage: guessed === "en" ? "de" : "en", targetLanguage: inferredSourceLanguage === "en" ? "de" : "en",
localeMap, localeMap,
warning: warnings.join("; ") || undefined, warning: warnings.join("; ") || undefined,
}; };
@@ -323,6 +512,7 @@ export function Workbench({
localeMap: { de: {} }, localeMap: { de: {} },
}); });
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const [showOnlyMissing, setShowOnlyMissing] = useState(false);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [glossary, setGlossary] = useState<GlossaryTerm[]>(() => { const [glossary, setGlossary] = useState<GlossaryTerm[]>(() => {
try { try {
@@ -396,7 +586,7 @@ export function Workbench({
const target = workspace.localeMap[effectiveTargetLanguage] ?? {}; const target = workspace.localeMap[effectiveTargetLanguage] ?? {};
const allKeys = new Set([...Object.keys(source), ...Object.keys(target)]); const allKeys = new Set([...Object.keys(source), ...Object.keys(target)]);
const query = filter.trim().toLocaleLowerCase("en"); const query = filter.trim().toLocaleLowerCase("en");
const values = Array.from(allKeys) return Array.from(allKeys)
.sort((left, right) => left.localeCompare(right)) .sort((left, right) => left.localeCompare(right))
.map((path) => ({ .map((path) => ({
key: path, key: path,
@@ -410,10 +600,11 @@ export function Workbench({
row.source.toLocaleLowerCase("en").includes(query) || row.source.toLocaleLowerCase("en").includes(query) ||
row.target.toLocaleLowerCase("en").includes(query), row.target.toLocaleLowerCase("en").includes(query),
) )
.filter((row) => (!showOnlyMissing ? true : !row.target.trim()))
.slice(0, MAX_PATH_ROWS); .slice(0, MAX_PATH_ROWS);
return values;
}, [ }, [
filter, filter,
showOnlyMissing,
workspace.localeMap, workspace.localeMap,
effectiveSourceLanguage, effectiveSourceLanguage,
effectiveTargetLanguage, effectiveTargetLanguage,
@@ -449,7 +640,11 @@ export function Workbench({
setRawInput(next); setRawInput(next);
try { try {
const parsed = parseTranslationInput(next); const parsed = parseTranslationInput(next);
const nextWorkspace = extractWorkspace(parsed, file.name); const nextWorkspace = extractWorkspace(
parsed.value,
file.name,
parsed.sourceLanguageHint,
);
setWorkspace({ setWorkspace({
mode: nextWorkspace.mode, mode: nextWorkspace.mode,
sourceLanguage: nextWorkspace.sourceLanguage, sourceLanguage: nextWorkspace.sourceLanguage,
@@ -481,7 +676,11 @@ export function Workbench({
} }
try { try {
const parsed = parseTranslationInput(rawInput); const parsed = parseTranslationInput(rawInput);
const nextWorkspace = extractWorkspace(parsed, sourceFileName); const nextWorkspace = extractWorkspace(
parsed.value,
sourceFileName,
parsed.sourceLanguageHint,
);
setWorkspace({ setWorkspace({
mode: nextWorkspace.mode, mode: nextWorkspace.mode,
sourceLanguage: nextWorkspace.sourceLanguage, sourceLanguage: nextWorkspace.sourceLanguage,
@@ -698,327 +897,333 @@ export function Workbench({
{importWarning && <p className="notice">{importWarning}</p>} {importWarning && <p className="notice">{importWarning}</p>}
{rawImportError && <p className="error">{rawImportError}</p>} {rawImportError && <p className="error">{rawImportError}</p>}
</section> </section>
<div className="translator-grid">
<div className="translator-main">
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Review strings</p>
<h2>Source and target side by side</h2>
</div>
<div className="actions">
<label className="field field-inline">
<span>Search in key/value</span>
<input
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="Search keys or text"
/>
</label>
<label className="field field-inline">
<input
type="checkbox"
checked={showOnlyMissing}
onChange={(event) => setShowOnlyMissing(event.target.checked)}
/>
<span>Only missing segments</span>
</label>
<span className="count">
{formatCount(glossaryRows.length)} rows ·{" "}
{formatCount(totalMissing)} missing
</span>
</div>
</div>
<section className="panel workspace pair-selectors"> {rows.length === 0 ? (
<div className="panel-heading"> <p className="notice">
<p className="eyebrow">Language pair</p> Open a file to review and edit translation keys.
<h2>Choose authoritative and target language</h2> </p>
) : (
<div className="table-wrap">
<table className="review-grid">
<thead>
<tr>
<th>Key</th>
<th>
{effectiveSourceLanguage.toUpperCase()} (authoritative)
</th>
<th>{effectiveTargetLanguage.toUpperCase()} (review)</th>
<th>Suggestions</th>
</tr>
</thead>
<tbody>
{glossaryRows.map((row) => {
const suggestions = row.suggestions;
return (
<tr key={row.key}>
<td className="key-cell">{formatPath(row.key)}</td>
<td>
<label
className="sr-only"
htmlFor={`source-${row.key}`}
>
Authoritative {formatPath(row.key)}
</label>
<textarea
id={`source-${row.key}`}
rows={2}
value={row.source}
onChange={(event) =>
setCell(
effectiveSourceLanguage,
row.key,
event.target.value,
)
}
/>
</td>
<td>
<label
className="sr-only"
htmlFor={`target-${row.key}`}
>
Target {formatPath(row.key)}
</label>
<textarea
id={`target-${row.key}`}
rows={2}
value={row.target}
onChange={(event) =>
setCell(
effectiveTargetLanguage,
row.key,
event.target.value,
)
}
/>
<div className="row-actions">
<button
type="button"
onClick={() => copySourceToTarget(row.key)}
>
Copy source
</button>
<button
type="button"
onClick={() => replaceTarget(row.key, "")}
>
Clear target
</button>
</div>
</td>
<td>
{suggestions.length === 0 ? (
<p className="muted">No suggestion.</p>
) : (
<div className="chip-list">
{suggestions.map((entry) => (
<button
key={`${row.key}-${entry.id}`}
type="button"
className="chip"
onClick={() =>
setCell(
effectiveTargetLanguage,
row.key,
entry.targetText,
)
}
title={`${entry.sourceText} -> ${entry.targetText}`}
>
{entry.targetText}
</button>
))}
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div> </div>
<div className="form-grid">
<label className="field"> <aside className="translator-sidebar">
<span>Authoritative language</span> <section className="panel workspace pair-selectors">
<select <div className="panel-heading">
value={effectiveSourceLanguage} <p className="eyebrow">Language selection</p>
onChange={(event) => <h2>Choose and manage languages</h2>
setWorkspace((current) => { </div>
const nextSource = event.target.value; <div className="form-grid">
const fallbackTarget = Object.keys(current.localeMap).find( <label className="field">
(lang) => lang !== nextSource, <span>Authoritative language</span>
); <select
return { value={effectiveSourceLanguage}
...current, onChange={(event) =>
sourceLanguage: nextSource, setWorkspace((current) => {
targetLanguage: const nextSource = event.target.value;
current.targetLanguage === nextSource const fallbackTarget = Object.keys(
? (fallbackTarget ?? current.targetLanguage) current.localeMap,
: current.targetLanguage, ).find((lang) => lang !== nextSource);
}; return {
}) ...current,
} sourceLanguage: nextSource,
> targetLanguage:
current.targetLanguage === nextSource
? (fallbackTarget ?? current.targetLanguage)
: current.targetLanguage,
};
})
}
>
{languageOptions.map((lang) => (
<option value={lang} key={`source-${lang}`}>
{lang}
</option>
))}
</select>
</label>
<label className="field">
<span>Review target language</span>
<select
value={effectiveTargetLanguage}
onChange={(event) =>
setWorkspace((current) => ({
...current,
targetLanguage: event.target.value,
}))
}
>
{languageOptions
.filter((lang) => lang !== effectiveSourceLanguage)
.map((lang) => (
<option value={lang} key={`target-${lang}`}>
{lang}
</option>
))}
</select>
</label>
<label className="field">
<span>Add language</span>
<div className="field-group">
<input
value={newLanguageCode}
onChange={(event) => setNewLanguageCode(event.target.value)}
placeholder="eg. fr"
/>
<button type="button" onClick={addLanguage}>
Add
</button>
</div>
</label>
</div>
<ul className="language-list">
{languageOptions.map((lang) => ( {languageOptions.map((lang) => (
<option value={lang} key={`source-${lang}`}> <li key={lang}>
{lang} <span>
</option>
))}
</select>
</label>
<label className="field">
<span>Review target language</span>
<select
value={effectiveTargetLanguage}
onChange={(event) =>
setWorkspace((current) => ({
...current,
targetLanguage: event.target.value,
}))
}
>
{languageOptions
.filter((lang) => lang !== effectiveSourceLanguage)
.map((lang) => (
<option value={lang} key={`target-${lang}`}>
{lang} {lang}
</option> {lang === effectiveSourceLanguage && " (authoritative)"}
{lang === effectiveTargetLanguage && " (target)"}
</span>
<button type="button" onClick={() => removeLanguage(lang)}>
Remove
</button>
</li>
))}
</ul>
</section>
<section className="panel workspace glossary">
<div className="panel-heading">
<p className="eyebrow">Glossary support</p>
<h2>Pair-aware terms + history</h2>
</div>
<form
className="glossary-form"
onSubmit={(event) => {
event.preventDefault();
addGlossaryTerm();
}}
>
<label className="field">
<span>Source language</span>
<input
value={newTermSourceLang}
onChange={(event) => setNewTermSourceLang(event.target.value)}
/>
</label>
<label className="field">
<span>Target language</span>
<input
value={newTermTargetLang}
onChange={(event) => setNewTermTargetLang(event.target.value)}
/>
</label>
<label className="field">
<span>Source term</span>
<input
value={newTermSource}
onChange={(event) => setNewTermSource(event.target.value)}
placeholder="e.g. dashboard"
/>
</label>
<label className="field">
<span>Target term</span>
<input
value={newTermTarget}
onChange={(event) => setNewTermTarget(event.target.value)}
placeholder="e.g. dashboard"
/>
</label>
<button type="submit" className="primary">
Add glossary term
</button>
</form>
{glossary.length === 0 ? (
<p className="notice">
No glossary terms yet. Add some language-pair terms.
</p>
) : (
<ul className="term-list">
{glossary.map((entry) => (
<li key={entry.id}>
<div>
<strong>
{entry.sourceLanguage}→{entry.targetLanguage}
</strong>
<p>
{entry.sourceText} = {entry.targetText}
</p>
</div>
<button
type="button"
onClick={() => removeGlossaryTerm(entry.id)}
>
Remove
</button>
</li>
))} ))}
</select> </ul>
</label> )}
<label className="field"> </section>
<span>Add language</span>
<div className="field-group"> <section className="panel workspace">
<input <div className="panel-heading">
value={newLanguageCode} <p className="eyebrow">Output</p>
onChange={(event) => setNewLanguageCode(event.target.value)} <h2>Download or share reviewed bundle</h2>
placeholder="eg. fr" </div>
/> <div className="actions">
<button type="button" onClick={addLanguage}> <button type="button" onClick={exportOutput}>
Add Download JSON
</button>
<button
type="button"
onClick={() => void sendOutput()}
className="primary"
>
Send to tool
</button> </button>
</div> </div>
</label> {status && <p className="notice">{status}</p>}
</div> {incoming?.status !== "ready" && incoming?.error && (
</section> <p className="error">{incoming.error}</p>
)}
<section className="panel workspace"> </section>
<div className="panel-heading"> </aside>
<div> </div>
<p className="eyebrow">Review strings</p>
<h2>Source and target side by side</h2>
</div>
<div className="actions">
<label className="field field-inline">
<span>Search in key/value</span>
<input
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="Search keys or text"
/>
</label>
<span className="count">
{formatCount(glossaryRows.length)} rows ·{" "}
{formatCount(totalMissing)} missing
</span>
</div>
</div>
{rows.length === 0 ? (
<p className="notice">
Open a file to review and edit translation keys.
</p>
) : (
<div className="table-wrap">
<table className="review-grid">
<thead>
<tr>
<th>Key</th>
<th>
{effectiveSourceLanguage.toUpperCase()} (authoritative)
</th>
<th>{effectiveTargetLanguage.toUpperCase()} (review)</th>
<th>Suggestions</th>
</tr>
</thead>
<tbody>
{glossaryRows.map((row) => {
const suggestions = row.suggestions;
return (
<tr key={row.key}>
<td className="key-cell">{formatPath(row.key)}</td>
<td>
<label
className="sr-only"
htmlFor={`source-${row.key}`}
>
Authoritative {formatPath(row.key)}
</label>
<textarea
id={`source-${row.key}`}
rows={2}
value={row.source}
onChange={(event) =>
setCell(
effectiveSourceLanguage,
row.key,
event.target.value,
)
}
/>
</td>
<td>
<label
className="sr-only"
htmlFor={`target-${row.key}`}
>
Target {formatPath(row.key)}
</label>
<textarea
id={`target-${row.key}`}
rows={2}
value={row.target}
onChange={(event) =>
setCell(
effectiveTargetLanguage,
row.key,
event.target.value,
)
}
/>
<div className="row-actions">
<button
type="button"
onClick={() => copySourceToTarget(row.key)}
>
Copy source
</button>
<button
type="button"
onClick={() => replaceTarget(row.key, "")}
>
Clear target
</button>
</div>
</td>
<td>
{suggestions.length === 0 ? (
<p className="muted">No suggestion.</p>
) : (
<div className="chip-list">
{suggestions.map((entry) => (
<button
key={`${row.key}-${entry.id}`}
type="button"
className="chip"
onClick={() =>
setCell(
effectiveTargetLanguage,
row.key,
entry.targetText,
)
}
title={`${entry.sourceText} -> ${entry.targetText}`}
>
{entry.targetText}
</button>
))}
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
<section className="panel workspace glossary">
<div className="panel-heading">
<p className="eyebrow">Glossary support</p>
<h2>Pair-aware terms + history</h2>
</div>
<form
className="glossary-form"
onSubmit={(event) => {
event.preventDefault();
addGlossaryTerm();
}}
>
<label className="field">
<span>Source language</span>
<input
value={newTermSourceLang}
onChange={(event) => setNewTermSourceLang(event.target.value)}
/>
</label>
<label className="field">
<span>Target language</span>
<input
value={newTermTargetLang}
onChange={(event) => setNewTermTargetLang(event.target.value)}
/>
</label>
<label className="field">
<span>Source term</span>
<input
value={newTermSource}
onChange={(event) => setNewTermSource(event.target.value)}
placeholder="e.g. dashboard"
/>
</label>
<label className="field">
<span>Target term</span>
<input
value={newTermTarget}
onChange={(event) => setNewTermTarget(event.target.value)}
placeholder="e.g. dashboard"
/>
</label>
<button type="submit" className="primary">
Add glossary term
</button>
</form>
{glossary.length === 0 ? (
<p className="notice">
No glossary terms yet. Add some language-pair terms.
</p>
) : (
<ul className="term-list">
{glossary.map((entry) => (
<li key={entry.id}>
<div>
<strong>
{entry.sourceLanguage}→{entry.targetLanguage}
</strong>
<p>
{entry.sourceText} = {entry.targetText}
</p>
</div>
<button
type="button"
onClick={() => removeGlossaryTerm(entry.id)}
>
Remove
</button>
</li>
))}
</ul>
)}
</section>
<section className="panel workspace">
<div className="panel-heading">
<p className="eyebrow">Languages in workspace</p>
<h2>Add/remove languages</h2>
</div>
<ul className="language-list">
{languageOptions.map((lang) => (
<li key={lang}>
<span>
{lang}
{lang === effectiveSourceLanguage && " (authoritative)"}
{lang === effectiveTargetLanguage && " (target)"}
</span>
<button type="button" onClick={() => removeLanguage(lang)}>
Remove
</button>
</li>
))}
</ul>
</section>
<section className="panel workspace">
<div className="panel-heading">
<p className="eyebrow">Output</p>
<h2>Download or share reviewed bundle</h2>
</div>
<div className="actions">
<button type="button" onClick={exportOutput}>
Download JSON
</button>
<button
type="button"
onClick={() => void sendOutput()}
className="primary"
>
Send to tool
</button>
</div>
{status && <p className="notice">{status}</p>}
{incoming?.status !== "ready" && incoming?.error && (
<p className="error">{incoming.error}</p>
)}
</section>
</main> </main>
); );
} }
+42 -18
View File
@@ -1,16 +1,5 @@
:root { :root {
--toolbox-background: #f4f7fc; color-scheme: inherit;
--toolbox-surface: #fff;
--toolbox-surface-soft: #eff1f7;
--toolbox-text: #1f2433;
--toolbox-muted: #62697c;
--toolbox-border: #d9deea;
--toolbox-accent: #4f46e5;
--toolbox-accent-hover: #4437d6;
--toolbox-accent-soft: #eceaff;
--toolbox-accent-contrast: #fff;
--toolbox-focus: #107d74;
--toolbox-danger: #b42342;
} }
* { * {
@@ -67,7 +56,7 @@ button:disabled {
} }
.secondary { .secondary {
background: #f7f7ff; background: var(--toolbox-surface-soft);
} }
button.primary { button.primary {
@@ -118,6 +107,27 @@ a {
gap: 1rem; gap: 1rem;
} }
.translator-grid {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr) minmax(16rem, 24rem);
align-items: start;
}
.translator-main,
.translator-sidebar {
display: grid;
gap: 1rem;
}
.translator-sidebar {
position: sticky;
top: 0.9rem;
align-self: start;
max-height: calc(100vh - 1.2rem);
overflow: auto;
}
.hero, .hero,
.panel { .panel {
border: 1px solid var(--toolbox-border); border: 1px solid var(--toolbox-border);
@@ -154,7 +164,7 @@ a {
font-size: 0.84rem; font-size: 0.84rem;
border: 1px solid var(--toolbox-border); border: 1px solid var(--toolbox-border);
background: var(--toolbox-accent-soft); background: var(--toolbox-accent-soft);
color: #2f2a6d; color: var(--toolbox-accent);
align-self: flex-start; align-self: flex-start;
} }
@@ -244,7 +254,11 @@ a {
} }
.review-grid thead { .review-grid thead {
background: #f9fbff; background: color-mix(
in srgb,
var(--toolbox-surface-soft) 86%,
var(--toolbox-surface)
);
} }
.review-grid textarea { .review-grid textarea {
@@ -265,7 +279,7 @@ a {
color: var(--toolbox-muted); color: var(--toolbox-muted);
padding: 0.4rem 0.5rem; padding: 0.4rem 0.5rem;
border-radius: 0.5rem; border-radius: 0.5rem;
background: #f8f9fc; background: color-mix(in srgb, var(--toolbox-surface-soft) 90%, transparent);
border: 1px solid var(--toolbox-border); border: 1px solid var(--toolbox-border);
} }
@@ -295,7 +309,7 @@ a {
.notice { .notice {
margin: 0.75rem 0 0; margin: 0.75rem 0 0;
color: #1f5a75; color: var(--toolbox-accent);
} }
.error { .error {
@@ -351,7 +365,7 @@ a {
.language-list li { .language-list li {
border: 1px solid var(--toolbox-border); border: 1px solid var(--toolbox-border);
border-radius: 0.6rem; border-radius: 0.6rem;
background: #fbfcff; background: color-mix(in srgb, var(--toolbox-surface-soft) 90%, transparent);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
@@ -376,6 +390,16 @@ a {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.translator-grid {
grid-template-columns: minmax(0, 1fr);
}
.translator-sidebar {
position: static;
max-height: none;
overflow: visible;
}
.panel-heading { .panel-heading {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
+1 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.translator-tools", "id": "de.add-ideas.translator-tools",
"name": "Translator Tools", "name": "Translator Tools",
"version": "0.1.0", "version": "0.1.3",
"description": "Review and edit translation bundles in the browser with glossary suggestions.", "description": "Review and edit translation bundles in the browser with glossary suggestions.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.1.3";