@yhotamos/enja-cli 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 yhotta240
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # Enja CLI
2
+
3
+ ![NPM Version](https://img.shields.io/npm/v/%40yhotamos%2Fenja-cli)
4
+ ![NPM Downloads](https://img.shields.io/npm/dm/%40yhotamos%2Fenja-cli)
5
+ ![NPM License](https://img.shields.io/npm/l/%40yhotamos%2Fenja-cli)
6
+
7
+ 英語を日本語に翻訳するシンプルなコマンドラインツール
8
+
9
+ ## 特徴
10
+
11
+ - インストール後すぐ使える(セットアップ不要)
12
+ - Google Apps Script の LanguageApp を使用した軽量な翻訳
13
+ - 引数,パイプ,ファイルから翻訳可能
14
+ - HTML タグ除去機能で Web ページも翻訳可能
15
+ - API キー不要,課金なし
16
+ - カスタム翻訳エンドポイント対応
17
+ - 翻訳履歴の保存・参照機能
18
+
19
+ ## インストール
20
+
21
+ ```bash
22
+ npm install -g @yhotamos/enja-cli
23
+ ```
24
+
25
+ ## 使い方
26
+
27
+ ### 基本的な使い方
28
+
29
+ ```bash
30
+ # 引数で渡された文字列を翻訳
31
+ enja "Hello, world!"
32
+
33
+ # パイプで翻訳
34
+ echo "Hello, world!" | enja
35
+
36
+ # ファイルから読み込み
37
+ enja -f input.txt -o output.txt
38
+
39
+ # 翻訳方向を逆にする (日本語 → 英語)
40
+ enja "こんにちは" -F
41
+ ```
42
+
43
+ ### 履歴と設定
44
+
45
+ ```bash
46
+ # 翻訳履歴を表示
47
+ enja history
48
+
49
+ # 設定を表示・変更
50
+ enja config
51
+ enja config endpoint https://api.example.com/translate
52
+ ```
53
+
54
+ ### 実用例
55
+
56
+ ```bash
57
+ # エラーメッセージの翻訳
58
+ npm install nonexistent-package 2>&1 | enja
59
+
60
+ # Git コマンドのヘルプを日本語化
61
+ git --help | enja
62
+
63
+ # 英語のドキュメントを日本語に変換
64
+ enja -f CONTRIBUTING.md -o CONTRIBUTING.ja.md
65
+
66
+ # Webページの本文を翻訳(HTMLタグ除去)
67
+ curl -s https://example.com | enja -s
68
+
69
+ # APIドキュメントなどテキストコンテンツの翻訳
70
+ curl -s https://example.com/api/docs | enja
71
+ ```
72
+
73
+ ## コマンド
74
+
75
+ [COMMANDS.md](docs/COMMANDS.md) を参照してください.
76
+
77
+ ## セキュリティとプライバシー
78
+
79
+ - 翻訳データは保存されません(リクエストごとに処理し,即座にレスポンス)
80
+ - 他のユーザーの翻訳内容は見えません(完全にステートレス)
81
+ - 機密情報の翻訳は避けてください(公開エンドポイントを使用しているため)
82
+
83
+ ## 制限事項
84
+
85
+ - 1 日あたりのリクエスト数: すべてのユーザーで共有で約 5,000 リクエスト
86
+ - 文字数制限: 1 リクエストあたり最大 100,000 文字
87
+
88
+ 制限に達した場合はエラーメッセージが表示されます.
89
+
90
+ ## 今後の予定
91
+
92
+ - [ ] 複数言語対応
93
+ - [ ] プロファイル機能(複数の設定を切り替え)
94
+ - [ ] API キーの暗号化保存
95
+
96
+ ## 貢献
97
+
98
+ バグ報告や機能リクエストは [GitHub Issues](https://github.com/yhotamos/enja-cli/issues) へお願いします.
99
+
100
+ ## 作者
101
+
102
+ yhotta240
103
+
104
+ - Email: yhotta240@gmail.com
105
+ - GitHub: [@yhotta240](https://github.com/yhotta240)
@@ -0,0 +1,69 @@
1
+ import { ConfigStorage } from '../services/config/storage.js';
2
+ import kleur from 'kleur';
3
+ /** 設定コマンドの実行 */
4
+ export async function config(key, value, options) {
5
+ const storage = new ConfigStorage();
6
+ try {
7
+ // --reset: すべての設定をリセット
8
+ if (options?.reset) {
9
+ await storage.reset();
10
+ console.log(`${kleur.green('✔')} 設定をリセットしました`);
11
+ return;
12
+ }
13
+ // --unset: 指定したキーを削除(デフォルトに戻す)
14
+ if (options?.unset) {
15
+ await storage.unset(options.unset);
16
+ console.log(`${kleur.green('✔')} ${options.unset} をリセットしました`);
17
+ return;
18
+ }
19
+ // key と value が指定された場合: 設定を保存
20
+ if (key && value) {
21
+ await storage.set(key, value);
22
+ console.log(`${kleur.green('✔')} ${key} を設定しました`);
23
+ return;
24
+ }
25
+ // key のみ指定された場合: その設定値を表示
26
+ if (key && !value) {
27
+ const config = await storage.get();
28
+ if (key === 'endpoint') {
29
+ console.log(config.endpoint);
30
+ }
31
+ else if (key === 'api-key') {
32
+ console.log(config.apiKey ? maskApiKey(config.apiKey) : '(not set)');
33
+ }
34
+ else if (key === 'provider') {
35
+ console.log(config.provider);
36
+ }
37
+ else {
38
+ console.error(`error: 無効な設定キー (${key})`);
39
+ process.exit(1);
40
+ }
41
+ return;
42
+ }
43
+ // --list または引数なし: すべての設定を表示
44
+ const config = await storage.get();
45
+ console.log(`${kleur.blue('provider:')} ${config.provider}`);
46
+ console.log(`${kleur.blue('endpoint:')} ${config.endpoint}`);
47
+ console.log(`${kleur.blue('apiKey:')} ${config.apiKey ? maskApiKey(config.apiKey) : '(not set)'}`);
48
+ }
49
+ catch (error) {
50
+ if (error instanceof Error) {
51
+ console.error(error.message);
52
+ }
53
+ else {
54
+ console.error(error);
55
+ }
56
+ process.exit(1);
57
+ }
58
+ }
59
+ // APIキーのマスキング表示
60
+ function maskApiKey(apiKey) {
61
+ if (apiKey.length <= 8) {
62
+ return '*'.repeat(apiKey.length);
63
+ }
64
+ const visible = 4;
65
+ const start = apiKey.slice(0, visible);
66
+ const end = apiKey.slice(-visible);
67
+ const masked = '*'.repeat(apiKey.length - visible * 2);
68
+ return `${start}${masked}${end}`;
69
+ }
@@ -0,0 +1,96 @@
1
+ import { HistoryStorage } from '../services/history/storage.js';
2
+ import { formatHistory } from '../services/history/formatter.js';
3
+ import kleur from 'kleur';
4
+ /** 履歴コマンドの実行 */
5
+ export async function history(id, options) {
6
+ try {
7
+ const storage = new HistoryStorage();
8
+ // 特定IDの履歴表示
9
+ if (id) {
10
+ const trimmed = id.trim();
11
+ if (!trimmed) {
12
+ throw new Error(`空のIDが指定されました`);
13
+ }
14
+ // 完全IDらしければ完全一致で検索
15
+ if (trimmed.length >= 36) {
16
+ const entry = await storage.findById(trimmed);
17
+ if (!entry) {
18
+ throw new Error(`指定されたIDの履歴が見つかりません (${id})`);
19
+ }
20
+ console.log(formatHistory([entry], options.detail));
21
+ return;
22
+ }
23
+ // 短縮IDは最低 8 文字を要求
24
+ if (trimmed.length < 8) {
25
+ throw new Error(`短縮IDは少なくとも8文字を指定してください (${trimmed.length})`);
26
+ }
27
+ // 短縮IDで先頭一致検索
28
+ const matches = await storage.findByShortId(trimmed);
29
+ if (matches.length === 0) {
30
+ throw new Error(`指定されたIDの履歴が見つかりません (${id})`);
31
+ }
32
+ // 複数マッチしても表示する
33
+ const output = formatHistory(matches, options.detail);
34
+ console.log(output);
35
+ return;
36
+ }
37
+ // 履歴削除
38
+ if (options.delete) {
39
+ const delId = options.delete.trim();
40
+ if (!delId) {
41
+ throw new Error(`空のIDが指定されました`);
42
+ }
43
+ // 完全IDらしければ直接削除を試みる
44
+ if (delId.length >= 36) {
45
+ const deleted = await storage.deleteById(delId);
46
+ if (deleted) {
47
+ console.log(`${kleur.green('✔')} 履歴ID ${delId} を削除しました`);
48
+ }
49
+ else {
50
+ throw new Error(`指定されたIDの履歴が見つかりません (${delId})`);
51
+ }
52
+ return;
53
+ }
54
+ // 短縮IDは最低 8 文字を要求
55
+ if (delId.length < 8) {
56
+ throw new Error(`短縮IDで削除する場合は少なくとも8文字を指定してください (${delId.length})`);
57
+ }
58
+ // 短縮IDで先頭一致検索(複数ヒットする可能性あり)
59
+ const matches = await storage.findByShortId(delId);
60
+ if (matches.length === 0) {
61
+ throw new Error(`指定されたIDの履歴が見つかりません (${delId})`);
62
+ }
63
+ if (matches.length > 1) {
64
+ console.error('error: 指定された短縮IDは複数の履歴に一致しました.完全なIDを指定してください.');
65
+ const output = formatHistory(matches, false);
66
+ console.error(output);
67
+ process.exit(1);
68
+ }
69
+ // 単一マッチなら削除
70
+ const targetId = matches[0].id;
71
+ const deleted = await storage.deleteById(targetId);
72
+ if (deleted) {
73
+ console.log(`${kleur.green('✔')} 履歴ID ${targetId} を削除しました`);
74
+ }
75
+ else {
76
+ throw new Error(`削除に失敗しました (${targetId})`);
77
+ }
78
+ return;
79
+ }
80
+ // 履歴クリア
81
+ if (options.clear) {
82
+ await storage.clear();
83
+ console.log(`${kleur.green('✔')} 履歴をクリアしました`);
84
+ return;
85
+ }
86
+ // 履歴表示
87
+ const limit = Number(options.number) || 10;
88
+ const entries = await storage.getRecent(limit);
89
+ const output = formatHistory(entries, options.detail);
90
+ console.log(output);
91
+ }
92
+ catch (error) {
93
+ console.error(error instanceof Error ? `error: ${error.message}` : error);
94
+ process.exit(1);
95
+ }
96
+ }
@@ -0,0 +1,144 @@
1
+ import * as fs from 'fs';
2
+ import ora from 'ora';
3
+ import { createTranslator } from '../services/translator/factory.js';
4
+ import { HistoryStorage } from '../services/history/storage.js';
5
+ import { hashText } from '../utils/hash.js';
6
+ import kleur from 'kleur';
7
+ export async function translate(text, options) {
8
+ try {
9
+ // 標準入力からの読み込み処理
10
+ if (!text && !options.file && !process.stdin.isTTY) {
11
+ const stdin = await readStdin();
12
+ await processTranslation(stdin, options, 'stdin');
13
+ return;
14
+ }
15
+ // ファイルからの読み込み処理
16
+ if (options.file) {
17
+ if (!fs.existsSync(options.file)) {
18
+ throw new Error(`error: ファイルが見つかりません (${options.file})`);
19
+ }
20
+ const fileContent = fs.readFileSync(options.file, 'utf-8');
21
+ await processTranslation(fileContent, options, 'file');
22
+ return;
23
+ }
24
+ // 引数で渡されたテキストの処理
25
+ if (text) {
26
+ await processTranslation(text, options, 'arg');
27
+ return;
28
+ }
29
+ console.error('error: 入力が提供されていません');
30
+ console.error('使い方: enja <テキスト> または enja -f <ファイル> または パイプ入力');
31
+ process.exit(1);
32
+ }
33
+ catch (error) {
34
+ console.error(error instanceof Error ? error.message : error);
35
+ process.exit(1);
36
+ }
37
+ }
38
+ async function processTranslation(text, options, inputMethod) {
39
+ if (!text || text.trim().length === 0) {
40
+ throw new Error('error: 翻訳するテキストが空です');
41
+ }
42
+ // HTMLタグ除去
43
+ let processedText = text;
44
+ if (options.stripHtml) {
45
+ processedText = stripHtmlTags(text);
46
+ if (!processedText || processedText.trim().length === 0) {
47
+ throw new Error('error: HTMLタグを除去した結果、翻訳するテキストが空になりました');
48
+ }
49
+ }
50
+ // 翻訳サービスの初期化
51
+ const translator = await createTranslator(options);
52
+ const historyStorage = new HistoryStorage();
53
+ // 翻訳処理
54
+ const sourceLang = options.flip ? 'ja' : 'en';
55
+ const targetLang = options.flip ? 'en' : 'ja';
56
+ // キャッシュチェック
57
+ const textHash = hashText(processedText);
58
+ const cachedEntry = await historyStorage.findByHash(textHash, sourceLang, targetLang);
59
+ if (cachedEntry && options.cache !== false) {
60
+ console.log(`${kleur.green('✔')} キャッシュから翻訳結果を取得しました`);
61
+ const translated = cachedEntry.translatedText;
62
+ // 出力処理
63
+ if (options.output) {
64
+ try {
65
+ fs.writeFileSync(options.output, translated, 'utf-8');
66
+ console.log(`${kleur.green('✔')} ${options.output} に翻訳結果を保存しました`);
67
+ }
68
+ catch (error) {
69
+ throw new Error(`error: ファイルへの書き込みに失敗しました (${options.output})`);
70
+ }
71
+ }
72
+ else {
73
+ console.log(translated);
74
+ }
75
+ return;
76
+ }
77
+ const dir = `(${sourceLang} → ${targetLang})`;
78
+ const spinner = ora(`翻訳中... ${dir}`).start();
79
+ try {
80
+ const result = await translator.translate(processedText, sourceLang, targetLang);
81
+ const translated = result.text;
82
+ spinner.succeed(`翻訳完了 ${dir}`);
83
+ // 履歴に保存
84
+ await historyStorage.add({
85
+ sourceText: processedText,
86
+ translatedText: translated,
87
+ sourceLang,
88
+ targetLang,
89
+ textLength: processedText.length,
90
+ sourceHash: textHash,
91
+ options: {
92
+ stripHtml: options.stripHtml,
93
+ file: options.file,
94
+ inputMethod,
95
+ },
96
+ });
97
+ // 出力処理
98
+ if (options.output) {
99
+ try {
100
+ fs.writeFileSync(options.output, translated, 'utf-8');
101
+ console.log(`${kleur.green('✔')} ${options.output} に翻訳結果を保存しました`);
102
+ }
103
+ catch (error) {
104
+ throw new Error(`error: ファイルへの書き込みに失敗しました (${options.output})`);
105
+ }
106
+ }
107
+ else {
108
+ console.log(translated);
109
+ }
110
+ }
111
+ catch (error) {
112
+ spinner.fail(`翻訳失敗 ${dir}`);
113
+ throw error;
114
+ }
115
+ }
116
+ function readStdin() {
117
+ return new Promise((resolve, reject) => {
118
+ let data = '';
119
+ process.stdin.setEncoding('utf-8');
120
+ process.stdin.on('data', (chunk) => {
121
+ data += chunk;
122
+ });
123
+ process.stdin.on('end', () => {
124
+ resolve(data);
125
+ });
126
+ process.stdin.on('error', (error) => {
127
+ reject(error);
128
+ });
129
+ });
130
+ }
131
+ function stripHtmlTags(html) {
132
+ return html
133
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
134
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
135
+ .replace(/<[^>]+>/g, '')
136
+ .replace(/&nbsp;/g, ' ')
137
+ .replace(/&lt;/g, '<')
138
+ .replace(/&gt;/g, '>')
139
+ .replace(/&amp;/g, '&')
140
+ .replace(/&quot;/g, '"')
141
+ .replace(/&#39;/g, "'")
142
+ .replace(/\n\s*\n/g, '\n')
143
+ .trim();
144
+ }
@@ -0,0 +1,22 @@
1
+ import { ConfigStorage } from '../services/config/storage.js';
2
+ const DEFAULT_GAS_API_URL = "https://script.google.com/macros/s/AKfycbxOSbKD0aBTaQqIzHv00BMzp6WwrtWHBU3gJY0vhB2HblgUO-cgesfT1l-rrfttnWZzew/exec";
3
+ export async function getConfig(options) {
4
+ // 設定ファイルから読み込み
5
+ const storage = new ConfigStorage();
6
+ const fileConfig = await storage.get();
7
+ // 優先順位: コマンドラインオプション > 設定ファイル > デフォルト値
8
+ const endpoint = options?.endpoint ||
9
+ fileConfig.endpoint ||
10
+ DEFAULT_GAS_API_URL;
11
+ const apiKey = options?.apiKey ||
12
+ fileConfig.apiKey ||
13
+ undefined;
14
+ const provider = options?.provider ||
15
+ fileConfig.provider ||
16
+ 'gas';
17
+ return {
18
+ endpoint,
19
+ provider,
20
+ apiKey,
21
+ };
22
+ }
package/dist/index.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "fs";
3
+ import { Command } from 'commander';
4
+ import { translate } from "./commands/translate.js";
5
+ import { history } from "./commands/history.js";
6
+ import { config } from "./commands/config.js";
7
+ const pkgJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
8
+ const program = new Command();
9
+ program
10
+ .name('enja')
11
+ .usage('[arguments] [options]')
12
+ .description(`Description: ${pkgJson.description}`)
13
+ .version(pkgJson.version, '-v, --version', 'output the current version');
14
+ // 翻訳コマンド (デフォルト)
15
+ program
16
+ .argument('[text]', 'テキストを翻訳する')
17
+ .option('-f, --file <path>', 'ファイルを翻訳する')
18
+ .option('-o, --output <path>', 'ファイルに出力する (デフォルト: 標準出力)')
19
+ .option('-s, --strip-html', 'HTMLタグを除去してから翻訳する')
20
+ .option('-N, --no-cache', 'キャッシュを使用せずに再翻訳する')
21
+ .option('-F, --flip', '翻訳方向を逆にする (デフォルト: 英語→日本語)')
22
+ .option('--endpoint <url>', 'カスタム翻訳エンドポイントを指定')
23
+ .option('--api-key <key>', 'API キーを指定')
24
+ .option('--provider <name>', '翻訳プロバイダーを指定 (gas, custom)')
25
+ .showHelpAfterError()
26
+ .addHelpText('after', `\nExamples:
27
+ $ enja "Hello, world!" # 引数で渡された文字列を翻訳
28
+ $ git --help | enja # パイプ(標準入力)で渡されたテキストを翻訳
29
+ $ enja -f input.txt # ファイルからテキストを読み込んで翻訳
30
+ $ enja -f input.txt -o output.txt # ファイルから読み込み,翻訳結果をファイルに保存
31
+ $ cat README.md | enja -o japanese.md # パイプとファイル出力の組み合わせ
32
+ $ curl -s https://example.com | enja -s # HTMLタグを除去して翻訳
33
+ $ enja "Hello" --endpoint https://api.example.com/translate --api-key YOUR_KEY # カスタムエンドポイント`)
34
+ .addHelpText('afterAll', `\nEnja CLI v${pkgJson.version}`)
35
+ .addHelpText('afterAll', 'Copyright (c) 2025 yhotta240')
36
+ .addHelpText('afterAll', 'GitHub: https://github.com/yhotamos/enja-cli')
37
+ .action(translate);
38
+ // 履歴コマンド
39
+ program
40
+ .command('history')
41
+ .description('翻訳履歴を表示する')
42
+ .argument('[id]', 'ID で履歴を表示する(完全 ID または短縮 ID)')
43
+ .option('-d, --detail', '詳細表示')
44
+ .option('-n, --number <number>', '表示件数 (デフォルト: 10)', '10')
45
+ .option('--delete <id>', '特定の履歴を削除する')
46
+ .option('--clear', '履歴をクリア')
47
+ .action(history);
48
+ // 設定コマンド
49
+ program
50
+ .command('config')
51
+ .description('設定を管理する')
52
+ .argument('[key]', '設定キー (endpoint, api-key, provider)')
53
+ .argument('[value]', '設定値')
54
+ .option('-l, --list', '設定を一覧表示')
55
+ .option('--unset <key>', '設定を削除(デフォルトに戻す)')
56
+ .option('--reset', 'すべての設定をリセット')
57
+ .addHelpText('after', `\nExamples:
58
+ $ enja config # すべての設定を表示
59
+ $ enja config --list # すべての設定を表示
60
+ $ enja config endpoint # endpoint の値を表示
61
+ $ enja config endpoint <URL> # endpoint を設定
62
+ $ enja config api-key <KEY> # API キーを設定
63
+ $ enja config provider gas # プロバイダーを設定
64
+ $ enja config --unset api-key # API キーを削除
65
+ $ enja config --reset # すべての設定をリセット`)
66
+ .action(config);
67
+ program.parse();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ import * as fs from 'fs';
2
+ import { getConfigFilePath, getConfigDir } from '../../utils/paths.js';
3
+ const DEFAULT_CONFIG = {
4
+ provider: 'gas',
5
+ endpoint: 'https://script.google.com/macros/s/AKfycbxOSbKD0aBTaQqIzHv00BMzp6WwrtWHBU3gJY0vhB2HblgUO-cgesfT1l-rrfttnWZzew/exec',
6
+ apiKey: undefined,
7
+ };
8
+ /** 設定の永続化を管理するクラス */
9
+ export class ConfigStorage {
10
+ filePath;
11
+ constructor() {
12
+ this.filePath = getConfigFilePath();
13
+ }
14
+ ensureConfigDir() {
15
+ const configDir = getConfigDir();
16
+ if (!fs.existsSync(configDir)) {
17
+ fs.mkdirSync(configDir, { recursive: true });
18
+ }
19
+ }
20
+ async readConfig() {
21
+ try {
22
+ if (!fs.existsSync(this.filePath)) {
23
+ return { ...DEFAULT_CONFIG };
24
+ }
25
+ const data = fs.readFileSync(this.filePath, 'utf-8');
26
+ const config = JSON.parse(data);
27
+ return { ...DEFAULT_CONFIG, ...config };
28
+ }
29
+ catch (error) {
30
+ return { ...DEFAULT_CONFIG };
31
+ }
32
+ }
33
+ async writeConfig(config) {
34
+ try {
35
+ this.ensureConfigDir();
36
+ fs.writeFileSync(this.filePath, JSON.stringify(config, null, 2), 'utf-8');
37
+ }
38
+ catch (error) {
39
+ throw new Error(`error: 設定ファイルの書き込みに失敗しました`);
40
+ }
41
+ }
42
+ /** 設定を取得 */
43
+ async get() {
44
+ return await this.readConfig();
45
+ }
46
+ /** 設定を保存 */
47
+ async set(key, value) {
48
+ const config = await this.readConfig();
49
+ switch (key) {
50
+ case 'endpoint':
51
+ config.endpoint = value;
52
+ break;
53
+ case 'api-key':
54
+ config.apiKey = value;
55
+ break;
56
+ case 'provider':
57
+ if (value !== 'gas' && value !== 'custom') {
58
+ throw new Error(`error: 無効なプロバイダー (${value}): gas または custom を指定してください`);
59
+ }
60
+ config.provider = value;
61
+ break;
62
+ default:
63
+ throw new Error(`error: 無効な設定キー (${key})`);
64
+ }
65
+ await this.writeConfig(config);
66
+ }
67
+ /** 指定したキーを削除(デフォルトに戻す) */
68
+ async unset(key) {
69
+ const config = await this.readConfig();
70
+ switch (key) {
71
+ case 'endpoint':
72
+ config.endpoint = DEFAULT_CONFIG.endpoint;
73
+ break;
74
+ case 'api-key':
75
+ config.apiKey = undefined;
76
+ break;
77
+ case 'provider':
78
+ config.provider = DEFAULT_CONFIG.provider;
79
+ break;
80
+ default:
81
+ throw new Error(`error: 無効な設定キー (${key})`);
82
+ }
83
+ await this.writeConfig(config);
84
+ }
85
+ /** 設定をリセット */
86
+ async reset() {
87
+ await this.writeConfig({ ...DEFAULT_CONFIG });
88
+ }
89
+ }
@@ -0,0 +1,61 @@
1
+ import kleur from 'kleur';
2
+ /** 履歴エントリをフォーマットして文字列として返す */
3
+ export function formatHistory(entries, detailed = false) {
4
+ if (entries.length === 0) {
5
+ return '履歴はありません';
6
+ }
7
+ if (!detailed) {
8
+ return formatSimple(entries);
9
+ }
10
+ return formatDetailed(entries);
11
+ }
12
+ /** 簡易フォーマット */
13
+ function formatSimple(entries) {
14
+ const lines = [];
15
+ lines.push(`全 ${entries.length} 件の履歴\n`);
16
+ entries.forEach((entry, index) => {
17
+ const date = new Date(entry.timestamp).toLocaleString('ja-JP');
18
+ const preview = entry.sourceText.length > 30
19
+ ? entry.sourceText.replace(/[\r\n]+/g, ' ').substring(0, 30) + '...'
20
+ : entry.sourceText;
21
+ lines.push(`${kleur.cyan('[' + (index + 1) + ']')} ${entry.id.substring(0, 8)} | ${date}`);
22
+ lines.push(` ${entry.sourceLang} → ${entry.targetLang} | ${preview}`);
23
+ lines.push('');
24
+ });
25
+ return lines.join('\n');
26
+ }
27
+ /** 詳細フォーマット */
28
+ function formatDetailed(entries) {
29
+ const lines = [];
30
+ lines.push(`全 ${entries.length} 件の履歴の詳細\n`);
31
+ entries.forEach((entry, index) => {
32
+ if (index > 0) {
33
+ lines.push('─'.repeat(60));
34
+ }
35
+ const date = new Date(entry.timestamp).toLocaleString('ja-JP');
36
+ lines.push(`${kleur.cyan('ID:')} ${entry.id}`);
37
+ lines.push(`${kleur.cyan('Date:')} ${date}`);
38
+ lines.push(`${kleur.cyan('Direction:')} ${entry.sourceLang} → ${entry.targetLang}`);
39
+ lines.push(`${kleur.cyan('Length:')} ${entry.textLength} characters`);
40
+ if (entry.options) {
41
+ const opts = [];
42
+ if (entry.options.inputMethod)
43
+ opts.push(`input=${entry.options.inputMethod}`);
44
+ if (entry.options.stripHtml)
45
+ opts.push('stripHtml=true');
46
+ if (entry.options.file)
47
+ opts.push(`file=${entry.options.file}`);
48
+ if (opts.length > 0) {
49
+ lines.push(`${kleur.cyan('Options:')} ${opts.join(', ')}`);
50
+ }
51
+ }
52
+ lines.push('');
53
+ lines.push(`${kleur.cyan('Input:')}`);
54
+ lines.push(entry.sourceText);
55
+ lines.push('');
56
+ lines.push(`${kleur.cyan('Output:')}`);
57
+ lines.push(entry.translatedText);
58
+ lines.push('');
59
+ });
60
+ return lines.join('\n');
61
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import * as fs from 'fs';
2
+ import { promises as fsp } from 'fs';
3
+ import { randomUUID } from 'crypto';
4
+ import { getHistoryFilePath, getConfigDir } from '../../utils/paths.js';
5
+ const MAX_HISTORY_ENTRIES = 100;
6
+ /** 履歴管理のためのストレージクラス */
7
+ export class HistoryStorage {
8
+ filePath;
9
+ constructor() {
10
+ this.filePath = getHistoryFilePath();
11
+ }
12
+ /** 設定ディレクトリが存在しない場合は作成 */
13
+ ensureConfigDir() {
14
+ const configDir = getConfigDir();
15
+ if (!fs.existsSync(configDir)) {
16
+ fs.mkdirSync(configDir, { recursive: true });
17
+ }
18
+ }
19
+ /** 履歴ファイルを読み込む */
20
+ async readHistory() {
21
+ try {
22
+ // ファイルが存在しない場合は空配列を返す
23
+ await fsp.access(this.filePath).catch(() => { throw new Error('NO_FILE'); });
24
+ const data = await fsp.readFile(this.filePath, 'utf-8');
25
+ return JSON.parse(data);
26
+ }
27
+ catch (error) {
28
+ // 読み取りや JSON パースに失敗した場合は安全に空配列を返す
29
+ return [];
30
+ }
31
+ }
32
+ /** 履歴ファイルに書き込む */
33
+ async writeHistory(entries) {
34
+ try {
35
+ this.ensureConfigDir();
36
+ // 一時ファイルに書き込み、リネームで原子性を確保
37
+ const tmpPath = `${this.filePath}.tmp`;
38
+ const data = JSON.stringify(entries, null, 2);
39
+ await fsp.writeFile(tmpPath, data, 'utf-8');
40
+ await fsp.rename(tmpPath, this.filePath);
41
+ }
42
+ catch (error) {
43
+ throw new Error(`error: 履歴ファイルの書き込みに失敗しました`);
44
+ }
45
+ }
46
+ /** 履歴にエントリを追加 */
47
+ async add(entry) {
48
+ const entries = await this.readHistory();
49
+ const newEntry = {
50
+ ...entry,
51
+ id: randomUUID(),
52
+ timestamp: new Date().toISOString(),
53
+ };
54
+ entries.unshift(newEntry);
55
+ // 最大エントリ数を超えた場合は古いエントリを削除
56
+ if (entries.length > MAX_HISTORY_ENTRIES) {
57
+ entries.splice(MAX_HISTORY_ENTRIES);
58
+ }
59
+ await this.writeHistory(entries);
60
+ }
61
+ /** すべての履歴を取得 */
62
+ async getAll() {
63
+ return await this.readHistory();
64
+ }
65
+ /** 最近の履歴を取得 */
66
+ async getRecent(limit) {
67
+ const entries = await this.readHistory();
68
+ return entries.slice(0, limit);
69
+ }
70
+ /** 履歴を削除 */
71
+ async deleteById(id) {
72
+ const entries = await this.readHistory();
73
+ const filteredEntries = entries.filter(entry => entry.id !== id);
74
+ // 変更がなければ書き込みを行わない
75
+ if (filteredEntries.length === entries.length)
76
+ return false;
77
+ await this.writeHistory(filteredEntries);
78
+ return true;
79
+ }
80
+ /** 履歴をクリア */
81
+ async clear() {
82
+ await this.writeHistory([]);
83
+ }
84
+ /** IDで履歴を検索 */
85
+ async findById(id) {
86
+ const entries = await this.readHistory();
87
+ return entries.find(entry => entry.id === id) || null;
88
+ }
89
+ /**
90
+ * 短縮IDで履歴を検索(先頭一致)
91
+ * 複数マッチする可能性があるため配列を返す
92
+ */
93
+ async findByShortId(id) {
94
+ const entries = await this.readHistory();
95
+ return entries.filter(entry => entry.id.startsWith(id));
96
+ }
97
+ /** ハッシュと翻訳方向で履歴を検索 */
98
+ async findByHash(hash, sourceLang, targetLang) {
99
+ const entries = await this.readHistory();
100
+ return entries.find(entry => entry.sourceHash === hash &&
101
+ entry.sourceLang === sourceLang &&
102
+ entry.targetLang === targetLang) || null;
103
+ }
104
+ }
@@ -0,0 +1,25 @@
1
+ import * as deepl from 'deepl-node';
2
+ export class DeepLTranslator {
3
+ translator;
4
+ constructor(apiKey) {
5
+ this.translator = new deepl.Translator(apiKey);
6
+ }
7
+ async translate(text, sourceLang, targetLang) {
8
+ try {
9
+ const result = await this.translator.translateText(text, sourceLang, targetLang);
10
+ return {
11
+ text: result.text,
12
+ detectedSourceLang: result.detectedSourceLang,
13
+ };
14
+ }
15
+ catch (error) {
16
+ if (error instanceof Error) {
17
+ throw new Error(`DeepL translation failed: ${error.message}`);
18
+ }
19
+ throw error;
20
+ }
21
+ }
22
+ async checkUsage() {
23
+ return await this.translator.getUsage();
24
+ }
25
+ }
@@ -0,0 +1,12 @@
1
+ import { GASTranslator } from './gas.js';
2
+ import { getConfig } from '../../config/index.js';
3
+ export async function createTranslator(options) {
4
+ const config = await getConfig(options);
5
+ switch (config.provider) {
6
+ case 'gas':
7
+ case 'custom':
8
+ return new GASTranslator(config.endpoint, config.apiKey);
9
+ default:
10
+ throw new Error(`error: サポートされていないプロバイダー (${config.provider})`);
11
+ }
12
+ }
@@ -0,0 +1,44 @@
1
+ export class GASTranslator {
2
+ apiUrl;
3
+ apiKey;
4
+ constructor(apiUrl, apiKey) {
5
+ this.apiUrl = apiUrl;
6
+ this.apiKey = apiKey;
7
+ }
8
+ async translate(text, sourceLang, targetLang) {
9
+ try {
10
+ const headers = {
11
+ 'Content-Type': 'application/json',
12
+ };
13
+ if (this.apiKey) {
14
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
15
+ }
16
+ const response = await fetch(this.apiUrl, {
17
+ method: 'POST',
18
+ headers,
19
+ body: JSON.stringify({
20
+ text,
21
+ sourceLang,
22
+ targetLang,
23
+ }),
24
+ });
25
+ if (!response.ok) {
26
+ throw new Error(`error: HTTP ${response.status} ${response.statusText}`);
27
+ }
28
+ const data = await response.json();
29
+ if (data.code !== 200 || !data.translatedText) {
30
+ throw new Error(`error: ${data.error || '翻訳に失敗しました'}`);
31
+ }
32
+ return {
33
+ text: data.translatedText,
34
+ detectedSourceLang: data.detectedSourceLang,
35
+ };
36
+ }
37
+ catch (error) {
38
+ if (error instanceof Error) {
39
+ throw error;
40
+ }
41
+ throw new Error(`error: 翻訳に失敗しました`);
42
+ }
43
+ }
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import { createHash } from 'crypto';
2
+ /** テキストの SHA-256 ハッシュを計算して返す */
3
+ export function hashText(text) {
4
+ return createHash('sha256').update(text).digest('hex');
5
+ }
@@ -0,0 +1,23 @@
1
+ import * as path from 'path';
2
+ import * as os from 'os';
3
+ /** OS の設定ディレクトリのパスを取得 */
4
+ export function getConfigDir() {
5
+ const platform = process.platform;
6
+ if (platform === 'win32') {
7
+ const appData = process.env.APPDATA;
8
+ if (!appData) {
9
+ throw new Error('error: APPDATA 環境変数が設定されていません');
10
+ }
11
+ return path.join(appData, 'enja-cli');
12
+ }
13
+ const homeDir = os.homedir();
14
+ return path.join(homeDir, '.config', 'enja-cli');
15
+ }
16
+ /** 履歴ファイルのパスを取得 */
17
+ export function getHistoryFilePath() {
18
+ return path.join(getConfigDir(), 'history.json');
19
+ }
20
+ /** 設定ファイルのパスを取得 */
21
+ export function getConfigFilePath() {
22
+ return path.join(getConfigDir(), 'config.json');
23
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@yhotamos/enja-cli",
3
+ "version": "1.0.0",
4
+ "description": "英語を日本語に翻訳するシンプルなコマンドラインツール",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "bin": {
11
+ "enja": "dist/index.js"
12
+ },
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "watch": "tsc --watch"
16
+ },
17
+ "keywords": [
18
+ "translation",
19
+ "cli",
20
+ "english",
21
+ "japanese",
22
+ "enja"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "author": "yhotta240 <yhotta240@gmail.com> (https://github.com/yhotta240)",
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/yhotamos/enja-cli.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/yhotamos/enja-cli/issues"
38
+ },
39
+ "homepage": "https://github.com/yhotamos/enja-cli#readme",
40
+ "dependencies": {
41
+ "commander": "^14.0.2",
42
+ "kleur": "^4.1.5",
43
+ "ora": "^9.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^24.10.1",
47
+ "tsx": "^4.20.6",
48
+ "typescript": "^5.9.3"
49
+ }
50
+ }