@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.
Files changed (3) hide show
  1. package/README.md +70 -29
  2. package/dist/bin.js +261 -88
  3. package/package.json +6 -3
package/README.md CHANGED
@@ -1,11 +1,31 @@
1
1
  # @fal-works/sagmal
2
2
 
3
- Command-line translation tool powered by the DeepL API.
3
+ CLI translation tool powered by the [DeepL API](https://developers.deepl.com/).
4
4
 
5
- > "sagmal" comes from the German "Sag mal" ("say" or "tell me").
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
- Then you will see the translated text in your terminal.
50
+ The translated text will then appear in your terminal.
31
51
 
32
52
 
33
- ## Specifying Languages
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 (confliction should be avoided)
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 don't specify a source language in the CLI or config file, the DeepL API will detect it automatically.
60
- - If you don't specify a target language in either place, it will default to `en-US`.
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
- ## Configuration
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
- - `deepL.sourceLang`: The default source language code.
70
- - `deepL.targetLang`: The default target language code.
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": "vi",
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
- ## Help message
90
-
91
- If no text is provided, or if the first argument is `--help` or `-h`, the tool will display a help message.
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
- ```text
94
- Usage:
95
- sagmal [languages] <text>
96
- sagmal <text> [languages]
97
- sagmal [language] <text> [language]
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
- if (!arg.includes(":")) return null;
15
- const colonIndex = arg.indexOf(":");
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: beforeColon || null,
20
- targetLang: afterColon || null
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
- * Checks if CLI arguments contain help option.
52
+ * Parses all CLI arguments including options and positionals.
53
53
  */
54
- function hasHelpOption(args) {
55
- return args.length === 0 || args[0] === "--help" || args[0] === "-h";
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 parseCliArguments(args) {
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 readConfigFileContent(path) {
157
+ function readFileContent(path) {
95
158
  try {
96
159
  return readFileSync(path, "utf-8");
97
160
  } catch (error) {
98
- throw new SagmalError(`Cannot read config file: ${path}\n ${stringifyError(error)}`);
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 parseConfigJson(content, path) {
172
+ function parseJson(content, path) {
110
173
  try {
111
174
  return JSON.parse(content);
112
175
  } catch (error) {
113
- throw new SagmalError(`Invalid JSON in config file: ${path}\n ${stringifyError(error)}`);
176
+ throw new SagmalError(`Invalid JSON: ${path}\n ${stringifyError(error)}`);
114
177
  }
115
178
  }
116
179
  /**
117
- * Validates and types parsed config data.
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 validateConfigData(parsed, path) {
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 loadConfigFile(path) {
200
+ function loadSagmalRc(path) {
138
201
  if (!existsSync(path)) return void 0;
139
- const content = readConfigFileContent(path);
140
- const parsed = parseConfigJson(content, path);
141
- return validateConfigData(parsed, path);
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 loadConfigInputs() {
211
+ function loadSagmalRcInputs() {
149
212
  const homeDir = homedir();
150
- const homeConfig = loadConfigFile(join(homeDir, ".sagmalrc.json"));
151
- const localConfig = loadConfigFile(join(process.cwd(), ".sagmalrc.json"));
213
+ const homeConfig = loadSagmalRc(join(homeDir, ".sagmalrc.json"));
214
+ const localConfig = loadSagmalRc(join(process.cwd(), ".sagmalrc.json"));
152
215
  return {
153
- homeConfig: homeConfig ?? {},
154
- localConfig: localConfig ?? {}
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
- Examples:
205
- sagmal Bonjour tout le monde
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(firstLang, lastLang) {
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
- * Resolves source language with cascading priority.
235
- * Priority order: CLI local config → home config → null (auto-detect)
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 mergeTranslationOptions(configInputs) {
265
- const homeOptions = configInputs.homeConfig.deepL?.options;
266
- const localOptions = configInputs.localConfig.deepL?.options;
267
- if (homeOptions && "__path" in homeOptions) throw new SagmalError("Invalid config: '__path' is an internal-only field and cannot be used in configuration");
268
- if (localOptions && "__path" in localOptions) throw new SagmalError("Invalid config: '__path' is an internal-only field and cannot be used in configuration");
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
- ...homeOptions,
271
- ...localOptions
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 - Parsed CLI language arguments (always provided, uses default object if no language specified)
288
- * @param configInputs - Configuration inputs from files (always provided, uses empty objects as defaults)
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(firstCliLanguages, lastCliLanguages, configInputs) {
292
- const mergedCliLanguages = mergeCliLanguageOptions(firstCliLanguages, lastCliLanguages);
293
- const sourceLanguage = resolveSourceLanguage(mergedCliLanguages, configInputs);
294
- const targetLanguage = resolveTargetLanguage(mergedCliLanguages, configInputs);
295
- const translationOptions = mergeTranslationOptions(configInputs);
296
- return {
297
- sourceLanguage,
298
- targetLanguage,
299
- translationOptions
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.1.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 args = process.argv.slice(2);
335
- if (hasHelpOption(args)) {
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 configInputs = loadConfigInputs();
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.first, parseResult.languageOptions.last, configInputs);
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.1.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": "./dist/bin.js"
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"