UNPKG

multranslate

Version:

Cross-platform TUI for translating text in multiple translators simultaneously and LLM, with support for translation history and automatic language detection.

687 lines (649 loc) 25.9 kB
#!/usr/bin/env node import blessed from 'blessed' import axios from 'axios' import clipboardy from 'clipboardy' var screen = blessed.screen({ autoPadding: true, smartCSR: true, // Добавить кастомный курсор Blessed cursor: { artificial: true, shape: { bg: 'white', fg: 'white', bold: true, ch: '' }, blink: false } }) // Панель для ввода текста const inputBox = blessed.textarea({ top: '0%', left: 'left', width: '100%', height: '20%', inputOnFocus: false, // Отключаем ввод текста для управления через TextBuffer scrollable: true, alwaysScroll: true, scrollbar: { inverse: true }, border: { type: 'line' }, style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, scrollbar: { bg: 'white' } } }) // Панель для отображения перевода от Google const outputBox1 = blessed.textarea({ label: `Google (Ctrl+Q)`, top: '20%', left: 'left', width: '50%', height: '40%', scrollable: true, alwaysScroll: true, scrollbar: { inverse: true }, border: { type: 'line' }, style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, scrollbar: { bg: 'white' } } }) // Панель для отображения перевода от DeepLX const outputBox2 = blessed.textarea({ label: `DeepL (Ctrl+W)`, top: '20%', right: 'right', left: '51%', width: '50%', height: '40%', scrollable: true, alwaysScroll: true, scrollbar: { inverse: true }, border: { type: 'line' }, style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, scrollbar: { bg: 'white' } } }) // Панель для отображения перевода от MyMemory const outputBox3 = blessed.textarea({ label: `MyMemory (Ctrl+E)`, top: '60%', left: 'left', width: '50%', height: '40%', scrollable: true, alwaysScroll: true, scrollbar: { inverse: true }, border: { type: 'line' }, style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, scrollbar: { bg: 'white' } } }) // Панель для отображения перевода от Reverso const outputBox4 = blessed.textarea({ label: `Reverso (Ctrl+R)`, top: '60%', right: 'right', left: '51%', width: '50%', height: '40%', scrollable: true, alwaysScroll: true, scrollbar: { inverse: true }, border: { type: 'line' }, style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, scrollbar: { bg: 'white' } } }) // Информация по навигации внизу формы const textInfo = blessed.text({ content: 'Ctrl+C: clear input, Ctrl+<⬆/⬇>: scroll output, Ctrl+<Q/W/E/R>: copy to clipboard, Escape: exit', // ⬅/➡: input navigation bottom: 0, left: 0, right: 0, align: 'center', style: { fg: 'blue', bg: 'black' } }) // Добавление панелей на экран screen.append(inputBox) screen.append(outputBox1) screen.append(outputBox2) screen.append(outputBox3) screen.append(outputBox4) screen.append(textInfo) // ------------------------------------- TextBuffer ------------------------------------- // Класс для управления текстовым буфером и курсором class TextBuffer { // Инициализация свойств объекта constructor() { // Содержимое буфера this.text = '' // Начальная позиция курсора this.cursorPosition = 0 } // Метод для перемещения курсора влево moveLeft() { // Проверяем, что курсор не находится в начале текста if (this.cursorPosition > 0) { this.cursorPosition-- } } // Метод для перемещения курсора вправо moveRight() { // Проверяем, что курсор не находится в конце текста if (this.cursorPosition < this.text.length) { this.cursorPosition++ } } // Метод для отображения перемещения курсора viewDisplayCursor(box) { this.navigateNativeCursor(box) return this.text } // Метод обновления позиции нативного курсора navigateNativeCursor(box) { // Определяем ширину формы для виртуального переноса строки // const maxWidth = process.stdout.columns - 4 const maxWidth = box.width - 4 // Разбиваем текст на массив из строк let linesArray = this.text.split('\r') // Массив для хранения строк с учетом переноса // Массив для хранения строк с учетом переноса let wrappedLines = [] // Массив для хранения информации о том, был ли перенос строки виртуальным let isVirtualWrap = [] // Разбиваем каждую строку на виртуальные строки по ширине box for (const line of linesArray) { let remainingLine = line // Разбиваем строку на части, если длина превышает максимальную ширину формы while (remainingLine.length > maxWidth) { wrappedLines.push(remainingLine.slice(0, maxWidth)) isVirtualWrap.push(true) // Помечаем, что это был виртуальный перенос remainingLine = remainingLine.slice(maxWidth) } // Добавлять остаток строки wrappedLines.push(remainingLine) isVirtualWrap.push(false) // Это остаток строки, и здесь может быть настоящий перенос } // Определяем строку, в которой находится курсор буфера (cursorPosition) let currentLine = 0 let totalChars = 0 for (let i in wrappedLines) { // Если строка была перенесена виртуально, не добавлять +1 за символ новой строки let lineLength = wrappedLines[i].length if (!isVirtualWrap[i]) { lineLength += 1 // +1 для символа \r только для реальных переносов } totalChars += lineLength // Если позиция курсора попадает в текущую строку if (this.cursorPosition < totalChars) { currentLine = Number(i) break } } // Рассчитываем позицию курсора в пределах текущей строки let charPositionInLine = this.cursorPosition - (totalChars - wrappedLines[currentLine].length) if (isVirtualWrap[currentLine]) { // Для виртуальных строк (перенесенных) не нужно учитывать символ новой строки charPositionInLine = this.cursorPosition - (totalChars - wrappedLines[currentLine].length) } else { // Для реальных строк добавить -1, чтобы скорректировать расчёт charPositionInLine = this.cursorPosition - (totalChars - wrappedLines[currentLine].length - 1) } // Если позиция вышла за пределы строки, корректируем её if (charPositionInLine < 0) { currentLine-- charPositionInLine = wrappedLines[currentLine].length + charPositionInLine } // Узнаем максимальное количество отображаемых строк формы const maxLine = box.height - 3 const bottomVisibleLine = maxLine + maxLine - 2 // Скроллим вверх вверх или вниз if (currentLine < maxLine) { box.scrollTo(Math.max(0, currentLine)) } else if (currentLine > bottomVisibleLine) { box.scrollTo(currentLine - maxLine + 1) } // Синхронизируем нативный курсор с текущей позицией const visibleBase = box.childBase || 0 const adjustedLine = currentLine - visibleBase // Ограничиваем текущую строку в пределах видимой области if (adjustedLine >= maxLine) { currentLine = maxLine - 1 } // Добавляем отступы по умолчанию (по два сверху и слева), чтобы курсор не выходил за пределы формы const line = adjustedLine + 2 const char = charPositionInLine + 2 // Перемещаем нативный курсор process.stdout.write(`\x1B[${line};${char}H`) return } // Метод отключения нативного курсора disableNativeCursor() { process.stdout.write('\x1B[?25l') } // Метод Включения нативного курсора enableNativeCursor() { process.stdout.write('\x1B[?25h') } // Метод получения текущей позиции курсора getCursorPosition() { return this.cursorPosition } // Метод изменения текущей позиции курсора setCursorPosition(int) { return this.cursorPosition = int } // Метод получения содержимого текста из буфера getText() { return this.text } // Метод для изменения (перезаписи) текста в буфер setText(newText) { this.text = newText // Корректируем позицию курсора, чтобы она не выходила за пределы нового текста this.cursorPosition = Math.min(this.cursorPosition, this.text.length) } } // Создаем экземпляр класса const buffer = new TextBuffer() // Отключить магиние нативного курсора process.stdout.write('\x1B[?12l') // Скрыть нативный курсор терминала для использования только кастомного курсора // buffer.disableNativeCursor() // Обновляем поле ввода текста для имитации мигания кастомного курсора или смена фокуса при перемещении для нативного курсора setInterval( () => { inputBox.setValue(buffer.viewDisplayCursor(inputBox)) screen.render() }, 1 // 1 для нативного курсора или 500 для кастомного курсора ) // Обработка нажатий клавиш для управления буфером inputBox.on('keypress', function (ch, key) { // Назначить методы перемещения курсора на стрелочки if (key.name === 'left') { buffer.moveLeft() } else if (key.name === 'right') { buffer.moveRight() } else if (key.name === 'backspace') { // Проверяем, что курсор не находится в начале содержимого буфера if (buffer.getCursorPosition() > 0) { // Извлекаем текст с первого (нулевого) индекса по порядковый номер положения курсора без последней буквы для ее удаления (-1) и добавляем остаток после курсора до конца содержимого буфера const newText = buffer.getText().slice(0, buffer.getCursorPosition() - 1) + buffer.getText().slice(buffer.getCursorPosition()) // Перемещаем курсор влево после удаления символа buffer.moveLeft() // Обновляем текст в буфере buffer.setText(newText) } } else if (key.name === 'delete') { // Проверяем, что курсор не находится в конце содержимого буфера if (buffer.getCursorPosition() < buffer.getText().length) { const newText = buffer.getText().slice(0, buffer.getCursorPosition()) + buffer.getText().slice(buffer.getCursorPosition() + 1) buffer.setText(newText) } } // Переопределяем нажатие Enter, не добавляя дополнительный символ переноса строки else if (key.name === 'enter') { const newText = buffer.getText() buffer.setText(newText) } // Если нажата любая другая клавиша и она не пустая, добавляем символ в текст else if (ch) { // Обновляем текст, добавляя символом следом за текущей позицией курсора const newText = buffer.getText().slice(0, buffer.getCursorPosition()) + ch + buffer.getText().slice(buffer.getCursorPosition()) // Устанавливаем новый текст в буфер buffer.setText(newText) // Перемещаем курсор вправо после добавления символа buffer.moveRight() } // Обновляем поле ввода текста inputBox.setValue(buffer.viewDisplayCursor(inputBox)) screen.render() }) // Перевести курсор в самое начало или конец текст inputBox.key(['C-left', 'C-right'], function(ch, key) { // Скроллим вверх if (key.name === 'left') { buffer.setCursorPosition(0) } // Скроллим вниз else if (key.name === 'right') { buffer.setCursorPosition(buffer.getText().length) } }) // -------------------------------------------------------------------------------------- // Функция определения исходного языка function detectFromLanguage(text) { const russianPattern = /[а-яА-Я]/g const englishPattern = /[a-zA-Z]/g const russianMatches = text.match(russianPattern) || [] const englishMatches = text.match(englishPattern) || [] const russianCount = russianMatches.length const englishCount = englishMatches.length if (russianCount >= englishCount) { return 'ru' } else if (russianCount <= englishCount) { return 'en' } else { return '' } } // Функция определения целевого языка function detectToLanguage(lang) { if (lang === 'ru') { return 'en' } else if (lang === 'en') { return 'ru' } else { return '' } } // ----------------------------------- API functions ------------------------------------ // Функция перевода через Google API // https://github.com/matheuss/google-translate-api // Source: https://github.com/olavoparno/translate-serverless-vercel async function translateGoogle(text) { const fromLang = detectFromLanguage(text) const toLang = detectToLanguage(fromLang) const apiUrl = 'https://translate-serverless.vercel.app/api/translate' try { const response = await axios.post(apiUrl, { timeout: 3000, message: text, from: fromLang, to: toLang }, { headers: { 'Content-Type': 'application/json' } }) return response.data.translation.trans_result.dst } catch (error) { return error.message } } // Функция перевода через DeepLX API // https://github.com/OwO-Network/DeepLX // https://github.com/LegendLeo/deeplx-serverless // Source: https://github.com/bropines/Deeplx-vercel async function translateDeepLX(text) { const fromLang = detectFromLanguage(text) const toLang = detectToLanguage(fromLang) const apiUrl = 'https://deeplx-vercel-phi.vercel.app/api/translate' try { const response = await axios.post(apiUrl, { timeout: 3000, text: text, source_lang: fromLang, target_lang: toLang }, { headers: { 'Content-Type': 'application/json' } }) return response.data.data } catch (error) { return error.message } } // Функция перевода через MyMemory API // Source: https://mymemory.translated.net/doc/spec.php async function translateMyMemory(text) { const fromLang = detectFromLanguage(text) const toLang = detectToLanguage(fromLang) const apiUrl = 'https://api.mymemory.translated.net/get' try { const response = await axios.get(apiUrl, { timeout: 3000, params: { q: text, langpair: `${fromLang}|${toLang}` } }) return response.data.responseData.translatedText } catch (error) { return error.message } } // Функция перевода через Reverso API (ошибка: Parse Error: Invalid header value char) async function translateReverso(text) { const fromLang = detectFromLanguage(text) const toLang = detectToLanguage(fromLang) const apiUrl = 'https://api.reverso.net/translate/v1/translation' try { const response = await axios.post(apiUrl, { format: 'text', from: fromLang, to: toLang, input: text, options: { sentenceSplitter: true, origin: 'translation.web', contextResults: true, languageDetection: true } }, { headers: { 'Content-Type': 'application/json' }, }) return response.data.translation.join('') } catch (error) { return error.message } } // Функция перевода через Reverso API с использованием Fetch async function translateReversoFetch(text) { const fromLang = detectFromLanguage(text) const toLang = detectToLanguage(fromLang) const apiUrl = 'https://api.reverso.net/translate/v1/translation' // Создаем Promise для timeout в 3 секунды const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timed out')), 3000) ) try { // Выполняем запрос и применяем тайм-аут const response = await Promise.race([ fetch(apiUrl, { method: 'POST', body: JSON.stringify({ format: 'text', from: fromLang, to: toLang, input: text, options: { sentenceSplitter: true, origin: 'translation.web', contextResults: true, languageDetection: true } }), headers: { 'content-type': 'application/json' } }), timeoutPromise ]) const data = await response.json() return data.translation.join('') } catch (error) { return error.message } } // Функция обработки перевода async function handleTranslation() { // Заменяем символ возврата каретки на перенос строки без экранирования const textToTranslate = buffer.getText().trim().replace(/\r/g, '\n') if (textToTranslate) { const [ translatedText1, translatedText2, translatedText3, translatedText4 ] = await Promise.all([ translateGoogle(textToTranslate), translateDeepLX(textToTranslate), translateMyMemory(textToTranslate), translateReversoFetch(textToTranslate) ]) outputBox1.setContent(translatedText1) outputBox2.setContent(translatedText2) outputBox3.setContent(translatedText3) outputBox4.setContent(translatedText4) screen.render() // Вернуть фокус на inputBox inputBox.focus() } } // -------------------------------------------------------------------------------------- // Обработка нажатия Enter для перевода текста вместе с переносом на новую строку inputBox.key(['enter'], async () => { // Debug (отключить вызов функций перевода для отладки интерфейса) await handleTranslation() }) // Обработка вставка текста из буфера обмена в поле ввода inputBox.key(['C-v'], function() { clipboardy.read().then(text => { // Обновляем экранирование переноса строки для фиксации при перемещении нативного курсора text = text.replace(/\n/g, '\r') // Добавляем текст из буфера обмена к текущему тексту buffer.setText(buffer.getText() + text) // Перемещаем курсор в конец текста buffer.cursorPosition = buffer.getText().length inputBox.setValue(buffer.viewDisplayCursor(inputBox)) screen.render() }) }) // Обработка копирования вывода в буфер обмена inputBox.key(['C-q'], function() { const textToCopy = outputBox1.getContent() clipboardy.writeSync(textToCopy) outputBox1.style.border.fg = 'green' outputBox2.style.border.fg = 'blue' outputBox3.style.border.fg = 'blue' outputBox4.style.border.fg = 'blue' screen.render() inputBox.focus() }) inputBox.key(['C-w'], function() { const textToCopy = outputBox2.getContent() clipboardy.writeSync(textToCopy) outputBox1.style.border.fg = 'blue' outputBox2.style.border.fg = 'green' outputBox3.style.border.fg = 'blue' outputBox4.style.border.fg = 'blue' screen.render() inputBox.focus() }) inputBox.key(['C-e'], function() { const textToCopy = outputBox3.getContent() clipboardy.writeSync(textToCopy) outputBox1.style.border.fg = 'blue' outputBox2.style.border.fg = 'blue' outputBox3.style.border.fg = 'green' outputBox4.style.border.fg = 'blue' screen.render() inputBox.focus() }) inputBox.key(['C-r'], function() { const textToCopy = outputBox4.getContent() clipboardy.writeSync(textToCopy) outputBox1.style.border.fg = 'blue' outputBox2.style.border.fg = 'blue' outputBox3.style.border.fg = 'blue' outputBox4.style.border.fg = 'green' screen.render() inputBox.focus() }) // Обработчик событий клавиш для пролистывания экрана панелей вывода: Ctrl+<Up/Down> inputBox.key(['C-up', 'C-down'], function(ch, key) { // Скроллим вверх if (key.name === 'up') { outputBox1.scroll(-1) outputBox2.scroll(-1) outputBox3.scroll(-1) outputBox4.scroll(-1) } // Скроллим вниз else if (key.name === 'down') { outputBox1.scroll(1) outputBox2.scroll(1) outputBox3.scroll(1) outputBox4.scroll(1) } }) // Обработка очистки экрана inputBox.key(['C-c'], function () { buffer.setText("") inputBox.setValue(buffer.viewDisplayCursor(inputBox)) screen.render() }) // Обработка выхода inputBox.key(['escape'], function () { return process.exit(0) }) // Отображение интерфейса screen.render() // Установить фокус на поле ввода inputBox.focus()