From b4350ce59f116b15bee75c2b9ac160e8a56da6a9 Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Thu, 7 May 2026 09:52:06 +0300 Subject: [PATCH] Version 0.1 --- .gitignore | 46 ++ ARCHITECTURE.md | 514 ++++++++++++++++++++++ README.md | 205 +++++++++ commands.go | 525 ++++++++++++++++++++++ config.go | 432 ++++++++++++++++++ fetch.go | 551 +++++++++++++++++++++++ file.go | 28 ++ file_io.go | 54 +++ filetree.go | 307 +++++++++++++ go.mod | 27 ++ go.sum | 41 ++ help.go | 275 ++++++++++++ keybindings.go | 639 +++++++++++++++++++++++++++ main.go | 105 +++++ model.go | 457 +++++++++++++++++++ pdf.go | 905 ++++++++++++++++++++++++++++++++++++++ pdf_font.go | 1113 +++++++++++++++++++++++++++++++++++++++++++++++ render.go | 503 +++++++++++++++++++++ search.go | 284 ++++++++++++ syntax.go | 174 ++++++++ untitled.md | 4 + 21 files changed, 7189 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 README.md create mode 100644 commands.go create mode 100644 config.go create mode 100644 fetch.go create mode 100644 file.go create mode 100644 file_io.go create mode 100644 filetree.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 help.go create mode 100644 keybindings.go create mode 100644 main.go create mode 100644 model.go create mode 100644 pdf.go create mode 100644 pdf_font.go create mode 100644 render.go create mode 100644 search.go create mode 100644 syntax.go create mode 100644 untitled.md 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, "