Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3690c46436 | ||
|
|
f4fdbff6ba |
@@ -6,6 +6,20 @@
|
||||
- Added language pair selection, glossary storage and fuzzy in-context suggestions.
|
||||
- Added local-only import/export pipeline with portal handoff integration.
|
||||
|
||||
## 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
|
||||
|
||||
- Initial local-first translator review implementation.
|
||||
|
||||
@@ -33,7 +33,7 @@ npm run test:browser
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 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`.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "translator-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "translator-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "translator-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"description": "Review and translate i18next-like JSON language files locally.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
|
||||
@@ -6,6 +6,20 @@
|
||||
- Added language pair selection, glossary storage and fuzzy in-context suggestions.
|
||||
- Added local-only import/export pipeline with portal handoff integration.
|
||||
|
||||
## 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
|
||||
|
||||
- Initial local-first translator review implementation.
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ npm run test:browser
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# 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`.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.translator-tools",
|
||||
"name": "Translator Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"description": "Review and edit translation bundles in the browser with glossary suggestions.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
|
||||
+518
-323
@@ -120,16 +120,196 @@ function guessLocaleFromFileName(name: string): string {
|
||||
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 {
|
||||
return JSON.parse(value);
|
||||
return { value: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
const trimmed = value.trim();
|
||||
const asAssignment =
|
||||
/^(?:export\s+)?(?:const|let|var)\s+[a-zA-Z_$][\w$]*\s*=\s*([\s\S]*?)\s*;?\s*$/u.exec(
|
||||
trimmed,
|
||||
)?.[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(
|
||||
value: unknown,
|
||||
sourceFileName: string,
|
||||
sourceLanguageHint?: string,
|
||||
): Workspace & { warning?: string } {
|
||||
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 inferredSourceLanguage = isLikelyLocaleCode(normalizedHint)
|
||||
? normalizedHint
|
||||
: isLikelyLocaleCode(guessed)
|
||||
? guessed
|
||||
: "en";
|
||||
const flat: FlatLocale = {};
|
||||
flattenLocale(value, "", flat, (path) => {
|
||||
warnings.push(`Skipped non-string value at ${path}`);
|
||||
});
|
||||
localeMap[guessed] = flat;
|
||||
localeMap[inferredSourceLanguage] = flat;
|
||||
return {
|
||||
mode: "single-locale",
|
||||
sourceLanguage: guessed,
|
||||
targetLanguage: guessed === "en" ? "de" : "en",
|
||||
sourceLanguage: inferredSourceLanguage,
|
||||
targetLanguage: inferredSourceLanguage === "en" ? "de" : "en",
|
||||
localeMap,
|
||||
warning: warnings.join("; ") || undefined,
|
||||
};
|
||||
@@ -449,7 +638,11 @@ export function Workbench({
|
||||
setRawInput(next);
|
||||
try {
|
||||
const parsed = parseTranslationInput(next);
|
||||
const nextWorkspace = extractWorkspace(parsed, file.name);
|
||||
const nextWorkspace = extractWorkspace(
|
||||
parsed.value,
|
||||
file.name,
|
||||
parsed.sourceLanguageHint,
|
||||
);
|
||||
setWorkspace({
|
||||
mode: nextWorkspace.mode,
|
||||
sourceLanguage: nextWorkspace.sourceLanguage,
|
||||
@@ -481,7 +674,11 @@ export function Workbench({
|
||||
}
|
||||
try {
|
||||
const parsed = parseTranslationInput(rawInput);
|
||||
const nextWorkspace = extractWorkspace(parsed, sourceFileName);
|
||||
const nextWorkspace = extractWorkspace(
|
||||
parsed.value,
|
||||
sourceFileName,
|
||||
parsed.sourceLanguageHint,
|
||||
);
|
||||
setWorkspace({
|
||||
mode: nextWorkspace.mode,
|
||||
sourceLanguage: nextWorkspace.sourceLanguage,
|
||||
@@ -698,327 +895,325 @@ export function Workbench({
|
||||
{importWarning && <p className="notice">{importWarning}</p>}
|
||||
{rawImportError && <p className="error">{rawImportError}</p>}
|
||||
</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>
|
||||
<span className="count">
|
||||
{formatCount(glossaryRows.length)} rows ·{" "}
|
||||
{formatCount(totalMissing)} missing
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="panel workspace pair-selectors">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Language pair</p>
|
||||
<h2>Choose authoritative and target language</h2>
|
||||
{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>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Authoritative language</span>
|
||||
<select
|
||||
value={effectiveSourceLanguage}
|
||||
onChange={(event) =>
|
||||
setWorkspace((current) => {
|
||||
const nextSource = event.target.value;
|
||||
const fallbackTarget = Object.keys(current.localeMap).find(
|
||||
(lang) => lang !== nextSource,
|
||||
);
|
||||
return {
|
||||
...current,
|
||||
sourceLanguage: nextSource,
|
||||
targetLanguage:
|
||||
current.targetLanguage === nextSource
|
||||
? (fallbackTarget ?? current.targetLanguage)
|
||||
: current.targetLanguage,
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
<aside className="translator-sidebar">
|
||||
<section className="panel workspace pair-selectors">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Language selection</p>
|
||||
<h2>Choose and manage languages</h2>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Authoritative language</span>
|
||||
<select
|
||||
value={effectiveSourceLanguage}
|
||||
onChange={(event) =>
|
||||
setWorkspace((current) => {
|
||||
const nextSource = event.target.value;
|
||||
const fallbackTarget = Object.keys(
|
||||
current.localeMap,
|
||||
).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) => (
|
||||
<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}`}>
|
||||
<li key={lang}>
|
||||
<span>
|
||||
{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>
|
||||
</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
|
||||
</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>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
{status && <p className="notice">{status}</p>}
|
||||
{incoming?.status !== "ready" && incoming?.error && (
|
||||
<p className="error">{incoming.error}</p>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+42
-18
@@ -1,16 +1,5 @@
|
||||
:root {
|
||||
--toolbox-background: #f4f7fc;
|
||||
--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;
|
||||
color-scheme: inherit;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -67,7 +56,7 @@ button:disabled {
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #f7f7ff;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
@@ -118,6 +107,27 @@ a {
|
||||
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,
|
||||
.panel {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
@@ -154,7 +164,7 @@ a {
|
||||
font-size: 0.84rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: #2f2a6d;
|
||||
color: var(--toolbox-accent);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@@ -244,7 +254,11 @@ a {
|
||||
}
|
||||
|
||||
.review-grid thead {
|
||||
background: #f9fbff;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-surface-soft) 86%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.review-grid textarea {
|
||||
@@ -265,7 +279,7 @@ a {
|
||||
color: var(--toolbox-muted);
|
||||
padding: 0.4rem 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);
|
||||
}
|
||||
|
||||
@@ -295,7 +309,7 @@ a {
|
||||
|
||||
.notice {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #1f5a75;
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -351,7 +365,7 @@ a {
|
||||
.language-list li {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.6rem;
|
||||
background: #fbfcff;
|
||||
background: color-mix(in srgb, var(--toolbox-surface-soft) 90%, transparent);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
@@ -376,6 +390,16 @@ a {
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.translator-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.translator-sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.translator-tools",
|
||||
"name": "Translator Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"description": "Review and edit translation bundles in the browser with glossary suggestions.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.1.2";
|
||||
|
||||
Reference in New Issue
Block a user