@fal-works/sagmal 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -29
- package/dist/bin.js +261 -88
- package/package.json +6 -3
package/README.md
CHANGED
@@ -1,11 +1,31 @@
|
|
1
1
|
# @fal-works/sagmal
|
2
2
|
|
3
|
-
|
3
|
+
CLI translation tool powered by the [DeepL API](https://developers.deepl.com/).
|
4
4
|
|
5
|
-
>
|
5
|
+
> “sagmal” comes from the German “Sag mal,” which means “say” or “tell me.”
|
6
6
|
|
7
7
|
Note: This is an early-stage project, primarily developed for personal use.
|
8
8
|
|
9
|
+
|
10
|
+
## In Short
|
11
|
+
|
12
|
+
```text
|
13
|
+
C:\> sagmal Oh mein Gott! # Input any text after the command
|
14
|
+
Oh my God! # Output (translated)
|
15
|
+
```
|
16
|
+
|
17
|
+
|
18
|
+
## Key Features
|
19
|
+
|
20
|
+
- Simple CLI for instant text translation
|
21
|
+
- Language selection via colon syntax (e.g., `de:`, `:en`), similar to [translate-shell](https://github.com/soimort/translate-shell)
|
22
|
+
- Usually you don't need to quote the input text (unless it contains special characters or something)
|
23
|
+
- Optionally copy results to the clipboard
|
24
|
+
- Supports config files for persistent preferences, with JSON schema validation
|
25
|
+
- Automatic fallback to a secondary target language
|
26
|
+
- Works on Windows (and possibly other platforms, but I haven't tried them)
|
27
|
+
|
28
|
+
|
9
29
|
## Quick Start
|
10
30
|
|
11
31
|
Save your DeepL API key in a `.env` file located either in the current directory or your home directory.
|
@@ -27,12 +47,12 @@ Or, if you have installed it globally:
|
|
27
47
|
sagmal <text-to-translate>
|
28
48
|
```
|
29
49
|
|
30
|
-
|
50
|
+
The translated text will then appear in your terminal.
|
31
51
|
|
32
52
|
|
33
|
-
##
|
53
|
+
## Language Options
|
34
54
|
|
35
|
-
You can specify language options at the **first** or **last** position of your input using colon (`:`) syntax.
|
55
|
+
You can specify language options at the **first** or **last** position of your input text using colon (`:`) syntax.
|
36
56
|
|
37
57
|
The format is `[from]:[to]`.
|
38
58
|
|
@@ -49,32 +69,52 @@ Hallo Welt de: # from German
|
|
49
69
|
Oh mon Dieu :en # to English
|
50
70
|
私は大丈夫です ja:vi # from Japanese to Vietnamese
|
51
71
|
|
52
|
-
# Language options at both positions (
|
72
|
+
# Language options at both positions (avoid conflicts)
|
53
73
|
ja: 私は大丈夫です :vi # from Japanese to Vietnamese
|
54
74
|
```
|
55
75
|
|
56
|
-
|
57
76
|
### Language Defaults
|
58
77
|
|
59
|
-
- If you
|
60
|
-
- If you
|
78
|
+
- If you do not specify a source language in the CLI or config file, the DeepL API will detect it automatically.
|
79
|
+
- If you do not specify a target language in the CLI or config file, it will default to `en-US`.
|
80
|
+
|
81
|
+
|
82
|
+
## Other Options
|
61
83
|
|
84
|
+
- `-c`, `--copy` : Copy translated text to clipboard (if available)
|
85
|
+
- `-h`, `--help` : Show help message
|
62
86
|
|
63
|
-
|
87
|
+
|
88
|
+
## Static Configuration
|
89
|
+
|
90
|
+
### Configuration File
|
64
91
|
|
65
92
|
You can configure the tool by creating a `.sagmalrc.json` file in either your home directory or the current directory.
|
66
93
|
|
94
|
+
For editor validation and autocompletion, add the JSON schema reference at the top of your config file:
|
95
|
+
|
96
|
+
```json
|
97
|
+
{
|
98
|
+
"$schema": "https://fal-works.github.io/sagmal/sagmalrc/v0.x.x/schema.json"
|
99
|
+
}
|
100
|
+
```
|
101
|
+
|
67
102
|
The configuration file must be in JSON format and can include:
|
68
103
|
|
69
|
-
- `
|
70
|
-
- `deepL.
|
104
|
+
- `copyToClipboard`: Automatically copy translated text to clipboard.
|
105
|
+
- `deepL.sourceLang`: The default [source language code](https://developers.deepl.com/docs/getting-started/supported-languages).
|
106
|
+
- `deepL.targetLang`: The default [target language code](https://developers.deepl.com/docs/getting-started/supported-languages#translation-source-languages).
|
107
|
+
- `deepL.targetLang2`: The secondary default target language code. See [Secondary Default Target Language](#secondary-default-target-language) for details.
|
71
108
|
- `deepL.options`: [Text translation options](https://github.com/deeplcom/deepl-node?tab=readme-ov-file#text-translation-options) that will be passed to the DeepL API.
|
72
109
|
|
73
110
|
```json
|
74
111
|
{
|
112
|
+
"$schema": "https://fal-works.github.io/sagmal/sagmalrc/v0.x.x/schema.json",
|
113
|
+
"copyToClipboard": true,
|
75
114
|
"deepL": {
|
76
115
|
"sourceLang": "ja",
|
77
|
-
"targetLang": "
|
116
|
+
"targetLang": "en-US",
|
117
|
+
"targetLang2": "ja",
|
78
118
|
"options": {
|
79
119
|
"formality": "less",
|
80
120
|
"context": "Always translate technical terms to English",
|
@@ -85,26 +125,27 @@ The configuration file must be in JSON format and can include:
|
|
85
125
|
}
|
86
126
|
```
|
87
127
|
|
128
|
+
### Secondary Default Target Language
|
88
129
|
|
89
|
-
|
90
|
-
|
91
|
-
|
130
|
+
The `targetLang2` option provides automatic fallback when sagmal assumes
|
131
|
+
no meaningful translation occurred due to matching source and target languages.
|
132
|
+
This commonly happens when you input English text and the default target language is also English.
|
92
133
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
Examples:
|
99
|
-
sagmal Bonjour tout le monde
|
100
|
-
sagmal de: Hallo Welt!
|
101
|
-
sagmal :it It's not a bug, it's a feature
|
102
|
-
sagmal fr:ar Je pense, donc je suis
|
103
|
-
sagmal I have made a terrible mistake :ja
|
104
|
-
sagmal 404 Motivation Not Found en:de
|
105
|
-
sagmal ja: 私は大丈夫です :zh-HANT
|
134
|
+
**Example:**
|
135
|
+
```bash
|
136
|
+
# Config: { "deepL": { "targetLang2": "de" } }
|
137
|
+
sagmal Hello world
|
138
|
+
# → Detects English → Targets "de" instead of English → "Hallo Welt"
|
106
139
|
```
|
107
140
|
|
141
|
+
**Conditions:**
|
142
|
+
- Target language not explicitly specified via CLI (`:en`, `de:`, etc.)
|
143
|
+
- `targetLang2` configured in `.sagmalrc.json`
|
144
|
+
- Detected source language matches resolved target language
|
145
|
+
- Input and output text are unchanged
|
146
|
+
|
147
|
+
**Note:** Simplified language matching is used (e.g., `en` matches `en-US`, but `en-US` does not match `en-GB`), which may not be accurate in all cases.
|
148
|
+
|
108
149
|
|
109
150
|
## References
|
110
151
|
|
package/dist/bin.js
CHANGED
@@ -1,23 +1,23 @@
|
|
1
1
|
#!/usr/bin/env node
|
2
2
|
import { DeepLClient, DeepLError } from "deepl-node";
|
3
|
+
import { parseArgs } from "node:util";
|
4
|
+
import { spawn } from "node:child_process";
|
5
|
+
import { homedir, platform } from "node:os";
|
3
6
|
import { existsSync, readFileSync } from "node:fs";
|
4
|
-
import { homedir } from "node:os";
|
5
7
|
import { join } from "node:path";
|
6
8
|
import dotenv from "dotenv";
|
7
9
|
|
8
10
|
//#region src/cli-language-parser.ts
|
9
11
|
/**
|
10
12
|
* Parses colon-separated language options from a command line argument.
|
11
|
-
* Returns null if argument doesn't contain colon.
|
13
|
+
* Returns null if argument doesn't contain exactly one colon.
|
12
14
|
*/
|
13
15
|
function parseCliLanguageOption(arg) {
|
14
|
-
|
15
|
-
|
16
|
-
const beforeColon = arg.substring(0, colonIndex);
|
17
|
-
const afterColon = arg.substring(colonIndex + 1);
|
16
|
+
const parts = arg.split(":");
|
17
|
+
if (parts.length !== 2) return null;
|
18
18
|
return {
|
19
|
-
sourceLang:
|
20
|
-
targetLang:
|
19
|
+
sourceLang: parts[0] || null,
|
20
|
+
targetLang: parts[1] || null
|
21
21
|
};
|
22
22
|
}
|
23
23
|
|
@@ -49,16 +49,36 @@ function stringifyError(error) {
|
|
49
49
|
//#endregion
|
50
50
|
//#region src/cli-parser.ts
|
51
51
|
/**
|
52
|
-
*
|
52
|
+
* Parses all CLI arguments including options and positionals.
|
53
53
|
*/
|
54
|
-
function
|
55
|
-
|
54
|
+
function parseCliArguments() {
|
55
|
+
const { values, positionals } = parseArgs({
|
56
|
+
options: {
|
57
|
+
help: {
|
58
|
+
type: "boolean",
|
59
|
+
short: "h"
|
60
|
+
},
|
61
|
+
copy: {
|
62
|
+
type: "boolean",
|
63
|
+
short: "c"
|
64
|
+
}
|
65
|
+
},
|
66
|
+
allowPositionals: true
|
67
|
+
});
|
68
|
+
const shouldShowHelp = values.help || positionals.length === 0;
|
69
|
+
const shouldCopyToClipboard = values.copy ?? false;
|
70
|
+
const positionalResult = parseCliPositionals(positionals);
|
71
|
+
return {
|
72
|
+
...positionalResult,
|
73
|
+
shouldShowHelp,
|
74
|
+
shouldCopyToClipboard
|
75
|
+
};
|
56
76
|
}
|
57
77
|
/**
|
58
|
-
* Parses CLI arguments to extract language options and text parts.
|
78
|
+
* Parses CLI positional arguments to extract language options and text parts.
|
59
79
|
* Handles language options at both first and last positions.
|
60
80
|
*/
|
61
|
-
function
|
81
|
+
function parseCliPositionals(args) {
|
62
82
|
if (args.length === 0) return {
|
63
83
|
languageOptions: {
|
64
84
|
first: null,
|
@@ -82,6 +102,49 @@ function parseCliArguments(args) {
|
|
82
102
|
};
|
83
103
|
}
|
84
104
|
|
105
|
+
//#endregion
|
106
|
+
//#region src/clipboard.ts
|
107
|
+
/**
|
108
|
+
* @returns The command to use for clipboard operations based on the platform,
|
109
|
+
* or null if not supported.
|
110
|
+
*/
|
111
|
+
function getClipboardCommand() {
|
112
|
+
const platform$1 = platform();
|
113
|
+
if (platform$1 === "win32") return "clip";
|
114
|
+
if (platform$1 === "darwin") return "pbcopy";
|
115
|
+
return null;
|
116
|
+
}
|
117
|
+
/**
|
118
|
+
* Internal function to copy text to clipboard without error handling.
|
119
|
+
* May throw errors that need to be handled by the caller.
|
120
|
+
*
|
121
|
+
* @param text Text to copy to clipboard.
|
122
|
+
*/
|
123
|
+
async function copyToClipboardInternal(text) {
|
124
|
+
const command = getClipboardCommand();
|
125
|
+
if (!command) return;
|
126
|
+
return new Promise((resolve, reject) => {
|
127
|
+
const proc = spawn(command);
|
128
|
+
proc.on("error", reject);
|
129
|
+
proc.stdin.write(text, "utf8", (err) => {
|
130
|
+
if (err) return reject(err);
|
131
|
+
proc.stdin.end(resolve);
|
132
|
+
});
|
133
|
+
});
|
134
|
+
}
|
135
|
+
/**
|
136
|
+
* Copy text to the system clipboard.
|
137
|
+
* Errors are silently suppressed - clipboard copy failures are non-fatal.
|
138
|
+
* No effect on unsupported platforms or if no clipboard command is available.
|
139
|
+
*
|
140
|
+
* @param text Text to copy to clipboard.
|
141
|
+
*/
|
142
|
+
async function copyToClipboard(text) {
|
143
|
+
try {
|
144
|
+
await copyToClipboardInternal(text);
|
145
|
+
} catch (_error) {}
|
146
|
+
}
|
147
|
+
|
85
148
|
//#endregion
|
86
149
|
//#region src/config-loader.ts
|
87
150
|
/**
|
@@ -91,11 +154,11 @@ function parseCliArguments(args) {
|
|
91
154
|
* @returns File content as string
|
92
155
|
* @throws SagmalError if file cannot be read
|
93
156
|
*/
|
94
|
-
function
|
157
|
+
function readFileContent(path) {
|
95
158
|
try {
|
96
159
|
return readFileSync(path, "utf-8");
|
97
160
|
} catch (error) {
|
98
|
-
throw new SagmalError(`Cannot read
|
161
|
+
throw new SagmalError(`Cannot read file: ${path}\n ${stringifyError(error)}`);
|
99
162
|
}
|
100
163
|
}
|
101
164
|
/**
|
@@ -106,22 +169,22 @@ function readConfigFileContent(path) {
|
|
106
169
|
* @returns Parsed JSON data
|
107
170
|
* @throws SagmalError if JSON is invalid
|
108
171
|
*/
|
109
|
-
function
|
172
|
+
function parseJson(content, path) {
|
110
173
|
try {
|
111
174
|
return JSON.parse(content);
|
112
175
|
} catch (error) {
|
113
|
-
throw new SagmalError(`Invalid JSON
|
176
|
+
throw new SagmalError(`Invalid JSON: ${path}\n ${stringifyError(error)}`);
|
114
177
|
}
|
115
178
|
}
|
116
179
|
/**
|
117
|
-
* Validates and types parsed
|
180
|
+
* Validates and types parsed `.sagmalrc` data.
|
118
181
|
*
|
119
182
|
* @param parsed - Parsed JSON data
|
120
183
|
* @param path - File path for error messages
|
121
184
|
* @returns Typed config object
|
122
185
|
* @throws SagmalError if data is not a valid config object
|
123
186
|
*/
|
124
|
-
function
|
187
|
+
function validateSagmalRc(parsed, path) {
|
125
188
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new SagmalError(`Invalid config file: ${path}\n must be an object, not ${Array.isArray(parsed) ? "array" : typeof parsed}`);
|
126
189
|
return parsed;
|
127
190
|
}
|
@@ -134,24 +197,24 @@ function validateConfigData(parsed, path) {
|
|
134
197
|
* @returns Parsed object or undefined if file doesn't exist
|
135
198
|
* @throws SagmalError if file cannot be read, JSON is malformed, or data is not an object
|
136
199
|
*/
|
137
|
-
function
|
200
|
+
function loadSagmalRc(path) {
|
138
201
|
if (!existsSync(path)) return void 0;
|
139
|
-
const content =
|
140
|
-
const parsed =
|
141
|
-
return
|
202
|
+
const content = readFileContent(path);
|
203
|
+
const parsed = parseJson(content, path);
|
204
|
+
return validateSagmalRc(parsed, path);
|
142
205
|
}
|
143
206
|
/**
|
144
207
|
* Loads configuration files from home and current directories.
|
145
208
|
*
|
146
209
|
* @returns Configuration inputs (no merging applied, uses empty objects as defaults)
|
147
210
|
*/
|
148
|
-
function
|
211
|
+
function loadSagmalRcInputs() {
|
149
212
|
const homeDir = homedir();
|
150
|
-
const homeConfig =
|
151
|
-
const localConfig =
|
213
|
+
const homeConfig = loadSagmalRc(join(homeDir, ".sagmalrc.json"));
|
214
|
+
const localConfig = loadSagmalRc(join(process.cwd(), ".sagmalrc.json"));
|
152
215
|
return {
|
153
|
-
|
154
|
-
|
216
|
+
home: homeConfig ?? {},
|
217
|
+
local: localConfig ?? {}
|
155
218
|
};
|
156
219
|
}
|
157
220
|
|
@@ -200,9 +263,18 @@ function getApiKey() {
|
|
200
263
|
function showHelp() {
|
201
264
|
const msg = `
|
202
265
|
Usage:
|
203
|
-
sagmal [languages] <text>
|
204
|
-
|
205
|
-
sagmal
|
266
|
+
sagmal [options] [languages] <text>
|
267
|
+
sagmal [options] <text> [languages]
|
268
|
+
sagmal [options] [language] <text> [language]
|
269
|
+
|
270
|
+
Options:
|
271
|
+
-c, --copy Copy translated text to clipboard
|
272
|
+
-h, --help Show this help message
|
273
|
+
|
274
|
+
Example:
|
275
|
+
sagmal おしまいだ
|
276
|
+
|
277
|
+
Examples with language options:
|
206
278
|
sagmal de: Hallo Welt!
|
207
279
|
sagmal :it It's not a bug, it's a feature
|
208
280
|
sagmal I have made a terrible mistake :ja
|
@@ -216,9 +288,74 @@ Examples:
|
|
216
288
|
//#endregion
|
217
289
|
//#region src/parameter-resolver.ts
|
218
290
|
/**
|
291
|
+
* Normalizes target language string, converting 'en' (case-insensitive) to 'en-US'.
|
292
|
+
*/
|
293
|
+
function normalizeTargetLanguage(lang) {
|
294
|
+
return lang.toLowerCase() === "en" ? "en-US" : lang;
|
295
|
+
}
|
296
|
+
/**
|
297
|
+
* Coalesces a property from two parameter layers, with higher priority taking precedence.
|
298
|
+
*/
|
299
|
+
function coalesceProperty(target, lower, higher, key) {
|
300
|
+
if (higher[key] !== void 0) target[key] = higher[key];
|
301
|
+
else if (lower[key] !== void 0) target[key] = lower[key];
|
302
|
+
}
|
303
|
+
/**
|
304
|
+
* Merges two parameter layers with the second layer taking priority.
|
305
|
+
*
|
306
|
+
* @param lower - Lower priority layer
|
307
|
+
* @param higher - Higher priority layer (overrides lower)
|
308
|
+
* @returns Merged parameter layer
|
309
|
+
*/
|
310
|
+
function mergeParameterLayers(lower, higher) {
|
311
|
+
const result = { translationOptions: {
|
312
|
+
...lower.translationOptions,
|
313
|
+
...higher.translationOptions
|
314
|
+
} };
|
315
|
+
coalesceProperty(result, lower, higher, "sourceLanguage");
|
316
|
+
coalesceProperty(result, lower, higher, "targetLanguage");
|
317
|
+
coalesceProperty(result, lower, higher, "targetLanguageSecond");
|
318
|
+
coalesceProperty(result, lower, higher, "shouldCopyToClipboard");
|
319
|
+
return result;
|
320
|
+
}
|
321
|
+
/**
|
322
|
+
* Creates the default parameter layer.
|
323
|
+
*/
|
324
|
+
function createDefaultLayer() {
|
325
|
+
return {
|
326
|
+
sourceLanguage: null,
|
327
|
+
targetLanguage: "en-US",
|
328
|
+
targetLanguageSecond: null,
|
329
|
+
translationOptions: {},
|
330
|
+
shouldCopyToClipboard: false
|
331
|
+
};
|
332
|
+
}
|
333
|
+
/**
|
334
|
+
* Creates parameter layer from config object.
|
335
|
+
*/
|
336
|
+
function createConfigLayer(config) {
|
337
|
+
const result = { translationOptions: config.deepL?.options ?? {} };
|
338
|
+
if (config.deepL?.sourceLang) result.sourceLanguage = config.deepL.sourceLang;
|
339
|
+
if (config.deepL?.targetLang) result.targetLanguage = normalizeTargetLanguage(config.deepL.targetLang);
|
340
|
+
if (config.deepL?.targetLang2) result.targetLanguageSecond = normalizeTargetLanguage(config.deepL.targetLang2);
|
341
|
+
if (config.copyToClipboard !== void 0) result.shouldCopyToClipboard = config.copyToClipboard;
|
342
|
+
return result;
|
343
|
+
}
|
344
|
+
/**
|
345
|
+
* Creates parameter layer from CLI arguments.
|
346
|
+
*/
|
347
|
+
function createCliLayer(mergedCliLanguages, cliCopyFlag) {
|
348
|
+
const result = { translationOptions: {} };
|
349
|
+
if (mergedCliLanguages.sourceLang) result.sourceLanguage = mergedCliLanguages.sourceLang;
|
350
|
+
if (mergedCliLanguages.targetLang) result.targetLanguage = normalizeTargetLanguage(mergedCliLanguages.targetLang);
|
351
|
+
if (cliCopyFlag) result.shouldCopyToClipboard = true;
|
352
|
+
return result;
|
353
|
+
}
|
354
|
+
/**
|
219
355
|
* Merges multiple CLI language options with conflict detection.
|
220
356
|
*/
|
221
|
-
function mergeCliLanguageOptions(
|
357
|
+
function mergeCliLanguageOptions(cliLanguages) {
|
358
|
+
const { first: firstLang, last: lastLang } = cliLanguages;
|
222
359
|
const firstSource = firstLang?.sourceLang ?? null;
|
223
360
|
const firstTarget = firstLang?.targetLang ?? null;
|
224
361
|
const lastSource = lastLang?.sourceLang ?? null;
|
@@ -231,79 +368,85 @@ function mergeCliLanguageOptions(firstLang, lastLang) {
|
|
231
368
|
};
|
232
369
|
}
|
233
370
|
/**
|
234
|
-
*
|
235
|
-
*
|
236
|
-
*/
|
237
|
-
function resolveSourceLanguage(cliLanguages, configInputs) {
|
238
|
-
let src = null;
|
239
|
-
src ??= cliLanguages.sourceLang;
|
240
|
-
src ??= configInputs.localConfig.deepL?.sourceLang;
|
241
|
-
src ??= configInputs.homeConfig.deepL?.sourceLang;
|
242
|
-
src ??= null;
|
243
|
-
return src;
|
244
|
-
}
|
245
|
-
/**
|
246
|
-
* Resolves target language with cascading priority.
|
247
|
-
* Priority order: CLI → local config → home config → 'en-US' (default)
|
248
|
-
* Also normalizes 'en' to 'en-US'.
|
249
|
-
*/
|
250
|
-
function resolveTargetLanguage(cliLanguages, configInputs) {
|
251
|
-
let target = null;
|
252
|
-
target ??= cliLanguages.targetLang;
|
253
|
-
target ??= configInputs.localConfig.deepL?.targetLang;
|
254
|
-
target ??= configInputs.homeConfig.deepL?.targetLang;
|
255
|
-
target ??= "en-US";
|
256
|
-
if (target.toLowerCase() === "en") target = "en-US";
|
257
|
-
return target;
|
258
|
-
}
|
259
|
-
/**
|
260
|
-
* Merges DeepL API options from config files.
|
261
|
-
* Local config options override home config options.
|
262
|
-
* Validates that no internal-only fields are present.
|
371
|
+
* Applies post-processing to the merged parameter layer.
|
372
|
+
* Handles normalization, validation, and type conversion.
|
263
373
|
*/
|
264
|
-
function
|
265
|
-
const homeOptions =
|
266
|
-
const localOptions =
|
267
|
-
if (homeOptions && "__path" in homeOptions) throw new SagmalError("Invalid
|
268
|
-
if (localOptions && "__path" in localOptions) throw new SagmalError("Invalid
|
374
|
+
function applyPostProcessing(resolved, sagmalRcInputs) {
|
375
|
+
const homeOptions = sagmalRcInputs.home.deepL?.options;
|
376
|
+
const localOptions = sagmalRcInputs.local.deepL?.options;
|
377
|
+
if (homeOptions && "__path" in homeOptions) throw new SagmalError("Invalid .sagmalrc in the home directory: '__path' is an internal-only field and cannot be used in configuration");
|
378
|
+
if (localOptions && "__path" in localOptions) throw new SagmalError("Invalid .sagmalrc in the current directory: '__path' is an internal-only field and cannot be used in configuration");
|
269
379
|
return {
|
270
|
-
|
271
|
-
|
380
|
+
sourceLanguage: resolved.sourceLanguage ?? null,
|
381
|
+
targetLanguage: resolved.targetLanguage ?? "en-US",
|
382
|
+
targetLanguageSecond: resolved.targetLanguageSecond ?? null,
|
383
|
+
isTargetLanguageFromCli: false,
|
384
|
+
translationOptions: resolved.translationOptions ?? {},
|
385
|
+
shouldCopyToClipboard: resolved.shouldCopyToClipboard ?? false
|
272
386
|
};
|
273
387
|
}
|
274
388
|
/**
|
275
389
|
* Centralized parameter resolution with complete cascading logic.
|
276
390
|
*
|
277
391
|
* Resolution priority (later overrides earlier):
|
278
|
-
* 1. Default values (source: null/auto-detect, target: 'en-US')
|
392
|
+
* 1. Default values (source: null/auto-detect, target: 'en-US', copyToClipboard: false)
|
279
393
|
* 2. Home directory config
|
280
394
|
* 3. Local directory config
|
281
|
-
* 4. CLI language arguments
|
395
|
+
* 4. CLI language arguments and flags
|
282
396
|
*
|
283
397
|
* Special handling:
|
284
398
|
* - 'en' (case-insensitive) is normalized to 'en-US'
|
285
399
|
* - DeepL options are merged (local overrides home)
|
400
|
+
* - Clipboard copy follows CLI flag → local config → home config → false
|
286
401
|
*
|
287
|
-
* @param cliLanguages -
|
288
|
-
* @param
|
402
|
+
* @param cliLanguages - CLI language options from both positions
|
403
|
+
* @param sagmalRcInputs - Configuration inputs from files (always provided, uses empty objects as defaults)
|
404
|
+
* @param cliCopyFlag - CLI copy flag from parsed arguments
|
289
405
|
* @returns Fully resolved parameters ready for translation
|
290
406
|
*/
|
291
|
-
function resolveParameters(
|
292
|
-
const mergedCliLanguages = mergeCliLanguageOptions(
|
293
|
-
const
|
294
|
-
const
|
295
|
-
const
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
407
|
+
function resolveParameters(cliLanguages, sagmalRcInputs, cliCopyFlag) {
|
408
|
+
const mergedCliLanguages = mergeCliLanguageOptions(cliLanguages);
|
409
|
+
const defaultLayer = createDefaultLayer();
|
410
|
+
const homeLayer = createConfigLayer(sagmalRcInputs.home);
|
411
|
+
const localLayer = createConfigLayer(sagmalRcInputs.local);
|
412
|
+
const cliLayer = createCliLayer(mergedCliLanguages, cliCopyFlag);
|
413
|
+
let resolved = defaultLayer;
|
414
|
+
resolved = mergeParameterLayers(resolved, homeLayer);
|
415
|
+
resolved = mergeParameterLayers(resolved, localLayer);
|
416
|
+
resolved = mergeParameterLayers(resolved, cliLayer);
|
417
|
+
const finalResult = applyPostProcessing(resolved, sagmalRcInputs);
|
418
|
+
finalResult.isTargetLanguageFromCli = mergedCliLanguages.targetLang != null;
|
419
|
+
return finalResult;
|
301
420
|
}
|
302
421
|
|
303
422
|
//#endregion
|
304
423
|
//#region package.json
|
305
424
|
var name = "@fal-works/sagmal";
|
306
|
-
var version = "0.
|
425
|
+
var version = "0.2.0";
|
426
|
+
|
427
|
+
//#endregion
|
428
|
+
//#region src/language-code.ts
|
429
|
+
/**
|
430
|
+
* Determines if two language codes likely represent the same language using simplified matching logic.
|
431
|
+
* If language subtags match, treats missing regional subtag as "generic".
|
432
|
+
*
|
433
|
+
* Examples:
|
434
|
+
* - 'en' vs 'en-US' -> true (generic vs regional), although not accurate
|
435
|
+
* - 'en-US' vs 'en-GB' -> false (both regional, different)
|
436
|
+
*/
|
437
|
+
function areLikelySameLanguageCodes(lang1, lang2) {
|
438
|
+
const parts1 = lang1.split("-");
|
439
|
+
const parts2 = lang2.split("-");
|
440
|
+
const maxLength = Math.max(parts1.length, parts2.length);
|
441
|
+
for (let i = 0; i < maxLength; i++) {
|
442
|
+
const part1 = parts1[i];
|
443
|
+
const part2 = parts2[i];
|
444
|
+
if (part1 && part2) {
|
445
|
+
if (part1.toLowerCase() !== part2.toLowerCase()) return false;
|
446
|
+
} else break;
|
447
|
+
}
|
448
|
+
return true;
|
449
|
+
}
|
307
450
|
|
308
451
|
//#endregion
|
309
452
|
//#region src/translator.ts
|
@@ -315,7 +458,32 @@ const clientOptions = { appInfo: {
|
|
315
458
|
appVersion: version
|
316
459
|
} };
|
317
460
|
/**
|
461
|
+
* Determines if the translation likely didn't a meaningful translation.
|
462
|
+
* Uses simplified language matching logic and text comparison.
|
463
|
+
*/
|
464
|
+
function isLikelySameLanguageTranslation(inputText, translationResult, targetLanguage) {
|
465
|
+
const languageCodesEquivalent = areLikelySameLanguageCodes(translationResult.detectedSourceLang, targetLanguage);
|
466
|
+
const textUnchanged = inputText.trim() === translationResult.text.trim();
|
467
|
+
return languageCodesEquivalent && textUnchanged;
|
468
|
+
}
|
469
|
+
/**
|
470
|
+
* Determines the fallback target language to use, if any.
|
471
|
+
*
|
472
|
+
* @param inputText - Original input text
|
473
|
+
* @param params - Resolved translation parameters
|
474
|
+
* @param translationResult - Result from initial translation attempt
|
475
|
+
* @returns Fallback target language if conditions are met, null otherwise
|
476
|
+
*/
|
477
|
+
function getFallbackTargetLanguage(inputText, params, translationResult) {
|
478
|
+
if (!params.isTargetLanguageFromCli && params.targetLanguageSecond != null && isLikelySameLanguageTranslation(inputText, translationResult, params.targetLanguage)) return params.targetLanguageSecond;
|
479
|
+
return null;
|
480
|
+
}
|
481
|
+
/**
|
318
482
|
* Translates text using the DeepL API with fully resolved parameters.
|
483
|
+
* Implements smart fallback to secondary default target language when:
|
484
|
+
* - Target language was not explicitly specified via CLI
|
485
|
+
* - Secondary default target language is configured
|
486
|
+
* - Detected source language is the same as (or similar to) resolved target language
|
319
487
|
*
|
320
488
|
* @param apiKey - DeepL API key
|
321
489
|
* @param text - Text to translate
|
@@ -325,29 +493,34 @@ const clientOptions = { appInfo: {
|
|
325
493
|
async function translate(apiKey, text, params) {
|
326
494
|
const client = new DeepLClient(apiKey, clientOptions);
|
327
495
|
const result = await client.translateText(text, params.sourceLanguage, params.targetLanguage, params.translationOptions);
|
496
|
+
const fallbackTargetLanguage = getFallbackTargetLanguage(text, params, result);
|
497
|
+
if (fallbackTargetLanguage != null) {
|
498
|
+
const fallbackResult = await client.translateText(text, params.sourceLanguage, fallbackTargetLanguage, params.translationOptions);
|
499
|
+
return fallbackResult.text;
|
500
|
+
}
|
328
501
|
return result.text;
|
329
502
|
}
|
330
503
|
|
331
504
|
//#endregion
|
332
505
|
//#region src/bin.ts
|
333
506
|
async function main() {
|
334
|
-
const
|
335
|
-
if (
|
507
|
+
const parseResult = parseCliArguments();
|
508
|
+
if (parseResult.shouldShowHelp) {
|
336
509
|
showHelp();
|
337
510
|
process.exit(0);
|
338
511
|
}
|
339
512
|
loadEnvironment();
|
340
513
|
const apiKey = getApiKey();
|
341
|
-
const
|
342
|
-
const parseResult = parseCliArguments(args);
|
514
|
+
const sagmalRcInputs = loadSagmalRcInputs();
|
343
515
|
const text = parseResult.text;
|
344
516
|
if (text.length === 0) {
|
345
517
|
showHelp();
|
346
518
|
process.exit(0);
|
347
519
|
}
|
348
|
-
const resolvedParams = resolveParameters(parseResult.languageOptions
|
520
|
+
const resolvedParams = resolveParameters(parseResult.languageOptions, sagmalRcInputs, parseResult.shouldCopyToClipboard);
|
349
521
|
const translatedText = await translate(apiKey, text, resolvedParams);
|
350
522
|
console.log(translatedText);
|
523
|
+
if (resolvedParams.shouldCopyToClipboard) await copyToClipboard(translatedText);
|
351
524
|
}
|
352
525
|
main().catch((error) => {
|
353
526
|
if (error instanceof SagmalError) console.error(error.name, ">", error.message);
|
package/package.json
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
{
|
2
2
|
"name": "@fal-works/sagmal",
|
3
|
-
"version": "0.
|
3
|
+
"version": "0.2.0",
|
4
4
|
"description": "CLI for text translation using DeepL API",
|
5
5
|
"type": "module",
|
6
6
|
"bin": {
|
7
|
-
"sagmal": "
|
7
|
+
"sagmal": "dist/bin.js"
|
8
8
|
},
|
9
9
|
"sideEffects": false,
|
10
10
|
"files": [
|
@@ -13,12 +13,15 @@
|
|
13
13
|
"scripts": {
|
14
14
|
"setup": "deps-docs",
|
15
15
|
"build": "tsdown",
|
16
|
+
"test": "npm run test:unit && npm run test:integration",
|
17
|
+
"test:unit": "node --test src/**/*.test.ts",
|
18
|
+
"test:integration": "node --test test/**/*.test.ts",
|
16
19
|
"lint": "biome check",
|
17
20
|
"lint:fix": "biome check --fix",
|
18
21
|
"check:types": "tsc --noEmit -p tsconfig.json",
|
19
22
|
"check": "npm run check:types && npm run lint",
|
20
23
|
"fix": "npm run check:types && npm run lint:fix",
|
21
|
-
"prepublishOnly": "npm run check"
|
24
|
+
"prepublishOnly": "npm run check && npm test"
|
22
25
|
},
|
23
26
|
"keywords": [
|
24
27
|
"translation"
|