Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4fdbff6ba |
@@ -6,6 +6,12 @@
|
|||||||
- 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.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,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`.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "translator-tools",
|
"name": "translator-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "translator-tools",
|
"name": "translator-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "translator-tools",
|
"name": "translator-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
- 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.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.
|
||||||
|
|||||||
+2
-2
@@ -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`.
|
||||||
|
|||||||
@@ -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.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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*/guy;
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
return JSON.parse(value);
|
const parsed = JSON5.parse(extracted.value);
|
||||||
|
values.push({ name, value: parsed });
|
||||||
} catch {
|
} 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();
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("The translation input is empty.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { value: JSON.parse(trimmed) };
|
||||||
|
} catch {
|
||||||
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,
|
||||||
};
|
};
|
||||||
@@ -449,7 +638,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 +674,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,
|
||||||
|
|||||||
+11
-18
@@ -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 {
|
||||||
@@ -154,7 +143,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 +233,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 +258,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 +288,7 @@ a {
|
|||||||
|
|
||||||
.notice {
|
.notice {
|
||||||
margin: 0.75rem 0 0;
|
margin: 0.75rem 0 0;
|
||||||
color: #1f5a75;
|
color: var(--toolbox-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
@@ -351,7 +344,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;
|
||||||
|
|||||||
@@ -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.1",
|
||||||
"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
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.1.1";
|
||||||
|
|||||||
Reference in New Issue
Block a user