概要 編集

「Mery 翻訳」は、Mery エディタ上で選択したテキストやカーソル位置の行を翻訳するためのマクロです。

Google 翻訳のエンドポイントを curl.exe から呼び出し、翻訳結果を元の文章の直下へ挿入します。英語のコメントやドキュメントを読みながら、その場ですぐ日本語訳を確認したい場合などに便利です。

例えば、次のような英文を選択してマクロを実行すると、

Returns the current user information.

直下に翻訳結果が挿入されます。

Returns the current user information.
----- 翻訳: en -> ja -----
現在のユーザー情報を返します。
--------------------------

翻訳元の言語は標準で自動判定されます。

また、判定された言語が翻訳先の言語と同じ場合は、自動的に逆方向へ翻訳します。標準設定では、英語などの文章は日本語へ、日本語の文章は英語へ翻訳されます。

プログラムのソースコードを読む用途も想定しており、camelCase や snake_case などの変数名を単独で翻訳した場合は、適宜単語へ分割してから翻訳します。

例えば、

currentUserName

は内部的に、

current User Name

として翻訳されるため、一般的な英文だけでなく、変数名やメソッド名の意味を確認したい場合にも利用できます。

本マクロをショートカットキーに設定し、いつでも起動できるようにしておくと、英文コメントやエラーメッセージ、変数名などを確認するときに便利です。

注意事項 編集

Mery Ver 3.8.8 で動作確認しています。

本マクロでは、Google 翻訳の非公式エンドポイントへアクセスしています。公式 API として提供しているものではないため、エンドポイントの仕様変更などによって将来的に利用できなくなる可能性があります。

また、翻訳するテキストは Google 翻訳へ送信されます。機密情報、個人情報、パスワード、API キーなど、外部サービスへ送信してはいけない情報には使用しないでください。

Google 翻訳へのアクセスには Windows 10 以降で搭載されている curl.exe を使用します。

なお、短時間に大量の翻訳を実行すると、Google 側のレート制限によって一時的に利用できなくなる可能性があります。

使い方 編集

0. 事前準備 編集

本マクロでは外部ライブラリのインストールは必要ありません。

Google 翻訳への通信に Windows の curl.exe を利用するため、あらかじめ curl.exe が利用可能であることを確認してください。

また、マクロ冒頭にある CONFIG を変更することで、翻訳する言語や表示方式などを設定できます。

標準設定は次のとおりです。

const CONFIG = {
  /** 翻訳元言語。空文字または "auto" で自動判定 */
  sourceLanguage: "auto",
  /** 翻訳先言語 */
  targetLanguage: "ja",
  /** 検出言語が targetLanguage と同じときの翻訳先 */
  reverseLanguage: "en",
  /** curl のタイムアウト(秒) */
  timeoutSec: 10,

  /**
   * 翻訳結果の表示方法(複数同時に有効化可)
   * - editor: エディターへ挿入(選択範囲の直下)
   * - outputBar: アウトプットバーへ出力
   * - tip: ツールチップで表示
   */
  display: {
    editor: true,
    outputBar: false,
    tip: false,
  },

  /**
   * アウトプットバー出力時に自動で表示するか
   * true で表示、false で表示しない
   */
  outputBarVisible: true,

  /**
   * tip 表示時の位置フラグ
   * meShowTipPosCaret (キャレット位置) | meShowTipPosMouse (マウス位置)
   */
  tipFlags: meShowTipPosCaret,
};

標準では、翻訳元の言語を自動判定し、日本語以外の文章を日本語へ翻訳します。

日本語が検出された場合は reverseLanguage が使用され、日本語から英語へ翻訳されます。

1. 選択範囲を翻訳する場合 編集

翻訳したいテキストを選択します。

The request was successfully completed.

マクロを実行すると、選択範囲の最終行の直下に翻訳結果が挿入されます。

The request was successfully completed.
----- 翻訳: en -> ja -----
リクエストは正常に完了しました。
--------------------------

複数行をまとめて選択して翻訳することもできます。

翻訳中はカーソル付近に「選択範囲を翻訳中...」と表示され、翻訳が完了するとステータスバーに「翻訳完了」と表示されます。

2. カーソル位置の行を翻訳する場合 編集

テキストを選択していない状態でマクロを実行すると、カーソルがある行全体を翻訳します。

例えば、次の行にカーソルを置きます。

This method returns the current configuration.

マクロを実行すると、次のようになります。

This method returns the current configuration.
----- 翻訳: en -> ja -----
このメソッドは現在の設定を返します。
--------------------------

空行でマクロを実行した場合は翻訳を行わず、ステータスバーに「翻訳するテキストがありません」と表示されます。

3. 日本語を英語へ翻訳する場合 編集

標準設定では翻訳先言語に日本語 (ja) が設定されています。

翻訳元の言語を自動判定している場合、Google 翻訳によって日本語が検出されると、自動的に reverseLanguage で指定した言語へ翻訳します。

標準では reverseLanguage が英語 (en) のため、例えば、

ユーザー情報を取得する

を翻訳すると、

ユーザー情報を取得する
----- 翻訳: ja -> en -----
Get user information
--------------------------

のように英語へ翻訳されます。

このため、マクロを切り替えたり設定を書き換えたりすることなく、日本語と英語を相互に翻訳できます。

4. 翻訳する言語を固定する場合 編集

翻訳元の言語を固定したい場合は、sourceLanguage を変更します。

例えば、英語から日本語への翻訳に固定する場合は次のように設定します。

sourceLanguage: "en",
targetLanguage: "ja",

この場合、言語の自動判定や逆方向への翻訳は行われません。

翻訳元を自動判定する場合は、次のいずれかを指定します。

sourceLanguage: "auto",

または、

sourceLanguage: "",

翻訳先を変更したい場合は targetLanguage を変更します。

例えば、中国語へ翻訳する場合は、

targetLanguage: "zh-CN",

のように設定できます。

5. 変数名を翻訳する場合 編集

本マクロでは、プログラムで使用される変数名やメソッド名を翻訳しやすくするため、単独の識別子を自動的に単語へ分割する機能があります。

例えば、

currentUserName

を翻訳すると、内部的には、

current User Name

として Google 翻訳へ送信されます。

同様に、

current_user_name

や、

current-user-name

も、

current user name

として翻訳されます。

次のような形式に対応しています。

  • camelCase
  • PascalCase
  • snake_case
  • kebab-case
  • XMLHttpRequest のように大文字が連続する識別子

例えば、ソースコード中に、

const requestTimeoutSec = 10;

という変数があり、requestTimeoutSec の意味を確認したい場合、その部分だけを選択してマクロを実行できます。

通常の英文や文章については、この変換は行われません。

6. 翻訳結果の表示形式 編集

翻訳結果は、元の文章の直下に次の形式で挿入されます。

----- 翻訳: 翻訳元言語 -> 翻訳先言語 -----
翻訳結果
------------------------------------------

例えば英語から日本語へ翻訳した場合は、

----- 翻訳: en -> ja -----
翻訳された文章
--------------------------

と表示されます。

翻訳元の言語は Google 翻訳による自動判定結果が使用されます。

複数行の文章を翻訳した場合も、翻訳結果全体をヘッダーとフッターで囲んだ状態で挿入します。

翻訳後は、挿入された翻訳結果の次の行へカーソルが移動するため、そのまま文章の編集を続けられます。

7. エラーが発生した場合 編集

通信エラーなどが発生した場合は、翻訳結果を挿入せず、エラーメッセージを表示します。

主に次のようなエラーを検出します。

  • Google 翻訳への接続がタイムアウトした
  • curl.exe の実行に失敗した
  • Google 翻訳から空の応答が返された
  • Google 翻訳から HTML のエラーページが返された
  • Google 翻訳から想定していない形式のデータが返された
  • Google 翻訳のレート制限に達した
  • 翻訳結果が空だった

通信のタイムアウト時間は CONFIG.timeoutSec で変更できます。

例えば、20 秒に変更する場合は次のように設定します。

timeoutSec: 20,

通常は標準値の 10 秒のままで問題ありません。

ソースコード 編集

#language = "quickjs"
#title = "Mery 翻訳"
/**
 * Mery 翻訳マクロ
 *
 * Traduzir-paginas-web と同じ Google 翻訳クライアント API
 * (POST https://translate-pa.googleapis.com/v1/translateHtml) を、
 * shell.Exec + curl.exe で呼び出して翻訳します。
 *
 * API キーは Google 配信 JS から x-goog-api-key を抽出し、
 * Window.Tag に最大 20 分キャッシュします。翻訳結果はキャッシュしません。
 *
 * 使い方:
 *   1. 翻訳したい文字列を選択(未選択ならカーソル行を選択)
 *   2. このマクロを実行
 *
 * 必要環境:
 *   - Mery Ver 3.8.8 以降(左記バージョンにて動作確認)
 *   - PATH 上の curl.exe(Windows 10 以降に標準搭載)
 *
 * 設定は下の CONFIG を編集してください。
 * display は複数同時に true にできます(組み合わせ可)。
 */

const CONFIG = {
  /** 翻訳元言語。空文字または "auto" で自動判定 */
  sourceLanguage: "auto",
  /** 翻訳先言語 */
  targetLanguage: "ja",
  /** 検出言語が targetLanguage と同じときの翻訳先 */
  reverseLanguage: "en",
  /** curl のタイムアウト(秒) */
  timeoutSec: 10,

  /**
   * 翻訳結果の表示方法(複数同時に有効化可)
   * - editor: エディターへ挿入(選択範囲の直下)
   * - outputBar: アウトプットバーへ出力
   * - tip: ツールチップで表示
   */
  display: {
    editor: false,
    outputBar: true,
    tip: false,
  },

  /**
   * アウトプットバー出力時に自動で表示するか
   * true で表示、false で表示しない
   */
  outputBarVisible: true,

  /**
   * tip 表示時の位置フラグ
   * meShowTipPosCaret (キャレット位置) | meShowTipPosMouse (マウス位置)
   */
  tipFlags: meShowTipPosCaret,
};

const GOOGLE_TRANSLATE_ENDPOINT =
  "https://translate-pa.googleapis.com/v1/translateHtml";

const GOOGLE_AUTH_URL =
  "https://translate.googleapis.com/_/translate_http/_/js/k=translate_http.tr.en_US.YusFYy3P_ro.O/am=AAg/d=1/exm=el_conf/ed=1/rs=AN8SPfq1Hb8iJRleQqQc8zhdzXmF9E56eQ/m=el_main";

const GOOGLE_API_KEY_TTL_MS = 20 * 60 * 1000;
const GOOGLE_API_KEY_PATTERN = /^\w{39}$/;
const GOOGLE_API_KEY_TAG = "meryTranslate.apiKey";
const GOOGLE_API_KEY_FETCHED_AT_TAG = "meryTranslate.fetchedAt";
// Mery の shell.Exec は stdout の改行をスペースにすることがあるため、
// curl -w の区切りには改行を使わない。
const CURL_HTTP_CODE_MARKER = "__MERY_HTTP_CODE__";
/** 空白を除いた文字のうち、この割合以上が日本語なら API 判定前に逆翻訳する */
const JAPANESE_CHAR_RATIO_THRESHOLD = 0.3;

const sel = document.selection;

const console = {
  log: (message) => {
    outputBar.Writeln(message);
  },
};

main();

function main() {
  let keepResultTip = false;
  try {
    const target = resolveTranslateTarget();
    if (!target) {
      Status = "翻訳するテキストがありません";
      return;
    }

    if (!hasAnyDisplayTarget()) {
      throw new Error(
        "CONFIG.display のいずれか(editor / outputBar / tip)を true にしてください。"
      );
    }

    let tipMessage = "";
    if (sel.IsEmpty) {
      tipMessage = "この行を翻訳中...";
    } else {
      tipMessage = "選択範囲を翻訳中...";
    }

    ShowTip(tipMessage, meShowTipPosCaret);
    Status = "翻訳中…";

    const outcome = runTranslation(target.text);
    keepResultTip = presentTranslation(
      outcome.translatedText,
      outcome.sourceLanguage,
      outcome.targetLanguage
    );

    Status = "翻訳完了";
  } catch (error) {
    const message =
      error && error.message ? error.message : String(error);
    Status = "";
    alert("Translator: " + message);
  } finally {
    // tip 結果表示を残す場合は非表示にしない
    if (!keepResultTip) {
      ShowTip("", meShowTipHide);
    }
  }
}

function hasAnyDisplayTarget() {
  const display = CONFIG.display || {};
  return !!(display.editor || display.outputBar || display.tip);
}

/**
 * CONFIG.display に従って翻訳結果を出力する。
 * tip を使った場合は true を返し、進捗チップの非表示を抑止する。
 */
function presentTranslation(translatedText, sourceLanguage, targetLanguage) {
  const display = CONFIG.display || {};
  const block = formatTranslationBlock(
    translatedText,
    sourceLanguage,
    targetLanguage
  );

  if (display.editor) {
    insertTranslationBelow(translatedText, sourceLanguage, targetLanguage);
  }

  if (display.outputBar) {
    writeTranslationToOutputBar(block);
  }

  if (display.tip) {
    const flags =
      CONFIG.tipFlags == null ? meShowTipPosCaret : CONFIG.tipFlags;
    ShowTip(
      formatTranslationTip(translatedText, sourceLanguage, targetLanguage),
      flags
    );
    return true;
  }

  return false;
}

function formatTranslationBlock(translatedText, sourceLanguage, targetLanguage) {
  const header =
    "----- 翻訳: " +
    sourceLanguage +
    " -> " +
    targetLanguage +
    " -----";
  const footer = repeatChar("-", header.length);
  return {
    header: header,
    footer: footer,
    translatedText: translatedText,
    full: header + "\n" + translatedText + "\n" + footer,
  };
}

/** ツールチップ用の短い表示形式 */
function formatTranslationTip(translatedText, sourceLanguage, targetLanguage) {
  return (
    "[翻訳: " +
    sourceLanguage +
    " -> " +
    targetLanguage +
    "]\n" +
    translatedText
  );
}

function writeTranslationToOutputBar(block) {
  // Visible は CONFIG.outputBarVisible が true のときだけ立てる
  if (CONFIG.outputBarVisible) {
    OutputBar.Visible = true;
  }
  OutputBar.Writeln(block.full);
}

/**
 * 選択があればそれを使う。未選択ならカーソル行を選択してから返す。
 */
function resolveTranslateTarget() {
  if (!sel.IsEmpty) {
    const text = normalizeSelectionText(sel.Text);
    if (!text.trim()) {
      return null;
    }
    return { text: text };
  }

  const y = sel.GetActivePointY(mePosLogical);
  const line = document.GetLine(y, 0).trim();
  if (!line || !String(line).trim()) {
    return null;
  }

  return { text: normalizeSelectionText(line) };
}

function normalizeSelectionText(text) {
  return String(text).replace(/\r\n$/, "").replace(/\n$/, "").replace(/\r$/, "");
}

function runTranslation(rawText) {
  const sourceLanguage = normalizeSourceLanguage(CONFIG.sourceLanguage);
  const text = prepareTextForTranslation(rawText);

  if (shouldSkipToReverseTranslation(text, sourceLanguage)) {
    const reversed = translate(text, "auto", CONFIG.reverseLanguage);
    return {
      translatedText: reversed.text,
      sourceLanguage: reversed.detectedLanguage || "ja",
      targetLanguage: CONFIG.reverseLanguage,
    };
  }

  const first = translate(text, sourceLanguage, CONFIG.targetLanguage);
  let translatedText = first.text;
  let direction = resolveTranslationDirection(first.detectedLanguage);

  if (needsReverseTranslation(first.detectedLanguage)) {
    const second = translate(text, "auto", CONFIG.reverseLanguage);
    translatedText = second.text;
    direction = {
      sourceLanguage:
        second.detectedLanguage || first.detectedLanguage || "auto",
      targetLanguage: CONFIG.reverseLanguage,
    };
  }

  return {
    translatedText: translatedText,
    sourceLanguage: direction.sourceLanguage,
    targetLanguage: direction.targetLanguage,
  };
}

function translate(text, sourceLanguage, targetLanguage) {
  return translateOnce(text, sourceLanguage, targetLanguage, false);
}

function translateOnce(text, sourceLanguage, targetLanguage, isRetry) {
  const apiKey = getGoogleApiKey(isRetry);
  try {
    return executeGoogleTranslateRequest(
      text,
      sourceLanguage,
      targetLanguage,
      apiKey
    );
  } catch (error) {
    if (!isRetry && error && error.isAuthError) {
      clearGoogleApiKeyCache();
      return translateOnce(text, sourceLanguage, targetLanguage, true);
    }
    throw error;
  }
}

function getGoogleApiKey(forceRefresh) {
  if (!forceRefresh) {
    const cached = getCachedGoogleApiKey();
    if (cached) {
      return cached;
    }
  }

  const fetched = fetchGoogleApiKey();
  if (!isValidGoogleApiKey(fetched)) {
    throw new Error("Google Translate の認証情報を取得できませんでした。");
  }
  cacheGoogleApiKey(fetched);
  return fetched;
}

function getCachedGoogleApiKey() {
  const store = getWindowTagStore();
  if (!store) {
    return "";
  }
  if (
    !tagExists(store, GOOGLE_API_KEY_TAG) ||
    !tagExists(store, GOOGLE_API_KEY_FETCHED_AT_TAG)
  ) {
    return "";
  }

  const apiKey = String(store[GOOGLE_API_KEY_TAG] || "");
  const fetchedAt = Number(store[GOOGLE_API_KEY_FETCHED_AT_TAG]);
  if (!isValidGoogleApiKey(apiKey) || !isFinite(fetchedAt)) {
    return "";
  }
  if (Date.now() - fetchedAt >= GOOGLE_API_KEY_TTL_MS) {
    return "";
  }
  return apiKey;
}

function cacheGoogleApiKey(apiKey) {
  const store = getWindowTagStore();
  if (!store) {
    return;
  }
  store[GOOGLE_API_KEY_TAG] = apiKey;
  store[GOOGLE_API_KEY_FETCHED_AT_TAG] = String(Date.now());
}

function clearGoogleApiKeyCache() {
  const store = getWindowTagStore();
  if (!store) {
    return;
  }
  tagRemove(store, GOOGLE_API_KEY_TAG);
  tagRemove(store, GOOGLE_API_KEY_FETCHED_AT_TAG);
}

function fetchGoogleApiKey() {
  const fallbackKey = getGoogleFallbackApiKey();
  try {
    const response = executeCurl([
      "-sS",
      "-A",
      quoteWindowsArgument("Mozilla/5.0"),
      "--max-time",
      String(CONFIG.timeoutSec),
      quoteWindowsArgument(GOOGLE_AUTH_URL),
    ]);
    if (response.exitCode === 0 && response.stdout) {
      const extracted = extractGoogleApiKey(response.stdout);
      if (isValidGoogleApiKey(extracted)) {
        return extracted;
      }
    }
  } catch (error) {
    // ネットワークエラーでもフォールバックキーを使う
  }
  return fallbackKey;
}

function extractGoogleApiKey(jsText) {
  const match = String(jsText).match(
    /['"]x\-goog\-api\-key['"]\s*:\s*['"](\w{39})['"]/i
  );
  return match && match[1] ? match[1] : "";
}

function getGoogleFallbackApiKey() {
  const codes = [
    65, 73, 122, 97, 83, 121, 65, 84, 66, 88, 97, 106, 118, 122, 81, 76, 84,
    68, 72, 69, 81, 98, 99, 112, 113, 48, 73, 104, 101, 48, 118, 87, 68, 72,
    109, 79, 53, 50, 48,
  ];
  let key = "";
  for (let i = 0; i < codes.length; i++) {
    key += String.fromCharCode(codes[i]);
  }
  return key;
}

function isValidGoogleApiKey(apiKey) {
  return GOOGLE_API_KEY_PATTERN.test(String(apiKey || ""));
}

function buildGoogleRequestBody(text, sourceLanguage, targetLanguage) {
  const wrapped = "<pre>" + escapeHtml(text) + "</pre>";
  return JSON.stringify([
    [[wrapped], normalizeSourceLanguage(sourceLanguage), targetLanguage],
    "te",
  ]);
}

function executeGoogleTranslateRequest(
  text,
  sourceLanguage,
  targetLanguage,
  apiKey
) {
  const body = buildGoogleRequestBody(text, sourceLanguage, targetLanguage);
  const response = executeCurl([
    "-sS",
    "-A",
    quoteWindowsArgument("Mozilla/5.0"),
    "--max-time",
    String(CONFIG.timeoutSec),
    "-X",
    "POST",
    "-H",
    quoteWindowsArgument("Content-Type: application/json+protobuf"),
    "-H",
    quoteWindowsArgument("X-goog-api-key: " + apiKey),
    "--data-binary",
    quoteWindowsArgument(body),
    "-w",
    quoteWindowsArgument(CURL_HTTP_CODE_MARKER + "%{http_code}"),
    quoteWindowsArgument(GOOGLE_TRANSLATE_ENDPOINT),
  ]);

  if (response.exitCode === 28) {
    throw new Error(
      "Google Translate への接続がタイムアウトしました(" +
        CONFIG.timeoutSec +
        " 秒)。"
    );
  }

  if (response.exitCode !== 0) {
    throw new Error(
      "curl が失敗しました (exit " +
        response.exitCode +
        ")。" +
        (response.stderr ? "\n" + String(response.stderr).trim() : "")
    );
  }

  const parsedHttp = splitCurlHttpResponse(response.stdout);
  const statusCode = parsedHttp.statusCode;
  const raw = parsedHttp.body;

  if (isGoogleAuthError(statusCode, raw)) {
    throw createAuthError("Google Translate の認証に失敗しました。");
  }

  if (statusCode === 429 || raw.indexOf("429") === 0) {
    throw new Error("Google Translate のレート制限に達しました。");
  }

  if (statusCode >= 500 && statusCode <= 599) {
    throw new Error("Google Translate で一時的なエラーが発生しました。");
  }

  if (!raw) {
    throw new Error("Google Translate から空の応答が返されました。");
  }

  if (raw.charAt(0) === "<") {
    console.log(raw.slice(0, 180));
    throw new Error(
      "Google Translate が HTML エラーを返しました(リクエストが拒否された可能性があります)。"
    );
  }

  if (statusCode && statusCode !== 200) {
    throwInvalidGoogleResponse(raw);
  }

  let data;
  try {
    data = JSON.parse(raw);
  } catch (error) {
    throwInvalidGoogleResponse(raw);
  }

  const result = parseGoogleTranslateHtmlResponse(data);
  if (!result.text.trim()) {
    throw new Error("翻訳結果が空でした。");
  }
  return result;
}

function parseGoogleTranslateHtmlResponse(data) {
  if (!Array.isArray(data) || !Array.isArray(data[0])) {
    throwInvalidGoogleResponse(JSON.stringify(data));
  }

  const html = data[0][0];
  if (typeof html !== "string") {
    throwInvalidGoogleResponse(JSON.stringify(data));
  }

  let detectedLanguage;
  if (Array.isArray(data[1]) && typeof data[1][0] === "string") {
    const detected = data[1][0].trim();
    if (detected) {
      detectedLanguage = detected;
    }
  }

  return {
    text: unwrapGoogleTranslatedHtml(html),
    detectedLanguage: detectedLanguage,
  };
}

function unwrapGoogleTranslatedHtml(html) {
  let result = String(html);
  if (result.indexOf("<pre") !== -1) {
    result = result.replace(/<\/pre>/i, "");
    const gt = result.indexOf(">");
    if (gt !== -1) {
      result = result.slice(gt + 1);
    }
  }

  const sentences = [];
  let idx = 0;
  while (true) {
    const sentenceStartIndex = result.indexOf("<b>", idx);
    if (sentenceStartIndex === -1) {
      break;
    }
    const sentenceFinalIndex = result.indexOf("<i>", sentenceStartIndex);
    if (sentenceFinalIndex === -1) {
      sentences.push(result.slice(sentenceStartIndex + 3));
      break;
    }
    sentences.push(result.slice(sentenceStartIndex + 3, sentenceFinalIndex));
    idx = sentenceFinalIndex;
  }
  if (sentences.length > 0) {
    result = sentences.join(" ");
  }

  result = result.replace(/<\/b>/g, "");
  result = result.replace(/<a\s+i=\d+>/g, "").replace(/<\/a>/g, "");
  return unescapeHtml(result);
}

function escapeHtml(text) {
  return String(text)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function unescapeHtml(text) {
  return String(text)
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&amp;/g, "&");
}

function executeCurl(args) {
  const cmd = ["curl.exe"].concat(args).join(" ");
  const exec = shell.Exec(cmd, "", true);
  const exitCode = Number(
    exec.ExitCode != null ? exec.ExitCode : exec.exitCode
  );
  const stdout = exec.StdOut != null ? exec.StdOut : exec.stdOut;
  const stderr = exec.StdErr != null ? exec.StdErr : exec.stdErr;
  return {
    exitCode: exitCode,
    stdout: String(stdout == null ? "" : stdout).replace(/^\uFEFF/, ""),
    stderr: String(stderr == null ? "" : stderr),
  };
}

function splitCurlHttpResponse(raw) {
  const text = String(raw == null ? "" : raw).replace(/^\uFEFF/, "");
  const markerIndex = text.lastIndexOf(CURL_HTTP_CODE_MARKER);
  if (markerIndex !== -1) {
    const codeStr = text.slice(markerIndex + CURL_HTTP_CODE_MARKER.length).trim();
    const body = text.slice(0, markerIndex).trim();
    if (/^\d{3}$/.test(codeStr)) {
      return { body: body, statusCode: Number(codeStr) };
    }
  }

  // -w の改行がスペースになった場合の保険
  const fallback = text.match(/^([\s\S]*?)(?:\s|\r|\n)+(\d{3})\s*$/);
  if (fallback && fallback[1] !== "") {
    return { body: fallback[1].trim(), statusCode: Number(fallback[2]) };
  }

  const trimmed = text.trim();
  if (/^\d{3}$/.test(trimmed)) {
    return { body: "", statusCode: Number(trimmed) };
  }
  return { body: trimmed, statusCode: 0 };
}

function isGoogleAuthError(statusCode, body) {
  if (statusCode === 401 || statusCode === 403) {
    return true;
  }
  if (statusCode !== 400) {
    return false;
  }
  const text = String(body || "");
  return (
    text.indexOf("API_KEY_INVALID") !== -1 ||
    text.indexOf("API key not valid") !== -1 ||
    text.indexOf("API key expired") !== -1
  );
}

function createAuthError(message) {
  const error = new Error(message);
  error.isAuthError = true;
  return error;
}

function throwInvalidGoogleResponse(raw) {
  const preview = String(raw == null ? "" : raw)
    .replace(/\s+/g, " ")
    .slice(0, 180);
  console.log(preview);
  throw new Error(
    "Google Translate が不正な JSON を返しました。\n" + preview
  );
}

function getWindowTagStore() {
  try {
    if (Tag && typeof Tag.exists === "function") {
      return Tag;
    }
  } catch (error) {
    return null;
  }
  return null;
}

function tagExists(store, key) {
  try {
    return !!(store && store.exists(key));
  } catch (error) {
    return false;
  }
}

function tagRemove(store, key) {
  if (!store || !tagExists(store, key)) {
    return;
  }
  try {
    if (typeof store.remove === "function") {
      store.remove(key);
    }
  } catch (error) {
    store[key] = "";
  }
}

function resolveTranslationDirection(detectedLanguage) {
  if (normalizeSourceLanguage(CONFIG.sourceLanguage) !== "auto") {
    return {
      sourceLanguage: CONFIG.sourceLanguage,
      targetLanguage: CONFIG.targetLanguage,
    };
  }

  const detected = detectedLanguage && detectedLanguage.trim();
  if (detected && languagesEqual(detected, CONFIG.targetLanguage)) {
    return {
      sourceLanguage: detected,
      targetLanguage: CONFIG.reverseLanguage,
    };
  }

  return {
    sourceLanguage: detected || "auto",
    targetLanguage: CONFIG.targetLanguage,
  };
}

function needsReverseTranslation(detectedLanguage) {
  if (normalizeSourceLanguage(CONFIG.sourceLanguage) !== "auto") {
    return false;
  }
  const detected = detectedLanguage && detectedLanguage.trim();
  if (!detected) {
    return false;
  }
  return languagesEqual(detected, CONFIG.targetLanguage);
}

/**
 * auto→ja のあと検出言語が ja なら en へ再翻訳している。
 * ひらがな・カタカナ・漢字の割合が高ければ、1 回目の API を省略する。
 */
function shouldSkipToReverseTranslation(text, sourceLanguage) {
  if (normalizeSourceLanguage(sourceLanguage) !== "auto") {
    return false;
  }
  if (!languagesEqual(CONFIG.targetLanguage, "ja")) {
    return false;
  }
  return looksLikeJapaneseText(text);
}

function looksLikeJapaneseText(text) {
  let japanese = 0;
  let total = 0;
  const s = String(text);
  for (let i = 0; i < s.length; i++) {
    const code = s.charCodeAt(i);
    if (code <= 32 || code === 127) {
      continue;
    }
    total += 1;
    if (isJapaneseCharCode(code)) {
      japanese += 1;
    }
  }
  if (total === 0) {
    return false;
  }
  return japanese / total >= JAPANESE_CHAR_RATIO_THRESHOLD;
}

function isJapaneseCharCode(code) {
  // ひらがな
  if (code >= 0x3040 && code <= 0x309f) {
    return true;
  }
  // カタカナ
  if (code >= 0x30a0 && code <= 0x30ff) {
    return true;
  }
  // CJK 統合漢字
  if (code >= 0x4e00 && code <= 0x9fff) {
    return true;
  }
  // 半角カタカナ
  if (code >= 0xff66 && code <= 0xff9d) {
    return true;
  }
  // CJK 記号・句読点(、。・など)
  if (code >= 0x3000 && code <= 0x303f) {
    return true;
  }
  return false;
}

function languagesEqual(a, b) {
  return a.trim().toLowerCase() === b.trim().toLowerCase();
}

function normalizeSourceLanguage(value) {
  const trimmed = String(value == null ? "" : value).trim();
  return trimmed === "" ? "auto" : trimmed;
}

/**
 * 単独の変数風識別子(camelCase / snake_case 等)なら単語に分割する。
 */
function prepareTextForTranslation(text) {
  const trimmed = text.trim();
  if (!isVariableStyleIdentifier(trimmed)) {
    return text;
  }

  const words = splitVariableIdentifier(trimmed);
  return words.length > 0 ? words.join(" ") : text;
}

function isVariableStyleIdentifier(text) {
  if (!text || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(text)) {
    return false;
  }

  return (
    text.includes("_") ||
    text.includes("-") ||
    /[a-z][A-Z]/.test(text) ||
    /[A-Z]{2,}[a-z]/.test(text)
  );
}

function splitVariableIdentifier(text) {
  return text
    .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
    .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
    .split(/[\s_-]+/)
    .filter(Boolean);
}

/**
 * 選択範囲の最終行末の直後へ、vscode-translator と同じ形式で挿入する。
 */
function insertTranslationBelow(translatedText, sourceLanguage, targetLanguage) {
  let endY = sel.GetBottomPointY(mePosLogical);
  const endX = sel.GetBottomPointX(mePosLogical);

  // 行全体選択で次行先頭 (x=1) に終わっている場合は前の行末へ
  if (endX === 1 && endY > sel.GetTopPointY(mePosLogical)) {
    endY = endY - 1;
  }

  sel.SetActivePoint(mePosLogical, 1, endY);
  sel.EndOfLine(false, mePosLogical);

  const block = formatTranslationBlock(
    translatedText,
    sourceLanguage,
    targetLanguage
  );
  const body = "\n" + block.full;

  // 文書末尾なら、フッター直後に空行を残せるよう末尾改行を付ける
  const lineCount = document.GetLines();
  const appendTrailingNewline = endY >= lineCount;
  sel.Text = appendTrailingNewline ? body + "\n" : body;

  // フッターの次の行先頭へカーソルを置く
  const translationLineCount = String(translatedText).split("\n").length;
  const cursorY = endY + 3 + translationLineCount;
  sel.SetActivePoint(mePosLogical, 1, cursorY, false);
}

function repeatChar(ch, count) {
  let out = "";
  for (let i = 0; i < count; i++) {
    out += ch;
  }
  return out;
}

/**
 * Windows の C ランタイムが解釈するコマンドライン引数用に引用する。
 * JSON の " や \ を含む --data-binary を安全に渡すために使う。
 */
function quoteWindowsArgument(value) {
  const s = String(value);
  if (s === "") {
    return '""';
  }
  if (!/[\s"]/.test(s)) {
    return s;
  }

  let result = '"';
  let numBackslashes = 0;
  for (let i = 0; i < s.length; i++) {
    const ch = s.charAt(i);
    if (ch === "\\") {
      numBackslashes += 1;
      continue;
    }
    if (ch === '"') {
      result += repeatChar("\\", numBackslashes * 2 + 1) + '"';
      numBackslashes = 0;
      continue;
    }
    result += repeatChar("\\", numBackslashes) + ch;
    numBackslashes = 0;
  }
  result += repeatChar("\\", numBackslashes * 2) + '"';
  return result;
}

更新履歴 編集

  • 2026-08-28 翻訳APIのエンドポイントおよび実行方式を変更しました
  • 2026-08-12 出力方法を設定できるようにしました
  • 2026-08-11 公開
スポンサーリンク