UNPKG

multranslate

Version:

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

1,346 lines (1,288 loc) 75.4 kB
#!/usr/bin/env node import blessed from 'blessed' import axios from 'axios' import clipboardy from 'clipboardy' import Database from 'better-sqlite3' import path from 'path' import { fileURLToPath } from 'url' import { readFileSync } from 'fs' import { writeFileSync } from 'fs' import { Command } from 'commander' // Определяем текущий путь для доступа к файлу БД, ключу OpenAI и конфигурации package const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const apiKeyPath = path.join(__dirname, 'openai-key.txt') // Читаем файл конфигурации для получения описания и версии приложения const pkg = JSON.parse(readFileSync(path.join(__dirname, 'package.json'), 'utf-8')) // Определяем статические параметры const languages = [ 'ru', // Russian (Русский) 'ja', // Japanese (Японский) 'zh', // Chinese (Китайский) 'ko', // Korean (Корейский) 'ar', // Arabic (Арабский) 'tr', // Turkish (Турецкий) 'uk', // Ukrainian (Украинский) 'sk', // Slovak (Словацкий) 'pl', // Polish (Польский) 'de', // German (Немецкий) 'fr', // French (Французский) 'it', // Italian (Итальянский) 'es', // Spanish (Испанский) 'el', // Greek (Греческий) 'hu', // Hungarian (Венгерский) 'nl', // Dutch (Нидерландский) 'sv', // Swedish (Шведский) 'ro', // Romanian (Румынский) 'cs', // Czech (Чешский) 'da', // Danish (Датский) 'pt', // Portuguese (Португальский) to 0.5.2 (#1) 'vi', // Vietnam (Вьетнамский) to 0.6.0 (#2) ] // Language default let selectedLanguage = 'ru' // Карта для определения языка const mapLanguages = { 'en': 'English', 'ru': 'Russian', 'ja': 'Japanese', 'zh': 'Chinese', 'ko': 'Korean', 'ar': 'Arabic', 'tr': 'Turkish', 'uk': 'Ukrainian', 'sk': 'Slovak', 'pl': 'Polish', 'de': 'German', 'fr': 'French', 'it': 'Italian', 'es': 'Spanish', 'el': 'Greek', 'hu': 'Hungarian', 'nl': 'Dutch', 'sv': 'Swedish', 'ro': 'Romanian', 'cs': 'Czech', 'da': 'Danish', 'pt': 'Portuguese', 'vi': 'Vietnam', } const translators = [ 'all', 'Google', 'DeepL', 'Reverso', 'MyMemory', 'OpenAI' ] // Translator default let selectedTranslator = 'all' // Режим ответов OpenAI (перевод или чат) let selectedModeOpenAI = "translate" // Обработка аргументов const program = new Command() program .description(pkg.description) .version(pkg.version) .option( '-l, --language <name>', `Select the language: ${languages.join(', ')} (default: "ru" or the environment "TRANSLATE_LANGUAGE")`, ) .option( '-t, --translator <name>', `Select the translator: ${translators.join(', ')}`, 'all' ) .option( '-k, --key <value>', 'API key parameter for OpenAI (high priority) or using the environment "OPENAI_API_KEY"' ) .option( '-u, --urlOpenai <url>', 'Url address for OpenAI, OpenRouter or local LLM API (default: "https://api.openai.com" or the environment "OPENAI_URL")' ) .option( '-m, --model <name>', 'Select the LLM model (default: "gpt-4o-mini" or the environment "OPENAI_MODEL")' ) .option( '-e, --temp <number>', 'Select the temperature for LLM (default: "0.7" or the environment "OPENAI_TEMP")' ) .parse(process.argv) // Language let inputLanguage = program.opts().language?.toLowerCase() if (!inputLanguage) { inputLanguage = process.env.TRANSLATE_LANGUAGE ? process.env.TRANSLATE_LANGUAGE : "ru" } const languagesLowerCase = languages.map(t => t.toLowerCase()) if (!languagesLowerCase.includes(inputLanguage)) { console.error(`Invalid parameter value. Choose one of: ${languages.join(', ')}`) process.exit(1) } selectedLanguage = languages[languagesLowerCase.indexOf(inputLanguage)] // Translator const inputTranslator = program.opts().translator.toLowerCase() const translatorsLowerCase = translators.map(t => t.toLowerCase()) if (!translatorsLowerCase.includes(inputTranslator)) { console.error(`Invalid parameter value. Choose one of: ${translators.join(', ')}`) process.exit(1) } selectedTranslator = translators[translatorsLowerCase.indexOf(inputTranslator)] // Проверяем параметры и переменные окружения для настройки подключения к OpenAI let apiKey = program.opts().key if (!apiKey) { apiKey = process.env.OPENAI_API_KEY } // Если не передан через параметр, то проверяем переменную окружения или определяем значение по умолчанию let urlOpenai = program.opts().urlOpenai if (!urlOpenai) { urlOpenai = process.env.OPENAI_URL ? process.env.OPENAI_URL : "https://api.openai.com" } let openaiModel = program.opts().model if (!openaiModel) { openaiModel = process.env.OPENAI_MODEL ? process.env.OPENAI_MODEL : "gpt-4o-mini" } let openaiTemp = program.opts().temp if (!openaiTemp) { openaiTemp = process.env.OPENAI_TEMP ? process.env.OPENAI_TEMP : "0.7" openaiTemp = parseFloat(openaiModel) } // Если ключ передан, сохраняем его в файл // if (apiKey) { // try { // writeFileSync(apiKeyPath, apiKey, { encoding: 'utf8' }) // } catch (error) { // console.error(`Error saving API key to file: ${error.message}`) // process.exit(1) // } // } // Если ключ не передан, загружаем его из файла // else { // try { // apiKey = readFileSync(apiKeyPath, 'utf8').trim() // } catch (error) { // if (selectedTranslator === 'OpenAI') { // console.error('API key not found.') // process.exit(1) // } // } // } // blessed => screen var screen = blessed.screen({ autoPadding: true, smartCSR: true, title: 'multranslate', // Добавить кастомный курсор Blessed cursor: { artificial: true, shape: { bg: 'white', fg: 'white', bold: true, ch: '' }, blink: false } }) // Панель для ввода текста const inputBox = blessed.textarea({ // label: `Input (Alt+C)`, top: '0%', width: '100%', height: '20%', inputOnFocus: false, // отключить ввод текста для управления через TextBuffer wrap: true, // false для отключения автоматического переноса слов (от 0 до 10 в конце строки после пробела) 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 (Alt+1)`, 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 (Alt+2)`, scrollable: true, alwaysScroll: true, scrollbar: { inverse: true }, border: { type: 'line' }, style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, scrollbar: { bg: 'white' } } }) // Панель для отображения перевода из Reverso const outputBox3 = blessed.textarea({ label: `Reverso (Alt+3)`, scrollable: true, alwaysScroll: true, scrollbar: { inverse: true }, border: { type: 'line' }, style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, scrollbar: { bg: 'white' } } }) // Панель для отображения перевода из MyMemory const outputBox4 = blessed.textarea({ label: `MyMemory (Alt+4)`, scrollable: true, alwaysScroll: true, scrollbar: { inverse: true }, border: { type: 'line' }, style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, scrollbar: { bg: 'white' } } }) // Панель для отображения перевода из OpenAI (#4) const outputBox5 = blessed.textarea({ top: '60%', left: '50.5%', width: '50%', height: '39%', scrollable: true, alwaysScroll: true, scrollbar: { inverse: true }, border: { type: 'line' }, style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, scrollbar: { bg: 'white' } } }) let infoContent = '\x1b[32mCtrl+S\x1b[37m: Translation. \x1b[32mF1\x1b[37m: Get help on Hotkeys.' // Информация по навигации внизу формы const infoBox = blessed.text({ content: infoContent, bottom: 0, left: 1, right: 0, align: 'center', style: { fg: 'blue', bg: 'black' } }) // Информация по горячим клавишам const hotkeysBox = blessed.box({ hidden: true, // Скрыть форму tags: true, // включить поддержку тегов для разметки top: 'center', left: 'center', right: 'center', width: '70%', height: '50%', border: { type: 'line', }, style: { fg: 'white', bg: 'gray', border: { fg: 'green', }, } }) hotkeysBox.setContent(` Hotkeys: {green-fg}Ctrl+<Enter/S>{/green-fg}: Translation of text without breaking to a new line {cyan-fg}Ctrl+V{/cyan-fg}: Pasting text from the clipboard {cyan-fg}Alt+C{/cyan-fg}: Copy text from the input field to clipboard {cyan-fg}Alt+<1/2/3/4/5>{/cyan-fg}: Copy translation results to clipboard {yellow-fg}Ctrl+<P/Z>{/yellow-fg}: Move to the previous entry in the translation history {yellow-fg}Ctrl+<N/X>{/yellow-fg}: Move to the next entry in the translation history {blue-fg}Shift+<Up/Down>{/blue-fg}: Simultaneous scrolling of all output panels {blue-fg}Ctrl+<Up/Down>{/blue-fg}: Scrolling the text input panel without changing the cursor position {blue-fg}Ctrl+<Left/Right>{/blue-fg}: Fast cursor navigation through words {blue-fg}Ctrl+<A/E>{/blue-fg}: Move the cursor to the ahead or end of text input {blue-fg}Ctrl+<C/U/L>{/blue-fg}: Clear text input field {blue-fg}Ctrl+W/Alt+Back{/blue-fg}: Delete the word before the cursor {blue-fg}Del/Ctrl+K{/blue-fg}: Deletes one letter or character after the cursor {magenta-fg}F2{/magenta-fg}: Switch to OpenAI with a preset translation prompt {magenta-fg}F3{/magenta-fg}: Switch to OpenAI in chat mode {red-fg}Escape{/red-fg}: Exit the program * {cyan-fg}Alt{/cyan-fg} = {cyan-fg}Meta{/cyan-fg}/{cyan-fg}Option{/cyan-fg} & {cyan-fg}Ctrl{/cyan-fg} = {cyan-fg}Command{/cyan-fg}/{cyan-fg}Cmd{/cyan-fg} (⌘) Version: ${pkg.version} GitHub Source: https://github.com/Lifailon/multranslate `) // Функция для динамической настройки размера окна с переводчиком function selectWindow(selectedTranslatorHidden) { if (selectedTranslatorHidden === "Google") { outputBox1.width = '100%' outputBox1.height = '79%' outputBox1.top = '20%' outputBox1.left = '0%' outputBox1.hidden = false outputBox2.hidden = true outputBox3.hidden = true outputBox4.hidden = true outputBox5.hidden = true } else if (selectedTranslatorHidden === "DeepL") { outputBox2.width = '100%' outputBox2.height = '79%' outputBox2.top = '20%' outputBox2.left = '0%' outputBox1.hidden = true outputBox2.hidden = false outputBox3.hidden = true outputBox4.hidden = true outputBox5.hidden = true } else if (selectedTranslatorHidden === "Reverso") { outputBox3.width = '100%' outputBox3.height = '79%' outputBox3.top = '20%' outputBox3.left = '0%' outputBox1.hidden = true outputBox2.hidden = true outputBox3.hidden = false outputBox4.hidden = true outputBox5.hidden = true } else if (selectedTranslatorHidden === "MyMemory") { outputBox4.width = '100%' outputBox4.height = '79%' outputBox4.top = '20%' outputBox4.left = '0%' outputBox1.hidden = true outputBox2.hidden = true outputBox3.hidden = true outputBox4.hidden = false outputBox5.hidden = true } else if (selectedTranslatorHidden === "OpenAI") { // Иключить перезатирание панели outputBox1.height = '0%' outputBox2.height = '0%' outputBox3.height = '0%' outputBox4.height = '0%' // Настройка окна outputBox5.width = '100%' outputBox5.height = '79%' outputBox5.top = '20%' outputBox5.left = '0%' outputBox1.hidden = true outputBox2.hidden = true outputBox3.hidden = true outputBox4.hidden = true outputBox5.hidden = false } else if (selectedTranslatorHidden === "all") { selectedTranslator = 'all' // Иключить отрисовку 5-й панели outputBox5.width = '0%' // Настройка окна outputBox1.width = '49.5%' outputBox1.height = '40%' outputBox1.top = '20%' outputBox1.left = '0%' outputBox2.width = '50%' outputBox2.height = '40%' outputBox2.top = '20%' outputBox2.left = '50.5%' outputBox3.width = '49.5%' outputBox3.height = '39%' outputBox3.top = '60%' outputBox3.left = '0%' outputBox4.width = '50%' outputBox4.height = '39%' outputBox4.top = '60%' outputBox4.left = '50.5%' outputBox1.hidden = false outputBox2.hidden = false outputBox3.hidden = false outputBox4.hidden = false outputBox5.hidden = true } screen.render() } selectWindow(selectedTranslator) outputBox5.setLabel(`OpenAI Translator (Alt+5 or Alt+X)`) // Добавление панелей на экран screen.append(inputBox) screen.append(outputBox1) screen.append(outputBox2) screen.append(outputBox3) screen.append(outputBox4) screen.append(outputBox5) screen.append(infoBox) screen.append(hotkeysBox) // Вызов окна справки screen.key(['f1'], function() { if (hotkeysBox.hidden === true) { hotkeysBox.show() } else { hotkeysBox.hide() } }) // F2: OpenAI Translator (#4) screen.key(['f2'], function() { if (outputBox5.hidden === true || selectedModeOpenAI === "chat") { selectedModeOpenAI = "translate" outputBox5.setLabel('OpenAI Translator (Alt+5 or Alt+X)') selectedTranslator = "OpenAI" selectWindow(selectedTranslator) } else { selectedTranslator = "all" selectWindow(selectedTranslator) } }) // F3: OpenAI Chat screen.key(['f3'], function() { if (outputBox5.hidden === true || selectedModeOpenAI === "translate") { selectedModeOpenAI = "chat" outputBox5.setLabel('OpenAI Chat (Alt+5 or Alt+X)') selectedTranslator = "OpenAI" selectWindow(selectedTranslator) } else { selectedTranslator = "all" selectWindow(selectedTranslator) } }) // ------------------------------- Auto-detect Language --------------------------------- // Функция определения исходного языка function detectFromLanguage(text) { const nonLanguagePattern = /[\s\n\r.,;:!?()\-"']/g const englishPattern = /[a-zA-Z]/g const cleanText = text.replace(nonLanguagePattern, '') const englishMatches = cleanText.match(englishPattern) || [] const englishCount = englishMatches.length const otherLanguageCount = cleanText.length-englishCount if (otherLanguageCount >= englishCount) { return selectedLanguage } else if (otherLanguageCount <= englishCount) { return 'en' } else { return '' } } // Функция определения целевого языка function detectToLanguage(lang) { if (lang === 'en') { return selectedLanguage } else if (lang === selectedLanguage) { return 'en' } else { return '' } } // -------------------------------------- SQLite ---------------------------------------- const dbPath = path.join(__dirname, 'translation-history.db') const clearHistory = 500 // Количество объектов истории для хранения в базе данных let maxID = 0 let curID = 0 // Функция для записи в БД function writeHistory(inputData, googleData, deeplxData, reversoData, mymemoryData, openaiData) { const db = new Database(dbPath) db.exec(` CREATE TABLE IF NOT EXISTS translationTable ( id INTEGER PRIMARY KEY AUTOINCREMENT, inputText TEXT NOT NULL, googleText TEXT, deeplxText TEXT, reversoText TEXT, mymemoryText TEXT, openaiText TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) const insert = db.prepare('INSERT INTO translationTable (inputText, googleText, deeplxText, reversoText, mymemoryText, openaiText) VALUES (?, ?, ?, ?, ?, ?)') insert.run( inputData, googleData, deeplxData, reversoData, mymemoryData, openaiData ) db.close() } // Функция для получения всех уникальных id в БД function getAllId() { const db = new Database(dbPath) let result const tableExists = db.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name='translationTable' `).get() if (tableExists) { const data = db.prepare('SELECT * FROM translationTable').all() result = data.map(row => row.id) } else { db.close() result = [] } db.close() return result } // Функция для чтения из истории function readHistory(id) { const db = new Database(dbPath) const query = `SELECT inputText,googleText,deeplxText,reversoText,mymemoryText,openaiText,created_at FROM translationTable WHERE id = ?` const get = db.prepare(query) const data = get.get(id) db.close() return data } // Функция для преобразования даны из БД function parseData(inputDate) { const [datePart, timePart] = inputDate.split(' ') const [year, month, day] = datePart.split('-') return `${timePart} ${day}.${month}.${year}` } // Функция для удаления из истории function deleteHistory(id) { const db = new Database(dbPath) const query = 'DELETE FROM translationTable WHERE id = ?' const del = db.prepare(query) del.run(id) db.close() } // ------------------------------------- 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() { return this.text.slice(0, this.cursorPosition) + '\u2591' + this.text.slice(this.cursorPosition) } // Метод автонавигации скролла navigateScroll(box) { // Фиксируем текущее максимальное количество строк и длинну символов в строке с учетом размеров окна const maxLines = box.height - 2 const maxChars = box.width - 4 // Разбиваем текст на массив из строк const bufferLines = this.text.split('\r') // Массив из строк (index) и их длинны (value) для определения номера строки текущего положения курсора let arrayLinesAndChars = [] // Проверяем длинну всех строк в массиве for (let line of bufferLines) { let remainingLine = line // Если строка пустая (например, новая пустая строка), добавляем её в массив как отдельную строку if (remainingLine === '') { arrayLinesAndChars.push(0) // Длина строки - 0 символов continue } while (remainingLine.length > 0) { if (remainingLine.length > maxChars) { // Найти последний пробел в пределах maxChars let breakPoint = remainingLine.lastIndexOf(' ', maxChars) // Если пробел не найден, разрываем по количеству символов if (breakPoint === -1) { breakPoint = maxChars } // Вырезаем подстроку до точки разрыва и добавляем в массив let subLine = remainingLine.slice(0, breakPoint).trim() arrayLinesAndChars.push(subLine.length) // Убираем обработанную часть строки remainingLine = remainingLine.slice(breakPoint + 1) } else { // Если строка помещается, добавляем её целиком arrayLinesAndChars.push(remainingLine.length) break } } } // Определяем строку, на которой располагается курсор в текущей момент let lengthCharsAllLines = 0 // длинна всех строк let currentLine = 0 // текущая строка // Прогоняем все строки сверху вниз for (let i in arrayLinesAndChars) { // Увеличиваем длинну строки lengthCharsAllLines = lengthCharsAllLines + arrayLinesAndChars[i] // Проверяем, что текущая позиция курсора (за вычетом длинны массива ?) меньше или равна текущей длинны строки с учетом предыдущих if (this.cursorPosition - i <= lengthCharsAllLines) { currentLine = parseInt(i) + 1 break } } // Текущее положение скролла const getScroll = box.getScroll() // Общее количество строк для скролла const getScrollHeight = box.getScrollHeight() // Проверяем, выходит ли курсор за пределы видимого диапазона строк if (currentLine === 0) { box.scrollTo(arrayLinesAndChars.length) } // Если курсор больше или равен текущей области видимости, поднимаем вверх на 2 строки (-3 ?) else if (getScroll >= currentLine) { box.scrollTo(currentLine - 3) } else if (currentLine >= (getScroll + maxLines)) { // Опускаем вниз const newScrollPos = Math.min(currentLine - maxLines + 1, getScrollHeight) box.scrollTo(newScrollPos) } // Debug output // outputBox1.setContent(`currentLine: ${currentLine}\ngetScroll: ${getScroll}`) } // Метод быстрого перемещения курсора через словосочетания navigateFastCursor(type) { // Разбиваем буфер на массив из букв let charsArray = this.text.split('\r').join(' ').split('') if (type === 'left' || type === 'back') { if (this.cursorPosition > 0) { // Обратный массив от начала буфера до положения курсора const charsBeforCursorArray = charsArray.slice(0,this.cursorPosition).reverse() let count = charsBeforCursorArray.length for (let char of charsBeforCursorArray) { // Уменьшаем позицию курсора, если это не пробел if (char !== ' ') { count-- } // Уменьшаем позицию, если курсора уже находится на пробеле else if (count === charsBeforCursorArray.length) { count-- } // Уменьшаем позицию, если следующий символ в строке пробел else if (char === ' ' && charsArray[count] === ' ') { count-- } else { if (count <= 0) { count = 0 } else { count - 1 } break } } // Отдаем значение смещения для удаления словосочетания if (type === 'back') { return charsBeforCursorArray.length - count } else { this.cursorPosition = count } } } else if (type === 'right') { if (this.cursorPosition < this.text.length) { // Массив от позиции курсора до конца текста const charsBeforCursorArray = charsArray.slice(this.cursorPosition) let count = this.cursorPosition for (let char of charsBeforCursorArray) { if (char !== ' ') { count++ } else if (count === this.cursorPosition) { count++ } else if (char === ' ' && charsArray[count-1] === ' ') { count++ } else { if (count >= this.text.length) { count = this.text.length } else { count + 1 } break } } this.cursorPosition = Math.min(count, this.text.length) } } } // Метод навигации вверх и вниз navigateUpDown(box,type) { const maxChars = box.width - 4 // Массив из строк const bufferLines = this.text.split('\r') // Массив из длинны всех строк let linesArray = [] // Зафиксировать длинну только реальных строк // for (let line of bufferLines) { // linesArray.push(line.length) // } // Добавляем виртуальные строки при использовании встроенного wrap // Фиксируем длинну всех строк for (let line of bufferLines) { // Добавляем виртуальные строки if (line.length > maxChars) { // Стартовая позиция для среза let startCount = 0 // Конец строки для среза из максимальной длинны let endCount = maxChars // Узнаем длинну строк с учетом автопереноса while (true) { // Срез текущей строки let count = line.slice(startCount, endCount) // Если достигли конца всех строк (длинна всей строки минус начальная позиция текущего среза меньше длинны строки с учетом переноса), добавляем остаток и завершаем цикл if ((line.length - startCount) < maxChars) { linesArray.push(line.length - startCount) break } // Если достигли конца строки для автопереноса (в 10 символов), добавляем длинну строки целиком, обновляем начальную позицию и конец строки среза для проверки следующей строки else if (endCount === maxChars-10) { linesArray.push(maxChars - 1) // -1 из за смещения пробелом курсора startCount = startCount + maxChars endCount = endCount + maxChars } // Если последний символ в строке не является пробелом, увеличиваем счетчик конца среза текущей строки else if (count[count.length-1] !== ' ') { endCount-- } // Если последний символ в строке содержит пробел, то добавляем строку текущей длинны среза else { linesArray.push(count.length - 1) // -1 из за смещения пробелом курсора startCount = startCount + count.length endCount = endCount + maxChars } } } else { linesArray.push(line.length) } } // Счетчик начинается с длинны первой строки let charsArray = linesArray[0] let cursorLine = 1 let charToLine = 0 // Фиксируем на какой строке находится курсор for (let lineIndex in linesArray) { // Проверяем, что курсор находится в пределах текущей строки if (this.cursorPosition <= charsArray) { break } else { // Фиксируем позицию курсора в текущей строке charToLine = this.cursorPosition - charsArray - 1 // Увеличиваем длинну символов курсора на длинну символов следующей строки + длинна одного символа переноса строки charsArray += linesArray[parseInt(lineIndex) + 1] + 1 // Увеличиваем счетчик строки cursorLine++ } } let positionToLine if (type === 'up') { if (cursorLine > 1) { // Фиксируем позицию в строке выше if (linesArray[cursorLine-2] >= charToLine) { positionToLine = charToLine } else { positionToLine = linesArray[cursorLine-2] } const linesArraySlice = linesArray.slice(0, cursorLine-2) for (let l of linesArraySlice) { positionToLine = positionToLine + l + 1 } this.cursorPosition = positionToLine } } else if (type === 'down') { if (cursorLine < linesArray.length) { // Если первая строка, обновляем значение текущей позиции в строке if (cursorLine === 1) { charToLine = this.cursorPosition } // Фиксируем позицию в строке ниже if (linesArray[cursorLine] >= charToLine) { positionToLine = charToLine } else { positionToLine = linesArray[cursorLine] } const linesArraySlice = linesArray.slice(0, cursorLine) for (let l of linesArraySlice) { positionToLine = positionToLine + l + 1 } // Корректируем позицию курсора, чтобы она не выходила за пределы текста this.cursorPosition = Math.min(positionToLine, this.text.length) } } } // Метод отключения нативного курсора 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) } // Метод автоматического переноса строки при добавлении нового текста autoWrap(newText, box) { const maxChars = box.width - 5 let textArray = newText.split('\r') let textString = [] for (let line of textArray) { if (line.length > maxChars) { let currentLines = Math.ceil(line.length / maxChars) const indices = [...Array(currentLines).keys()] for (let i of indices) { let start = i * maxChars let end = start + maxChars let addText = line.slice(start, end) textString.push(addText) } } else { textString.push(line) } } this.text = textString.join('\r') this.cursorPosition = Math.min(this.cursorPosition, this.text.length) } } // Создаем экземпляр класса const buffer = new TextBuffer() // Скрыть нативный курсор терминала buffer.disableNativeCursor() // Обработка нажатий клавиш для управления буфером inputBox.on('keypress', async function (ch, key) { // Debug: вывод комбинации // outputBox1.setContent("Name: " + key.name + "\r" + "Full Name: " + key.full + "\r" + "Ctrl: " + key.ctrl + "\r" + "Shift: " + key.shift + "\r" + "Alt: " + key.meta) // Перевести курсор в самое начало (A)head или конец (E)nd (like vim #3) if (key.name === 'a' && key.ctrl === true || key.name === 'a' && key.Command === true) { buffer.setCursorPosition(0) } else if ( key.name === 'e' && key.ctrl === true || key.name === 'e' && key.Command === true ) { buffer.setCursorPosition(buffer.getText().length) } // Быстрая навигация курсора через слова (Ctrl/Command/Shift/Alt+Left/Right) else if ( key.name === 'left' && key.ctrl === true || key.name === 'left' && key.Command === true || key.name === 'left' && key.shift === true || key.name === 'left' && key.meta === true || key.full === 'M-left' || key.name === 'right' && key.ctrl === true || key.name === 'right' && key.Command === true || key.name === 'right' && key.shift === true || key.name === 'right' && key.meta === true || key.full === 'M-right' ) { let replaceKey = key.name.replace("M-","") buffer.navigateFastCursor(replaceKey) } // Назначить методы перемещения курсора на стрелочки (classic Left/Right) else if (key.name === 'left' && key.ctrl === false || key.name === 'left' && key.Command === false) { buffer.moveLeft() } else if (key.name === 'right' && key.ctrl === false || key.name === 'right' && key.Command === false) { buffer.moveRight() } // Обработчик событий пролистывания всех панелей вывода (Shift+Up/Down) else if (key.name === 'up' && key.shift === true) { outputBox1.scroll(-1) outputBox2.scroll(-1) outputBox3.scroll(-1) outputBox4.scroll(-1) outputBox5.scroll(-1) } else if (key.name === 'down' && key.shift === true) { outputBox1.scroll(1) outputBox2.scroll(1) outputBox3.scroll(1) outputBox4.scroll(1) outputBox5.scroll(1) } // Поднимаем поле ввода текста вверх для ручного скроллинга (Ctrl/Alt+Up/Down) else if ( key.name === 'up' && key.ctrl === true || key.name === 'up' && key.Command === true || key.name === 'up' && key.meta === true || key.full === 'M-up' ) { inputBox.scroll(-1) } // Опускаем поле ввода текста вниз (Ctrl/Alt+Down) else if ( key.name === 'down' && key.ctrl === true || key.name === 'down' && key.Command === true || key.name === 'down' && key.meta === true || key.full === 'M-down' ) { inputBox.scroll(1) } // Навигация курсора между строками (classic Up/Down) else if (key.name === 'up') { buffer.navigateUpDown(inputBox,'up') } else if (key.name === 'down') { buffer.navigateUpDown(inputBox,'down') } // Удалить словосочетание перед курсором (Ctrl+W и Alt+Backspace) else if ( key.name === 'w' && key.ctrl === true || key.name === 'w' && key.Command === true || key.name === 'backspace' && key.meta === true ) { const backCursorPosition = buffer.navigateFastCursor('back') if (buffer.getCursorPosition() > 0) { const newText = buffer.getText().slice(0, buffer.getCursorPosition() - backCursorPosition) + buffer.getText().slice(buffer.getCursorPosition()) buffer.setCursorPosition(buffer.getCursorPosition() - backCursorPosition) buffer.setText(newText) } } // Удалить один символ перед курсором (only classic backspace) else if (key.name === 'backspace' && key.ctrl === false || key.name === 'backspace' && key.Command === false) { // Проверяем, что курсор не находится в начале содержимого буфера if (buffer.getCursorPosition() > 0) { // Извлекаем текст с первого (нулевого) индекса по порядковый номер положения курсора без последней буквы для ее удаления (-1) и добавляем остаток после курсора до конца содержимого буфера const newText = buffer.getText().slice(0, buffer.getCursorPosition() - 1) + buffer.getText().slice(buffer.getCursorPosition()) // Перемещаем курсор влево после удаления символа buffer.moveLeft() // Обновляем текст в буфере buffer.setText(newText) } } // Удалить один символ после курсора (Del/Ctrl+K) else if ( key.name === 'delete' || key.name === 'k' && key.ctrl === true || key.name === 'k' && key.Command === true ) { // Проверяем, что курсор не находится в конце содержимого буфера if (buffer.getCursorPosition() < buffer.getText().length) { const newText = buffer.getText().slice(0, buffer.getCursorPosition()) + buffer.getText().slice(buffer.getCursorPosition() + 1) buffer.setText(newText) } } // Переопределяем нажатие Tab, для добавления 4 пробелов else if (key.name === 'tab') { const newText = buffer.getText().slice(0, buffer.getCursorPosition()) + ' ' + buffer.getText().slice(buffer.getCursorPosition()) buffer.setCursorPosition(buffer.getCursorPosition() + 4) buffer.setText(newText) } // Обрабатываем перенос строки для Enter (перенос строки добавляется автоматически) без комбинаций с зажатыми клавишами else if (key.name === 'return' && key.ctrl === false && key.shift === false && key.meta === false) { const newText = buffer.getText().slice(0, buffer.getCursorPosition()) + "" + buffer.getText().slice(buffer.getCursorPosition()) buffer.setText(newText) buffer.moveRight() } // Асинхронный перевод текста через Ctrl+S/Ctrl+Enter (linefeed) или Enter (return) с любой из зажатых комбинаций клавиш else if ( key.name === 'linefeed' || key.name === 'return' || key.name === 's' && key.ctrl === true ) { // Debug (отключить перевод для отладки интерфейса) await handleTranslation() // Сбрасываем покраску после перевода inputBox.style.border.fg = 'blue' outputBox1.style.border.fg = 'blue' outputBox2.style.border.fg = 'blue' outputBox3.style.border.fg = 'blue' outputBox4.style.border.fg = 'blue' outputBox5.style.border.fg = 'blue' screen.render() inputBox.focus() } // Обработка очистки буфера текста (Ctrl+C/U/L) - (C)lear else if ( key.name === 'c' && key.ctrl === true || key.name === 'c' && key.Command === true || key.name === 'u' && key.ctrl === true || key.name === 'u' && key.Command === true || key.name === 'l' && key.ctrl === true || key.name === 'l' && key.Command === true ) { buffer.setText("") } // Обработка вставки текста из буфера обмена в поле ввода (Ctrl+V) else if (key.name === 'v' && key.ctrl === true) { let clipboardText = clipboardy.readSync() // Обновляем экранирование переноса строки для фиксации при перемещении нативного курсора и обрезаем пробелы в конце строки clipboardText = clipboardText.replace(/\n/g, '\r').trim() let newText = buffer.getText().slice(0, buffer.getCursorPosition()) + clipboardText + buffer.getText().slice(buffer.getCursorPosition()) // Добавляем текст из буфера обмена к текущему тексту buffer.setText(newText) // Перемещаем курсор в конец текста buffer.setCursorPosition(buffer.getCursorPosition() + clipboardText.length) } // Чтение из истории с конца (Ctrl+P/Z) - (P)revious (like vim #3) else if ( key.name === 'p' && key.ctrl === true || key.name === 'p' && key.Command === true || key.name === 'z' && key.ctrl === true || key.name === 'z' && key.Command === true ) { const allId = getAllId() if (allId.length !== 0) { let lastId if (maxID === 0) { lastId = allId[allId.length-1] curID = allId.length-1 maxID = allId.length-1 } else { if (curID !== 0) { curID-- } lastId = allId[curID] } if (lastId) { // Извлекаем данные из БД по id const lastText = readHistory(lastId) const newText = lastText.inputText.replace(/\n/g, '\r') // Обновляем статус infoBox.content = `${infoContent} History: \x1b[32m${curID+1}\x1b[37m/\x1b[32m${maxID+1}\x1b[37m (${parseData(lastText.created_at)})` // Обновляем текст в поле ввода, курсор и текст в окнах вывода buffer.setText(newText) buffer.setCursorPosition(newText.length) outputBox1.setContent( lastText.googleText?.replace(/\n/g, '\r') ) outputBox2.setContent( lastText.deeplxText?.replace(/\n/g, '\r') ) outputBox3.setContent( lastText.reversoText?.replace(/\n/g, '\r') ) outputBox4.setContent( lastText.mymemoryText?.replace(/\n/g, '\r') ) outputBox5.setContent( lastText.openaiText?.replace(/\n/g, '\r') ) } } } // Чтение из истории в обратном порядке (Ctrl+N/X) - (N)ext (like vim #3) else if ( key.name === 'n' && key.ctrl === true || key.name === 'n' && key.Command === true || key.name === 'x' && key.ctrl === true || key.name === 'x' && key.Command === true ) { const allId = getAllId() if (allId.length !== 0) { let nextId if (maxID === 0) { nextId = allId[allId.length-1] curID = allId.length-1 maxID = allId.length-1 } else { if (curID !== allId.length-1) { curID++ } nextId = allId[curID] } if (nextId) { const lastText = readHistory(nextId) const newText = lastText.inputText.replace(/\n/g, '\r') infoBox.content = `${infoContent} History: \x1b[32m${curID+1}\x1b[37m/\x1b[32m${maxID+1}\x1b[37m (${parseData(lastText.created_at)})` buffer.setText(newText) buffer.setCursorPosition(newText.length) outputBox1.setContent( lastText.googleText?.replace(/\n/g, '\r') ) outputBox2.setContent( lastText.deeplxText?.replace(/\n/g, '\r') ) outputBox3.setContent( lastText.reversoText?.replace(/\n/g, '\r') ) outputBox4.setContent( lastText.mymemoryText?.replace(/\n/g, '\r') ) outputBox5.setContent( lastText.openaiText?.replace(/\n/g, '\r') ) } } } // Если нажата любая другая клавиша и она не пустая, добавляем символ в текстовый буффера else if (ch) { // Обновляем текст, добавляя символом следом за текущей позицией курсора const newText = buffer.getText().slice(0, buffer.getCursorPosition()) + ch + buffer.getText().slice(buffer.getCursorPosition()) // Устанавливаем новый текст в буфер buffer.setText(newText) // Перемещаем курсор вправо после добавления символа buffer.moveRight() } // Возврат автоматического скроллинга if (!( (key.name === 'up' && key.ctrl === true) || (key.name === 'up' && key.Command === true) || (key.name === 'up' && key.meta === true) || (key.name === 'down' && key.ctrl === true) || (key.name === 'down' && key.Command === true) || (key.name === 'down' && key.meta === true) )) { // Обновляем поле ввода текста inputBox.setValue(buffer.viewDisplayCursor()) // Включить автоматический скроллинг buffer.navigateScroll(inputBox) } screen.render() }) // ----------------------------------- 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) // Заменяем символы переноса строки (не воспринимается API) на временный символ (©) или экранирование (\\n) text = text.replace(/\n/g, '©') 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.replace(/©/g, '\n') // Нескольк ответов // console.log(response.data.alternatives) } 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((_,