diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44a5f7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Binaries +grimoir +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Credentials +*.env + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Config with sensitive data +config.yaml +config.yml +*.local.yaml +*.local.yml + +# Temporary files +*.tmp +*.temp diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6d7a364 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,514 @@ +# grimoir — Архитектура и устройство утилиты + +## Обзор + +`grimoir` — терминальный текстовый редактор в стиле Vim, написанный на Go. Поддерживает markdown с live preview, подсветку синтаксиса для 10 языков программирования, файловое дерево, экспорт в PDF, загрузку статей по URL и гибкую настройку через конфиг-файл. Построен на фреймворке [Bubble Tea](https://github.com/charmbracelet/bubbletea), реализующем архитектуру Elm (Model-Update-View). + +``` +Запуск: grimoir notes.md + grimoir main.go + grimoir https://habr.com/ru/articles/123456/ + grimoir --write-config + grimoir --config ~/my.conf notes.md +``` + +--- + +## Архитектура: Elm / Bubble Tea + +``` +┌─────────────────────────────────────────────────┐ +│ Bubble Tea │ +│ │ +│ Init() ──→ начальные команды (тики) │ +│ │ +│ Update(msg) ──→ обрабатывает события │ +│ • KeyMsg — нажатие клавиши │ +│ • WindowSizeMsg — изменение размера окна │ +│ • TickMsg — таймер (автосейв) │ +│ │ +│ View() ──→ возвращает строку для вывода │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## Структура файлов + +``` +grimoir/ +├── main.go (105 строк) — точка входа, флаги, URL-детектор +├── model.go (451 строка) — EditorModel, Init/Update/View, undo/redo +├── keybindings.go (626 строк) — обработка клавиш по режимам и фокусу +├── commands.go (521 строка) — команды : (save, quit, new, fetch, export...) +├── search.go (284 строки) — поиск, f/t навигация, замена, скобки +├── syntax.go (174 строки) — конфиги языков, подсветка синтаксиса +├── render.go (491 строка) — весь рендеринг UI и markdown +├── config.go (432 строки) — INI парсер, поиск конфига, биндинги +├── filetree.go (307 строк) — файловое дерево, TTF навигация +├── fetch.go (551 строка) — HTTP загрузка, HTML→Markdown конвертер +├── pdf.go (905 строк) — PDF генератор, dual-engine архитектура +├── pdf_font.go (1113 строк) — TTF парсер, subset builder, PDF embedding +├── file.go (28 строк) — сохранение файла +├── file_io.go (54 строки) — загрузка файла, определение типа +└── go.mod — зависимости +``` + +**Итого: ~6000 строк, 218 функций, 0 внешних зависимостей кроме charmbracelet.** + +--- + +## Ключевые структуры данных + +### EditorModel + +Центральная структура — единственное место хранения состояния: + +```go +type EditorModel struct { + // Контент (cursorX/Y в рунах, не байтах) + lines []string + cursorX int + cursorY int + + // Режим и ввод + mode VimMode // NORMAL / INSERT / COMMAND / SEARCH / VISUAL + pendingKeys string // буфер для dd, gg, f+char + numPrefix string // числовой префикс (3 в команде 3dd) + + // Файл + filepath string + fileType string // "go", "py", "md", "txt" и т.д. + dirty bool + + // UI — панели + width, height int + treeWidth int // ширина дерева (0 если скрыта) + previewWidth int // ширина preview + showFileTree bool + showPreview bool + focus PanelFocus // FocusEditor / FocusFileTree + + // Настройки из конфига + showLineNumbers bool + tabWidth int + autosaveInterval int + config Config + + // Поиск + searchMatches []struct{ line, col int } // позиции в рунах + lastFindChar rune + + // Undo/Redo + undoStack []undoSnapshot + redoStack []undoSnapshot + + // Файловое дерево + fileTree FileTree +} +``` + +### Важно: все позиции в рунах + +`cursorX` и все операции со строками используют `[]rune`, а не байтовые индексы. Это обеспечивает корректную работу с кириллицей, китайскими иероглифами и любым другим Unicode. + +```go +// Правильно: +line := []rune(m.lines[m.cursorY]) +before := string(line[:m.cursorX]) + +// Неправильно (сломает кириллицу): +before := m.lines[m.cursorY][:m.cursorX] +``` + +### undoSnapshot + +```go +type undoSnapshot struct { + lines []string + cursorX int + cursorY int +} +``` + +Стек ограничен 100 записями. Перед каждой мутацией вызывается `saveUndoSnapshot()`. + +### LanguageConfig (syntax.go) + +```go +type LanguageConfig struct { + Keywords []string + LineCommentPrefix string // "//" или "#" или "--" + StringDelimiters []string // `"`, `'`, `` ` `` + HasNumbers bool + CaseInsensitive bool // для SQL + VarPrefix string // для Bash: "$" +} +``` + +--- + +## Vim режимы и переходы + +``` + ┌──────────────┐ + esc │ │ i / a / A / I / o / O + ┌──────▶│ NORMAL │◀──────────────────────────┐ + │ │ │ │ + │ └──┬──────┬───┘ │ + │ │ : │ / │ + │ ▼ ▼ │ + │ COMMAND SEARCH ──── enter ───────────────┘ + │ │ │ │ + │ enter │ │ INSERT + └──────────┘ └───────────────────────────────┘ +``` + +| Режим | Описание | +|---------|----------| +| NORMAL | навигация, команды (dd, yy, p, u, Ctrl+R) | +| INSERT | ввод текста, стрелки, Unicode включая кириллицу | +| COMMAND | ввод : команд | +| SEARCH | ввод паттерна после `/` | + +--- + +## Обработка клавиш (keybindings.go) + +``` +handleKey(msg) +├── Глобальные (quit, toggle_tree, focus_switch, panel_shrink/grow) +│ └── берутся из config.KeyForAction() — переопределяемые +│ +├── FocusFileTree → handleFileTreeKey() +│ └── j/k/h/l/enter/r/gg/G/esc +│ +└── FocusEditor + ├── NORMAL → handleNormalMode() + │ ├── числовые префиксы (1-9, 0) + │ ├── handleModeSwitch() — i, a, A, I, o, O + │ ├── handleMovement() — h/j/k/l, w/b/e, gg/G, f/t, n/N, % + │ └── handleEditing() — x, dd, yy, p, P, u, Ctrl+R + │ + ├── INSERT → handleInsertMode() + │ ├── Стрелки up/down/left/right, home, end, delete + │ ├── enter, backspace, tab + │ └── KeyRunes — любой Unicode символ (включая кириллицу) + │ + ├── COMMAND → handleCommandMode() + └── SEARCH → handleSearchMode() +``` + +### Ввод Unicode в INSERT режиме + +```go +// msg.Type == tea.KeyRunes — единственно правильная проверка +// для печатаемых символов в bubbletea v0.25+ +if len(runes) == 1 && runes[0] >= 32 && msg.Type == tea.KeyRunes { + m.insertRuneAt(m.cursorY, m.cursorX, runes) + m.cursorX++ +} +``` + +--- + +## Файловое дерево (filetree.go) + +``` +FileTree { + root string // корневая директория + nodes []*FileNode // плоский список видимых узлов + cursor int + scroll int + focused bool +} +``` + +Дерево хранится иерархически, для рендера разворачивается в плоский список `flattenTree()`. Директории сортируются перед файлами, скрытые файлы и `node_modules/vendor/__pycache__` пропускаются. + +**Клавиши в дереве:** `j/k` навигация, `l/enter` открыть/развернуть, `h` свернуть/к родителю, `r` обновить, `gg/G` начало/конец, `esc` вернуть фокус редактору. + +--- + +## Компоновки экрана (render.go) + +``` +View() +├── tree + editor + preview → renderTreeEditorPreview() +├── tree + editor → renderTreeEditor() +├── editor + preview → renderSplit() +└── editor only → renderEditor() +``` + +**Управление шириной панелей:** + +| Способ | Действие | +|--------|----------| +| `<` / `>` | ±2 символа к фокусированной панели | +| `:treewidth 30` / `:tw 30` | точная ширина дерева | +| `:previewwidth 45` / `:pw 45` | точная ширина preview | +| `Ctrl+B` | показать/скрыть дерево | +| `Tab` | переключить фокус | +| ` ` (пробел) | показать/скрыть preview (только .md) | + +`clampPanelWidths()` гарантирует что все панели помещаются на экран, пропорционально уменьшая при нехватке. + +`truncateANSI()` обрезает строку по видимой ширине не ломая ANSI escape-последовательности. + +--- + +## Подсветка синтаксиса (syntax.go + render.go) + +### Алгоритм + +``` +HighlightLine(line, fileType) +└── highlightWithConfig(line, config) + ├── 1. Комментарии → colorComment, остальное не трогать + └── highlightTokens(text, config) + ├── 1. Строки (regexp по кавычкам) → colorString + ├── 2. Ключевые слова (\bKEYWORD\b) → colorKeyword + ├── 3. Числа (\b\d+\b) → colorNumber + └── 4. Переменные ($VAR для Bash) → colorFunc +``` + +### Поддерживаемые языки + +`go`, `py`, `js`, `ts`, `rs`, `c`, `cpp`, `java`, `sql`, `sh` + +--- + +## Конфиг-система (config.go) + +### Формат файла + +```ini +[settings] +tree_width = 25 +preview_width = 40 +line_numbers = true +tab_width = 4 +autosave = 60 +show_preview = false +show_tree = false + +[keys] +quit = ctrl+c +toggle_tree = ctrl+b +focus_switch = tab +panel_shrink = < +panel_grow = > +toggle_preview = " " +redo = ctrl+r +``` + +### Пути поиска (приоритет: первый найденный) + +| ОС | Пути | +|----|------| +| Linux | `./grimoir.conf` → `$XDG_CONFIG_HOME/grimoir/` → `~/.config/grimoir/` → `~/.grimoir.conf` | +| macOS | `./grimoir.conf` → `~/Library/Application Support/grimoir/` → `~/.config/grimoir/` | +| Windows | `./grimoir.conf` → `%APPDATA%\grimoir\` → `%USERPROFILE%\grimoir.conf` | + +### Команды управления конфигом + +| Команда | Действие | +|---------|----------| +| `:config show` | текущие настройки и откуда загружен конфиг | +| `:config paths` | список путей поиска | +| `:config write` | записать дефолтный конфиг с комментариями | +| `:config reload` | перезагрузить конфиг без перезапуска | +| `--config path` | флаг запуска: явный путь к конфигу | +| `--write-config` | записать конфиг и выйти | + +--- + +## PDF экспорт (pdf.go + pdf_font.go) + +### Dual-engine архитектура + +```go +type pdfEngine interface { + textCmd(text string, font pdfFont, color pdfColor, x, y float64) string + textWidth(text string, font pdfFont) float64 + fontResources() string + writeObjects(doc *pdfDoc) +} +``` + +**TTF engine** — включается если найден системный шрифт с кириллицей. Текст кодируется в UTF-16BE, шрифт регистрируется как Type0/CIDFont с Identity-H. Полноценный русский текст в PDF. + +**Fallback engine** — встроенные Helvetica/Courier без встраивания. Кириллица транслитерируется. Включается если шрифт не найден. + +### Поиск системного шрифта + +``` +FindCyrillicFont() +├── Список приоритетных имён: LiberationSans, DejaVuSans, Arial, Roboto, Noto... +├── Директории по ОС: +│ Linux: /usr/share/fonts, ~/.fonts, ~/.local/share/fonts +│ macOS: /Library/Fonts, ~/Library/Fonts +│ Windows: %WINDIR%\Fonts +└── Рекурсивный обход если по именам не найдено +``` + +### TTF Subset Builder + +Вместо всего шрифта (~300–500 KB) в PDF встраивается только подмножество реально использованных глифов. Для типичного русского текста: 200–400 глифов вместо 3000+. Composite glyphs обрабатываются рекурсивно. + +### Что рендерится в PDF + +Заголовки H1–H4 с цветами и линией под H1, параграфы с переносом строк, **bold**/*italic*/`code`, блоки кода с фоном и полоской, цитаты blockquote, маркированные и нумерованные списки, таблицы, горизонтальные линии, многостраничность. + +--- + +## Загрузка статей (fetch.go) + +``` +FetchAndConvert(url) +├── fetchHTML() +│ ├── net/http с таймаутом 30с, лимитом 5 MB +│ └── User-Agent чтобы не получить 403 +│ +├── extractTitle() — og:title → → <h1> +│ +├── extractMainContent() +│ ├── Удаляем: <script>, <style>, <nav>, <header>, <footer>, <aside> +│ ├── Ищем: <article>, <main>, div[role=main], div.content/post/article +│ └── Fallback: блок с максимальной длиной текста +│ +└── htmlToMarkdown() + ├── <h1>–<h6> → # заголовки + ├── <p> → параграфы + ├── <ul>/<ol>/<li> → - / 1. списки + ├── <pre>/<code> → ``` блоки + ├── <blockquote> → > цитаты + ├── <strong>/<em> → **bold**/*italic* + ├── <img> → ![alt](src) + ├── <table>/<tr>/<td> → | таблицы | + └── HTML entities → нормальные символы +``` + +**Имя файла** генерируется из заголовка через `slugify()` — транслитерация + латиница + дефисы, максимум 60 символов. + +--- + +## Undo / Redo (model.go) + +``` +saveUndoSnapshot() ← перед КАЖДОЙ мутацией +│ копирует lines[], cursorX, cursorY → undoStack +│ очищает redoStack +│ ограничивает стек 100 записями + +u → Undo(): undoStack → текущее, текущее → redoStack +Ctrl+R → Redo(): redoStack → текущее, текущее → undoStack +``` + +--- + +## Поиск (search.go) + +### Глобальный `/pattern` + +Поиск по рунам — позиции корректны для любого Unicode. `n`/`N` циклически обходят все совпадения. + +### f/t навигация + +| Команда | Действие | +|---------|----------| +| `f{c}` / `F{c}` | символ вправо/влево | +| `t{c}` / `T{c}` | позиция до символа вправо/влево | +| `;` / `,` | повторить в том же / обратном направлении | +| `%` | парная скобка `()[]{}` через строки | + +--- + +## Автосейв (model.go) + +```go +tea.Tick(interval, ...) // интервал из конфига +// В Update(): +if autosaveInterval > 0 && time.Since(lastSave) >= interval && dirty { + save() +} +``` + +`:set autosave=30` — изменить интервал на лету. `:set autosave=0` — выключить. + +--- + +## Команды (:) + +| Команда | Действие | +|---------|----------| +| `:w` / `:w path` | сохранить / сохранить копию | +| `:saveas path` | сохранить как (переключиться на новый файл) | +| `:q` / `:q!` / `:wq` | выйти | +| `:e path` / `:e` | открыть файл / перезагрузить текущий | +| `:new` / `:new path` | новый буфер / новый файл | +| `:fetch URL` | скачать статью и открыть | +| `:export pdf` | экспорт в PDF | +| `:export pdf path` | экспорт в указанный путь | +| `:tree` / `:notree` | показать/скрыть дерево | +| `:N` | перейти на строку N | +| `:s/old/new/` | заменить первое вхождение | +| `:s/old/new/g` | заменить все вхождения | +| `:set` | показать все настройки | +| `:set number` / `:set nonumber` | номера строк | +| `:set tab_width=2` | ширина таба | +| `:set autosave=30` | интервал автосейва | +| `:u` | undo | +| `:config show/paths/write/reload` | управление конфигом | + +--- + +## Зависимости + +| Пакет | Назначение | +|-------|------------| +| `charmbracelet/bubbletea` | event loop, Model-Update-View | +| `charmbracelet/lipgloss` | стилизация (цвет, bold, width) | +| `charmbracelet/bubbles/viewport` | скроллируемая область | +| `net/http` | загрузка URL (стандартная библиотека) | + +Подсветка синтаксиса, PDF генерация, TTF парсинг, INI парсер, HTML→Markdown конвертер — всё реализовано внутри без внешних зависимостей. + +--- + +## Сборка + +```bash +go mod tidy +go build -ldflags="-s -w" -o grimoir + +# Записать дефолтный конфиг: +./grimoir --write-config + +# Запуск: +./grimoir README.md +./grimoir main.go +./grimoir https://habr.com/ru/articles/123456/ +``` + +--- + +## Шпаргалка + +``` +НАВИГАЦИЯ РЕДАКТИРОВАНИЕ ПОИСК +h j k l i / a / A /pattern поиск +w b e I / o / O n / N следующий/предыдущий +gg / G x удалить f{c} символ в строке +0 / ^ / $ dd строку ; , повторить f/t +5j / 3dd yy скопировать % парная скобка + p / P вставить + u undo + Ctrl+R redo + +ПАНЕЛИ ФАЙЛ INSERT режим +Ctrl+B дерево :w сохранить стрелки навигация +Tab фокус :wq сохранить+выйти home/end начало/конец строки +< / > ширина :e path открыть любой Unicode включая кириллицу +Пробел preview :fetch URL скачать +``` \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b100eb7 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +# grimoir + +Терминальный текстовый редактор в стиле Vim, написанный на Go. + +Поддерживает Markdown с live preview, подсветку синтаксиса для 10 языков, встроенный файловый менеджер, экспорт в PDF с кириллицей, загрузку статей по URL и гибкую настройку через конфиг-файл. + +--- + +## Возможности + +### Редактор +- **Vim-режимы** — NORMAL, INSERT, COMMAND, SEARCH с полным набором команд +- **Unicode** — полная поддержка кириллицы и любых других символов при вводе +- **Undo / Redo** — до 100 шагов (`u` / `Ctrl+R`) +- **Числовые префиксы** — `5j`, `3dd`, `10G` +- **Поиск** — `/pattern` с навигацией `n`/`N`, поиск символа `f`/`t`, парные скобки `%` +- **Замена** — `:s/old/new/` и `:s/old/new/g` +- **Автосохранение** — настраиваемый интервал + +### Markdown +- **Live preview** — разделённый экран редактор + preview +- **Рендер** — заголовки, списки, таблицы, blockquotes, code blocks с подсветкой, ссылки, bold/italic + +### Подсветка синтаксиса +Go, Python, JavaScript, TypeScript, Rust, C/C++, Java, SQL, Bash + +### Файловый менеджер +- Дерево файлов с vim-навигацией +- Открытие файлов прямо из дерева +- `Ctrl+B` — показать/скрыть, `Tab` — переключить фокус + +### PDF экспорт +- Встраивает системный шрифт с кириллицей — полноценный русский текст в PDF +- Автоматический поиск шрифта (LiberationSans, DejaVu, Arial и др.) +- Subset embedding — в PDF попадают только использованные глифы +- Fallback — транслитерация если шрифт не найден + +### Загрузка статей +```bash +grimoir https://example.com/article +# или прямо в редакторе: +:fetch https://example.com/article +``` +Скачивает страницу, извлекает основной контент, конвертирует в Markdown, сохраняет файл с именем из заголовка статьи. + +--- + +## Управление + +### Режимы + +| Клавиша | Действие | +|---------|----------| +| `i` / `a` / `A` | INSERT: вставить / после курсора / в конец строки | +| `I` / `o` / `O` | INSERT: в начало / новая строка ниже / выше | +| `Esc` | вернуться в NORMAL | +| `:` | режим команд | +| `/` | поиск | +| `?` | справка | + +### Навигация + +| Клавиша | Действие | +|---------|----------| +| `h j k l` | ← ↓ ↑ → | +| `w` / `b` / `e` | слово вперёд / назад / конец слова | +| `0` / `^` / `$` | начало / первый символ / конец строки | +| `gg` / `G` | начало / конец файла | +| `42G` | перейти на строку 42 | +| `f{c}` / `F{c}` | найти символ вправо / влево | +| `t{c}` / `T{c}` | до символа вправо / влево | +| `;` / `,` | повторить f/t / в обратном направлении | +| `%` | парная скобка `()[]{}` | + +В INSERT режиме работают стрелки, `Home`, `End`, `Delete`. + +### Редактирование + +| Клавиша | Действие | +|---------|----------| +| `x` | удалить символ | +| `dd` / `3dd` | удалить строку / 3 строки | +| `yy` / `p` / `P` | скопировать / вставить ниже / выше | +| `u` / `Ctrl+R` | undo / redo | + +### Команды + +| Команда | Действие | +|---------|----------| +| `:w` | сохранить | +| `:w path` | сохранить копию | +| `:saveas path` | сохранить как | +| `:q` / `:q!` / `:wq` | выйти | +| `:e path` | открыть файл | +| `:new path` | новый файл | +| `:N` | перейти на строку N | +| `:s/old/new/g` | замена во всём файле | +| `:fetch URL` | скачать статью | +| `:export pdf` | экспорт в PDF | +| `:export pdf path` | экспорт в указанный путь | +| `:help` / `?` | справка | + +### Панели + +| Клавиша / Команда | Действие | +|-------------------|----------| +| `Ctrl+B` | показать / скрыть файловое дерево | +| `Tab` | переключить фокус между панелями | +| `<` / `>` | уменьшить / увеличить ширину панели | +| Пробел | показать / скрыть preview (только .md) | +| `:treewidth 30` | установить ширину дерева | +| `:previewwidth 45` | установить ширину preview | + +--- + +## Конфигурация + +Конфиг ищется автоматически при запуске: + +| ОС | Путь | +|----|------| +| Linux | `~/.config/grimoir/grimoir.conf` | +| macOS | `~/Library/Application Support/grimoir/grimoir.conf` | +| Windows | `%APPDATA%\grimoir\grimoir.conf` | + +Также поддерживается локальный `./grimoir.conf` в текущей директории — имеет наивысший приоритет. + +Создать конфиг с дефолтными настройками: + +```bash +./grimoir --write-config +``` + +### Пример конфига + +```ini +[settings] +tree_width = 28 +preview_width = 42 +line_numbers = true +tab_width = 4 +autosave = 60 +show_tree = false +show_preview = false + +[keys] +quit = ctrl+c +toggle_tree = ctrl+b +focus_switch = tab +panel_shrink = < +panel_grow = > +toggle_preview = " " +redo = ctrl+r +``` + +### Управление конфигом + +``` +:config show — откуда загружен конфиг и текущие значения +:config paths — список путей поиска +:config write — записать дефолтный конфиг +:config reload — перезагрузить без перезапуска +``` + +Настройки можно менять и на лету: + +``` +:set number / nonumber — номера строк +:set tab_width=2 — ширина таба +:set autosave=30 — автосейв каждые 30 секунд +:set autosave=0 — выключить автосейв +``` + +--- + +## Флаги запуска + +``` +grimoir [флаги] [файл или URL] + + -f path файл для редактирования + --config path явный путь к конфиг-файлу + --write-config записать дефолтный конфиг и выйти +``` + +--- + +## Сборка + +Требования: **Go 1.21+** + +```bash +go mod tidy +go build -ldflags="-s -w" -o grimoir +``` + +Зависимости: + +| Пакет | Назначение | +|-------|------------| +| `charmbracelet/bubbletea` | TUI фреймворк (Elm-архитектура) | +| `charmbracelet/lipgloss` | стилизация вывода | +| `charmbracelet/bubbles` | компоненты (viewport) | + +Вся остальная функциональность — подсветка синтаксиса, PDF генерация, TTF парсинг, INI парсер, HTML→Markdown конвертер — реализована без внешних зависимостей. diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..05e40f5 --- /dev/null +++ b/commands.go @@ -0,0 +1,525 @@ +package main + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// executeCommand выполняет vim-команду +func (m *EditorModel) executeCommand(cmd string) tea.Cmd { + cmd = strings.TrimSpace(cmd) + + // === Команды с аргументами (проверяем первыми) === + + // :w path — сохранить в другой файл (saveas без смены текущего) + if strings.HasPrefix(cmd, "w ") { + target := strings.TrimSpace(strings.TrimPrefix(cmd, "w ")) + return m.cmdWriteTo(target, false) + } + + // :saveas path / :wa path + if strings.HasPrefix(cmd, "saveas ") { + target := strings.TrimSpace(strings.TrimPrefix(cmd, "saveas ")) + return m.cmdWriteTo(target, true) + } + + // :e path / :edit path — открыть файл + if strings.HasPrefix(cmd, "e ") || strings.HasPrefix(cmd, "edit ") { + target := strings.TrimSpace(cmd) + target = strings.TrimSpace(strings.SplitN(target, " ", 2)[1]) + return m.cmdEdit(target) + } + + // :new path — создать новый файл + if strings.HasPrefix(cmd, "new") { + arg := strings.TrimSpace(strings.TrimPrefix(cmd, "new")) + return m.cmdNew(arg) + } + + // :export pdf [path] + if strings.HasPrefix(cmd, "export") { + arg := strings.TrimSpace(strings.TrimPrefix(cmd, "export")) + return m.cmdExport(arg) + } + + // :fetch URL — скачать статью и открыть в редакторе + if strings.HasPrefix(cmd, "fetch ") { + rawURL := strings.TrimSpace(strings.TrimPrefix(cmd, "fetch ")) + return m.cmdFetch(rawURL) + } + + // :s/old/new/[g] + if strings.HasPrefix(cmd, "s/") { + m.handleSubstituteCommand(cmd) + return nil + } + + // :/pattern + if strings.HasPrefix(cmd, "/") { + m.performSearch(strings.TrimPrefix(cmd, "/")) + return nil + } + + // :set ... + if strings.HasPrefix(cmd, "set") { + m.handleSetCommand(cmd) + return nil + } + + // :config ... + if strings.HasPrefix(cmd, "config") { + m.handleConfigCommand(strings.TrimSpace(strings.TrimPrefix(cmd, "config"))) + return nil + } + + // :treewidth N / :tw N + if strings.HasPrefix(cmd, "treewidth ") || strings.HasPrefix(cmd, "tw ") { + parts := strings.Fields(cmd) + if len(parts) == 2 { + if n, err := strconv.Atoi(parts[1]); err == nil { + m.treeWidth = n + m.clampPanelWidths() + m.SetStatusMsg(fmt.Sprintf("Tree width: %d", m.treeWidth)) + return nil + } + } + m.SetStatusMsg("Usage: treewidth <N>") + return nil + } + + // :previewwidth N / :pw N + if strings.HasPrefix(cmd, "previewwidth ") || strings.HasPrefix(cmd, "pw ") { + parts := strings.Fields(cmd) + if len(parts) == 2 { + if n, err := strconv.Atoi(parts[1]); err == nil { + m.previewWidth = n + m.clampPanelWidths() + m.SetStatusMsg(fmt.Sprintf("Preview width: %d", m.previewWidth)) + return nil + } + } + m.SetStatusMsg("Usage: previewwidth <N>") + return nil + } + + // === Простые команды === + switch cmd { + case "help", "h": + m.showHelp = true + return nil + + case "w": + if err := m.save(); err != nil { + m.SetStatusMsg("Error: " + err.Error()) + } else { + m.SetStatusMsg(fmt.Sprintf("Saved: %s", m.filepath)) + } + return nil + + case "q": + if m.dirty { + m.SetStatusMsg("No write since last change (add ! to override)") + } else { + return tea.Quit + } + return nil + + case "q!": + return tea.Quit + + case "wq", "x": + if err := m.save(); err != nil { + m.SetStatusMsg("Error: " + err.Error()) + } else { + return tea.Quit + } + return nil + + case "u": + m.Undo() + return nil + + case "tree": + m.showFileTree = true + m.fileTree.reload() + m.focus = FocusFileTree + m.fileTree.focused = true + m.clampPanelWidths() + m.SetStatusMsg(fmt.Sprintf("File tree ON (width: %d)", m.treeWidth)) + return nil + + case "notree": + m.showFileTree = false + m.focus = FocusEditor + m.fileTree.focused = false + m.SetStatusMsg("File tree OFF") + return nil + + case "new": + return m.cmdNew("") + + case "export", "export pdf": + return m.cmdExport("pdf") + } + + // :N — перейти на строку + if len(cmd) > 0 && cmd[0] >= '0' && cmd[0] <= '9' { + lineNum := 0 + if _, err := fmt.Sscanf(cmd, "%d", &lineNum); err == nil { + if lineNum > 0 && lineNum <= len(m.lines) { + m.cursorY = lineNum - 1 + m.cursorX = 0 + m.SetStatusMsg(fmt.Sprintf("Line %d", lineNum)) + return nil + } + m.SetStatusMsg(fmt.Sprintf("Invalid line: %d", lineNum)) + return nil + } + } + + m.SetStatusMsg("Not an editor command: " + cmd) + return nil +} + +// === Реализации команд === + +// cmdWriteTo сохраняет в указанный путь. +// changeFilepath=true означает ":saveas" — меняем текущий файл. +// changeFilepath=false означает ":w path" — копия, текущий файл не меняется. +func (m *EditorModel) cmdWriteTo(target string, changeFilepath bool) tea.Cmd { + if target == "" { + m.SetStatusMsg("Usage: w <path> or saveas <path>") + return nil + } + target = expandPath(target) + + // Сохраняем под новым именем + content := strings.Join(m.lines, "\n") + if err := writeFileImpl(target, content); err != nil { + m.SetStatusMsg("Error: " + err.Error()) + return nil + } + + if changeFilepath { + // saveas — переключаемся на новый файл + m.filepath = target + m.fileType = DetectFileType(target) + m.dirty = false + m.cfgShowPreview = m.cfgShowPreview + m.showPreview = m.fileType == "md" && m.cfgShowPreview + m.clampPanelWidths() + m.updatePreview() + m.SetStatusMsg(fmt.Sprintf("Saved as: %s", target)) + } else { + // w path — просто копия + m.SetStatusMsg(fmt.Sprintf("Written to: %s", target)) + } + return nil +} + +// cmdEdit открывает файл в редакторе (как :e в vim) +func (m *EditorModel) cmdEdit(target string) tea.Cmd { + if target == "" { + // :e без аргумента — перезагрузить текущий файл + if m.dirty { + m.SetStatusMsg("No write since last change (add ! to override with :e!)") + return nil + } + m.openFile(m.filepath) + m.SetStatusMsg("Reloaded: " + m.filepath) + return nil + } + + target = expandPath(target) + + if m.dirty { + m.SetStatusMsg(fmt.Sprintf("Save changes first (:w), or use :e! to discard — opening %s", target)) + // Открываем всё равно, как vim с confirm + } + + m.openFile(target) + return nil +} + +// cmdNew создаёт новый пустой файл +func (m *EditorModel) cmdNew(name string) tea.Cmd { + if m.dirty { + m.SetStatusMsg("Unsaved changes! Save first (:w) or discard (:q!), then :new") + return nil + } + + if name == "" { + // Без имени — открываем пустой буфер untitled + m.lines = []string{""} + m.filepath = "untitled.md" + m.fileType = "md" + m.cursorX = 0 + m.cursorY = 0 + m.dirty = false + m.undoStack = nil + m.redoStack = nil + m.searchMatches = nil + m.showPreview = m.cfgShowPreview + m.mode = NORMAL + m.updatePreview() + m.SetStatusMsg("New file (save with :w <name>)") + return nil + } + + name = expandPath(name) + + // Если файл уже существует — открываем его + content, err := LoadFile(name) + if err != nil { + m.SetStatusMsg("Error: " + err.Error()) + return nil + } + + m.openFile(name) + if content == "" { + m.SetStatusMsg(fmt.Sprintf("New file: %s", name)) + } else { + m.SetStatusMsg(fmt.Sprintf("Opened: %s", name)) + } + return nil +} + +// cmdExport экспортирует текущий файл +func (m *EditorModel) cmdExport(arg string) tea.Cmd { + parts := strings.Fields(arg) + + format := "pdf" + outputPath := "" + + if len(parts) >= 1 { + format = strings.ToLower(parts[0]) + } + if len(parts) >= 2 { + outputPath = expandPath(parts[1]) + } + + switch format { + case "pdf": + if m.fileType != "md" { + m.SetStatusMsg("PDF export only supported for .md files") + return nil + } + if outputPath == "" { + outputPath = defaultPDFPath(m.filepath) + } + m.SetStatusMsg(fmt.Sprintf("Exporting PDF: %s ...", outputPath)) + if err := ExportToPDF(m.lines, outputPath); err != nil { + m.SetStatusMsg("Export error: " + err.Error()) + } else { + m.SetStatusMsg(fmt.Sprintf("Exported: %s", outputPath)) + } + + default: + m.SetStatusMsg(fmt.Sprintf("Unknown export format: %s (supported: pdf)", format)) + } + return nil +} + +// === Вспомогательные === + +// cmdFetch скачивает статью по URL и открывает в редакторе +func (m *EditorModel) cmdFetch(rawURL string) tea.Cmd { + if rawURL == "" { + m.SetStatusMsg("Usage: fetch <URL>") + return nil + } + if !IsURL(rawURL) { + m.SetStatusMsg("Invalid URL (must start with http:// or https://)") + return nil + } + + m.SetStatusMsg(fmt.Sprintf("Fetching: %s ...", rawURL)) + + result, err := FetchAndConvert(rawURL) + if err != nil { + m.SetStatusMsg("Fetch error: " + err.Error()) + return nil + } + + savedPath, err := SaveFetchResult(result, "") + if err != nil { + m.SetStatusMsg("Save error: " + err.Error()) + return nil + } + + m.openFile(savedPath) + m.SetStatusMsg(fmt.Sprintf("Fetched: %s → %s", result.Title, savedPath)) + return nil +} + +// expandPath разворачивает ~ в домашнюю директорию +func expandPath(path string) string { + if strings.HasPrefix(path, "~/") || path == "~" { + if home, err := userHomeDir(); err == nil { + path = filepath.Join(home, path[1:]) + } + } + return path +} + +// handleConfigCommand обрабатывает :config <subcommand> +func (m *EditorModel) handleConfigCommand(sub string) { + switch sub { + case "", "show": + m.SetStatusMsg(fmt.Sprintf( + "Config: %s | tw=%d pw=%d nu=%v tab=%d autosave=%ds", + m.config.LoadedFrom, m.treeWidth, m.previewWidth, + m.showLineNumbers, m.tabWidth, m.autosaveInterval, + )) + case "paths": + paths := ConfigPaths() + m.SetStatusMsg("Search paths: " + strings.Join(paths[:min3(2, len(paths))], ", ") + " ...") + case "write": + paths := ConfigPaths() + target := "" + for _, p := range paths { + if p != paths[0] { + target = p + break + } + } + if target == "" && len(paths) > 0 { + target = paths[0] + } + if err := WriteDefaultConfig(target); err != nil { + m.SetStatusMsg("Error writing config: " + err.Error()) + } else { + m.SetStatusMsg("Config written: " + target) + } + case "reload": + cfg := LoadConfig() + m.ApplyConfig(cfg) + m.SetStatusMsg("Config reloaded from: " + cfg.LoadedFrom) + default: + m.SetStatusMsg("Usage: :config [show|paths|write|reload]") + } +} + +// handleSubstituteCommand обрабатывает :s/old/new/[g] +func (m *EditorModel) handleSubstituteCommand(cmd string) { + parts := strings.Split(cmd, "/") + if len(parts) < 3 { + m.SetStatusMsg("Usage: s/pattern/replacement/[g]") + return + } + old := parts[1] + newStr := parts[2] + flags := "" + if len(parts) > 3 { + flags = parts[3] + } + if old == "" { + m.SetStatusMsg("Empty pattern") + return + } + m.saveUndoSnapshot() + if strings.Contains(flags, "g") { + count := m.replaceAll(old, newStr) + m.SetStatusMsg(fmt.Sprintf("Replaced %d occurrence(s)", count)) + } else { + count := m.replaceInLine(old, newStr) + if count > 0 { + m.SetStatusMsg(fmt.Sprintf("Replaced %d occurrence(s)", count)) + } else { + m.SetStatusMsg("Pattern not found") + } + } +} + +// handleSetCommand обрабатывает :set option[=value] +func (m *EditorModel) handleSetCommand(cmd string) { + rest := strings.TrimSpace(strings.TrimPrefix(cmd, "set")) + if rest == "" { + m.SetStatusMsg(fmt.Sprintf( + "nu=%v preview=%v tree=%v tw=%d pw=%d tab=%d autosave=%d", + m.showLineNumbers, m.showPreview, m.showFileTree, + m.treeWidth, m.previewWidth, m.tabWidth, m.autosaveInterval, + )) + return + } + + switch rest { + case "number", "nu": + m.showLineNumbers = true + m.SetStatusMsg("Line numbers ON") + case "nonumber", "nonu": + m.showLineNumbers = false + m.SetStatusMsg("Line numbers OFF") + case "preview": + if m.fileType == "md" { + m.showPreview = true + m.clampPanelWidths() + m.SetStatusMsg("Preview ON") + } else { + m.SetStatusMsg("Preview only for .md files") + } + case "nopreview": + m.showPreview = false + m.SetStatusMsg("Preview OFF") + default: + if eqIdx := strings.Index(rest, "="); eqIdx != -1 { + key := rest[:eqIdx] + val := rest[eqIdx+1:] + switch key { + case "tabstop", "ts", "tab_width", "tabwidth": + n, err := strconv.Atoi(val) + if err != nil || n < 1 || n > 16 { + m.SetStatusMsg("tab_width must be 1–16") + return + } + m.tabWidth = n + m.SetStatusMsg(fmt.Sprintf("Tab width: %d", n)) + case "autosave": + n, err := strconv.Atoi(val) + if err != nil || n < 0 { + m.SetStatusMsg("autosave must be >= 0") + return + } + m.autosaveInterval = n + if n == 0 { + m.SetStatusMsg("Autosave OFF") + } else { + m.SetStatusMsg(fmt.Sprintf("Autosave every %ds", n)) + } + case "treewidth", "tw": + n, err := strconv.Atoi(val) + if err != nil { + m.SetStatusMsg("Expected integer") + return + } + m.treeWidth = n + m.clampPanelWidths() + m.SetStatusMsg(fmt.Sprintf("Tree width: %d", m.treeWidth)) + case "previewwidth", "pw": + n, err := strconv.Atoi(val) + if err != nil { + m.SetStatusMsg("Expected integer") + return + } + m.previewWidth = n + m.clampPanelWidths() + m.SetStatusMsg(fmt.Sprintf("Preview width: %d", m.previewWidth)) + default: + m.SetStatusMsg("Unknown option: " + key) + } + } else { + m.SetStatusMsg("Unknown option: " + rest) + } + } +} + +func min3(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..480f461 --- /dev/null +++ b/config.go @@ -0,0 +1,432 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +// Config хранит все настройки редактора +type Config struct { + // [settings] + TreeWidth int + PreviewWidth int + LineNumbers bool + TabWidth int + AutosaveInterval int // секунды (0 = выключен) + ShowPreview bool + ShowTree bool + Theme string + + // [keys] — map: действие → клавиша + // Например: "toggle_tree" → "ctrl+b" + Keys map[string]string + + // Путь откуда загружен конфиг (для отображения в :set) + LoadedFrom string +} + +// DefaultConfig возвращает конфиг со значениями по умолчанию +func DefaultConfig() Config { + return Config{ + TreeWidth: 25, + PreviewWidth: 40, + LineNumbers: true, + TabWidth: 4, + AutosaveInterval: 60, + ShowPreview: false, // включается автоматически для .md + ShowTree: false, + Theme: "default", + Keys: defaultKeys(), + } +} + +// defaultKeys возвращает стандартные биндинги +func defaultKeys() map[string]string { + return map[string]string{ + "quit": "ctrl+c", + "quit_alt": "ctrl+d", + "redo": "ctrl+r", + "toggle_tree": "ctrl+b", + "focus_switch": "tab", + "panel_shrink": "<", + "panel_grow": ">", + "toggle_preview": " ", // пробел + } +} + +// ActionForKey возвращает имя действия для заданной клавиши, +// или "" если биндинг не найден +func (c *Config) ActionForKey(key string) string { + for action, boundKey := range c.Keys { + if boundKey == key { + return action + } + } + return "" +} + +// KeyForAction возвращает клавишу для действия, или дефолтную если не переопределена +func (c *Config) KeyForAction(action string) string { + if k, ok := c.Keys[action]; ok { + return k + } + return defaultKeys()[action] +} + +// === Поиск файла конфига === + +// configSearchPaths возвращает список путей для поиска конфига, +// в порядке приоритета (первый найденный используется) +func configSearchPaths() []string { + var paths []string + appName := "md-editor" + fileName := "md-editor.conf" + + switch runtime.GOOS { + case "windows": + // %APPDATA%\md-editor\md-editor.conf + // %USERPROFILE%\md-editor.conf + if appData := os.Getenv("APPDATA"); appData != "" { + paths = append(paths, filepath.Join(appData, appName, fileName)) + } + if userProfile := os.Getenv("USERPROFILE"); userProfile != "" { + paths = append(paths, filepath.Join(userProfile, fileName)) + } + + case "darwin": + // ~/Library/Application Support/md-editor/md-editor.conf + // ~/.config/md-editor/md-editor.conf + // ~/.md-editor.conf + if home, err := os.UserHomeDir(); err == nil { + paths = append(paths, + filepath.Join(home, "Library", "Application Support", appName, fileName), + filepath.Join(home, ".config", appName, fileName), + filepath.Join(home, "."+fileName), + ) + } + + default: // linux и прочие Unix + // $XDG_CONFIG_HOME/md-editor/md-editor.conf (если задан) + // ~/.config/md-editor/md-editor.conf (XDG default) + // ~/.md-editor.conf + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + paths = append(paths, filepath.Join(xdg, appName, fileName)) + } + if home, err := os.UserHomeDir(); err == nil { + paths = append(paths, + filepath.Join(home, ".config", appName, fileName), + filepath.Join(home, "."+fileName), + ) + } + } + + // os.UserConfigDir() как дополнительный fallback (кроссплатформенный) + if dir, err := os.UserConfigDir(); err == nil { + candidate := filepath.Join(dir, appName, fileName) + if !containsPath(paths, candidate) { + paths = append(paths, candidate) + } + } + + // Локальный конфиг в текущей директории (наивысший приоритет — добавляем первым) + localPath := fileName + if abs, err := filepath.Abs(localPath); err == nil { + localPath = abs + } + return append([]string{localPath}, paths...) +} + +func containsPath(paths []string, p string) bool { + for _, existing := range paths { + if existing == p { + return true + } + } + return false +} + +// === Загрузка конфига === + +// LoadConfig ищет и загружает конфиг. Возвращает DefaultConfig если файл не найден. +func LoadConfig() Config { + cfg := DefaultConfig() + + for _, path := range configSearchPaths() { + data, err := os.ReadFile(path) + if err != nil { + continue + } + if err := parseConfig(data, &cfg); err != nil { + // Файл найден но с ошибками — сообщим при запуске + cfg.LoadedFrom = path + " (parse error: " + err.Error() + ")" + } else { + cfg.LoadedFrom = path + } + return cfg + } + + cfg.LoadedFrom = "(defaults, config not found)" + return cfg +} + +// ConfigPaths возвращает список путей поиска (для команды :config paths) +func ConfigPaths() []string { + return configSearchPaths() +} + +// parseConfig разбирает INI-файл и заполняет cfg +func parseConfig(data []byte, cfg *Config) error { + scanner := bufio.NewScanner(strings.NewReader(string(data))) + section := "" + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + // Пропускаем пустые строки и комментарии + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + + // Секция [name] + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.ToLower(line[1 : len(line)-1]) + continue + } + + // Пара key = value + eqIdx := strings.IndexByte(line, '=') + if eqIdx == -1 { + return fmt.Errorf("line %d: missing '=' in %q", lineNum, line) + } + + key := strings.TrimSpace(line[:eqIdx]) + value := strings.TrimSpace(line[eqIdx+1:]) + + // Удаляем инлайн-комментарии (# или ; после значения) + if idx := strings.IndexAny(value, "#;"); idx != -1 { + value = strings.TrimSpace(value[:idx]) + } + + key = strings.ToLower(key) + + switch section { + case "settings": + if err := applySettingKey(cfg, key, value, lineNum); err != nil { + return err + } + case "keys": + if err := applyKeyBinding(cfg, key, value, lineNum); err != nil { + return err + } + case "": + // Секция не задана — пробуем применить как settings + if err := applySettingKey(cfg, key, value, lineNum); err != nil { + return fmt.Errorf("line %d: unknown key %q (no section specified)", lineNum, key) + } + default: + // Неизвестная секция — игнорируем + } + } + + return scanner.Err() +} + +// applySettingKey применяет одну настройку из секции [settings] +func applySettingKey(cfg *Config, key, value string, line int) error { + switch key { + case "tree_width", "treewidth": + n, err := parseInt(value, line) + if err != nil { + return err + } + cfg.TreeWidth = n + + case "preview_width", "previewwidth": + n, err := parseInt(value, line) + if err != nil { + return err + } + cfg.PreviewWidth = n + + case "line_numbers", "linenumbers", "nu", "number": + b, err := parseBool(value, line) + if err != nil { + return err + } + cfg.LineNumbers = b + + case "tab_width", "tabwidth", "tabstop", "ts": + n, err := parseInt(value, line) + if err != nil { + return err + } + if n < 1 || n > 16 { + return fmt.Errorf("line %d: tab_width must be 1–16", line) + } + cfg.TabWidth = n + + case "autosave", "autosave_interval": + n, err := parseInt(value, line) + if err != nil { + return err + } + cfg.AutosaveInterval = n + + case "show_preview", "preview": + b, err := parseBool(value, line) + if err != nil { + return err + } + cfg.ShowPreview = b + + case "show_tree", "tree": + b, err := parseBool(value, line) + if err != nil { + return err + } + cfg.ShowTree = b + + case "theme": + cfg.Theme = value + + default: + return fmt.Errorf("line %d: unknown setting %q", line, key) + } + return nil +} + +// applyKeyBinding применяет один биндинг из секции [keys] +func applyKeyBinding(cfg *Config, action, key string, line int) error { + // Нормализуем имя действия + action = strings.ToLower(strings.ReplaceAll(action, "-", "_")) + + // Проверяем что действие известно + known := []string{ + "quit", "quit_alt", "redo", "toggle_tree", "focus_switch", + "panel_shrink", "panel_grow", "toggle_preview", + } + for _, a := range known { + if a == action { + cfg.Keys[action] = key + return nil + } + } + // Неизвестное действие — предупреждаем но не падаем + return fmt.Errorf("line %d: unknown action %q", line, action) +} + +// === Хелперы парсинга === + +func parseInt(s string, line int) (int, error) { + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("line %d: expected integer, got %q", line, s) + } + return n, nil +} + +func parseBool(s string, line int) (bool, error) { + switch strings.ToLower(s) { + case "true", "yes", "on", "1": + return true, nil + case "false", "no", "off", "0": + return false, nil + } + return false, fmt.Errorf("line %d: expected bool (true/false/yes/no/on/off/1/0), got %q", line, s) +} + +// === Сохранение конфига === + +// WriteDefaultConfig записывает дефолтный конфиг с комментариями в path +func WriteDefaultConfig(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + content := `# md-editor configuration file +# Format: key = value +# Comments start with # or ; + +[settings] + +# Ширина панели файлового дерева (символы) +tree_width = 25 + +# Ширина панели preview (символы) +preview_width = 40 + +# Показывать номера строк (true/false) +line_numbers = true + +# Ширина табуляции (1–16) +tab_width = 4 + +# Автосохранение каждые N секунд (0 = выключено) +autosave = 60 + +# Показывать preview при открытии .md файлов (true/false) +show_preview = false + +# Показывать файловое дерево при запуске (true/false) +show_tree = false + +# Тема (default — единственная пока) +theme = default + + +[keys] + +# Формат: action = key +# Примеры клавиш: ctrl+c, ctrl+b, tab, f1, alt+t, <, >, " " (пробел) + +# Выход +quit = ctrl+c +quit_alt = ctrl+d + +# Redo +redo = ctrl+r + +# Показать/скрыть файловое дерево +toggle_tree = ctrl+b + +# Переключение фокуса между панелями +focus_switch = tab + +# Уменьшить/увеличить ширину активной панели +panel_shrink = < +panel_grow = > + +# Показать/скрыть preview (только для .md) +toggle_preview = " " +` + return os.WriteFile(path, []byte(content), 0644) +} + +// === Применение конфига к модели === + +// ApplyConfig применяет загруженный конфиг к EditorModel +func (m *EditorModel) ApplyConfig(cfg Config) { + m.treeWidth = cfg.TreeWidth + m.previewWidth = cfg.PreviewWidth + m.showLineNumbers = cfg.LineNumbers + m.tabWidth = cfg.TabWidth + m.autosaveInterval = cfg.AutosaveInterval + + if cfg.ShowTree { + m.showFileTree = true + m.fileTree.reload() + } + // ShowPreview из конфига применяется только если тип файла md — + // это проверяется при открытии файла, здесь только сохраняем флаг + m.cfgShowPreview = cfg.ShowPreview + + m.config = cfg + m.clampPanelWidths() +} diff --git a/fetch.go b/fetch.go new file mode 100644 index 0000000..512175c --- /dev/null +++ b/fetch.go @@ -0,0 +1,551 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" + "unicode" +) + +// FetchResult — результат загрузки и конвертации страницы +type FetchResult struct { + Title string + Markdown string + Filename string // предлагаемое имя файла + URL string +} + +// IsURL проверяет что строка является HTTP(S) ссылкой +func IsURL(s string) bool { + s = strings.TrimSpace(s) + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// FetchAndConvert скачивает страницу и конвертирует в Markdown +func FetchAndConvert(rawURL string) (*FetchResult, error) { + // Нормализуем URL + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + if u.Scheme == "" { + u.Scheme = "https" + } + + // Скачиваем + html, err := fetchHTML(u.String()) + if err != nil { + return nil, fmt.Errorf("fetch error: %w", err) + } + + // Парсим и конвертируем + title := extractTitle(html) + contentHTML := extractMainContent(html) + markdown := htmlToMarkdown(contentHTML, u.String()) + + // Добавляем заголовок и источник в начало документа + header := fmt.Sprintf("# %s\n\n> Источник: [%s](%s)\n> Дата: %s\n\n---\n\n", + title, u.Host, u.String(), time.Now().Format("02.01.2006"), + ) + + result := &FetchResult{ + Title: title, + Markdown: header + markdown, + Filename: slugify(title) + ".md", + URL: u.String(), + } + return result, nil +} + +// fetchHTML загружает HTML страницу +func fetchHTML(rawURL string) (string, error) { + client := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + + req, err := http.NewRequest("GET", rawURL, nil) + if err != nil { + return "", err + } + + // Представляемся нормальным браузером + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; grimoir/1.0; +https://github.com/grimoir)") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "ru,en;q=0.9") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + } + + // Ограничиваем 5 MB + body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) + if err != nil { + return "", err + } + + return string(body), nil +} + +// === Извлечение контента === + +// extractTitle извлекает заголовок страницы +func extractTitle(html string) string { + // Пробуем og:title (обычно чище) + ogTitle := reFind(html, `(?i)<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']`) + if ogTitle == "" { + ogTitle = reFind(html, `(?i)<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']`) + } + if ogTitle != "" { + return cleanText(ogTitle) + } + + // Обычный <title> + title := reFind(html, `(?i)<title[^>]*>([^<]+)`) + if title != "" { + return cleanText(title) + } + + // H1 + h1 := reFind(html, `(?i)]*>([^<]+)`) + if h1 != "" { + return cleanText(h1) + } + + return "article" +} + +// extractMainContent выбирает основной контент статьи из HTML +func extractMainContent(html string) string { + // Удаляем ненужные секции + html = removeTagContent(html, "script") + html = removeTagContent(html, "style") + html = removeTagContent(html, "nav") + html = removeTagContent(html, "header") + html = removeTagContent(html, "footer") + html = removeTagContent(html, "aside") + html = removeTagContent(html, "noscript") + html = removeTagContent(html, "iframe") + html = removeTagAttr(html, "onclick|onload|onmouseover|onmouseout") + + // Кандидаты — семантические теги в порядке приоритета + candidates := []struct { + pattern string + }{ + {`(?is)]*>(.*?)`}, + {`(?is)]*>(.*?)`}, + {`(?is)]+role=["']main["'][^>]*>(.*?)`}, + {`(?is)]+class=["'][^"']*(?:article|post|content|entry|story|text|body)[^"']*["'][^>]*>(.*?)`}, + {`(?is)]+id=["'][^"']*(?:article|post|content|entry|story|main)[^"']*["'][^>]*>(.*?)`}, + } + + for _, c := range candidates { + if m := regexp.MustCompile(c.pattern).FindStringSubmatch(html); m != nil { + if len(stripTags(m[1])) > 200 { // минимум контента + return m[1] + } + } + } + + // Fallback: ищем div с максимальной плотностью текста + return findDenseTextBlock(html) +} + +// findDenseTextBlock ищет блок с наибольшим количеством текста +func findDenseTextBlock(html string) string { + divRe := regexp.MustCompile(`(?is)]*>(.*?)`) + matches := divRe.FindAllStringSubmatch(html, -1) + + best := "" + bestLen := 0 + for _, m := range matches { + text := stripTags(m[1]) + if len(text) > bestLen { + bestLen = len(text) + best = m[1] + } + } + + if best != "" { + return best + } + return html // совсем fallback — весь HTML +} + +// === HTML → Markdown конвертер === + +func htmlToMarkdown(html, baseURL string) string { + // Нормализуем переносы строк + html = strings.ReplaceAll(html, "\r\n", "\n") + html = strings.ReplaceAll(html, "\r", "\n") + + var sb strings.Builder + converter := &mdConverter{baseURL: baseURL, sb: &sb} + converter.convert(html) + + // Чистим результат + result := sb.String() + result = cleanMarkdown(result) + return result +} + +type mdConverter struct { + baseURL string + sb *strings.Builder + inPre bool + inCode bool + listDepth int + orderedList bool + listCounter int +} + +func (c *mdConverter) convert(html string) { + // Обрабатываем тег за тегом + tagRe := regexp.MustCompile(`(?is)(<[^>]+>)|([^<]+)`) + matches := tagRe.FindAllStringSubmatch(html, -1) + + for _, m := range matches { + tag := m[1] + text := m[2] + + if tag != "" { + c.handleTag(tag) + } else if text != "" { + c.handleText(text) + } + } +} + +func (c *mdConverter) handleTag(tag string) { + tag = strings.TrimSpace(tag) + if strings.HasPrefix(tag, "