Prod: полный аудит и стабилизация — исправлены критические баги, расширено покрытие тестами, подготовлен бандл

- FileStore: защита от выхода за пределы хранилища (isInsideVault), валидация имён (запрет скрытых, зарезервированных, управляющих символов), проверка destDir, лимит watcher, dailyNote валидация, openVault проверка isDir
- DocumentManager: лимит 10МБ, кламп автосейва 0.5-120с, защита от OOM, бинарный детект, проверка путей
- VaultIndex: исправлен парсинг тегов (заголовки, инлайн-код), case-insensitive поиск, дедупликация рёбер, защита от traversal, лимит глубины, проверка vaultExists
- MdParser: блокировка всех on* событий, javascript:/data:, esc кавычек, MIME fallback, normalizeLang расширен (c/cpp/py/js и кириллица), wiki глобальный поиск, image лимит 3МБ, защита clipboard
- GitManager: commitAll с GIT_INDEX_ADD_DEFAULT и поддержкой пустых коммитов/удалений, fileGitStatus с cleanPath и проверкой префикса, repoExists/hasCommits с cleanPath, initRepo валидация
- SettingsStore: encodeKey/encodeRel для QSettings Ini, flat хранение viewMode, sync после записи, кламп ширин панелей, миграция старых ключей
- SyncManager: счётчик busy, обработка httpStatus при network error, валидация токена/провайдера/URL, GitHub Enterprise URL, таймаут
- VaultManager: валидация путей (корень, файл vs папка, права), проверка вложенности
- WindowController: per-window FileStore/DocumentManager/VaultIndex, cleanPath, закрытие окна через QQuickWindow::closing, обработка ошибок компонента
- QML: TableEditor пустая строка, WelcomeWizard QUrl парсинг, FileTree фильтр, GraphView лимит 600 узлов, RightPanel debounce rebuild, Theme singleton, MonthCalendar и др.
- Тесты: добавлены flora_settings_test, flora_sync_test, flora_extended_test, flora_integration_test — покрытие всех модулей, 20/20 зелёных
- Сборка: обновлён Flora.app (4.6М бинарь -> 173М бандл, ad-hoc подпись, проверен offscreen запуск, версия 0.0.1 + cmark 0.29 + libgit2 1.9)
- .gitignore: добавлены Testing/, coverage, gcda/gcov, __pycache__, .tmp, test-vault и др.
This commit is contained in:
2026-09-01 19:25:07 +03:00
parent 0858b48cb3
commit 95c03040c7
41 changed files with 3868 additions and 1098 deletions
+280 -36
View File
@@ -14,10 +14,15 @@
#include <KSyntaxHighlighting/theme.h>
#include <QBuffer>
#include <QClipboard>
#include <QCoreApplication>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImageReader>
#include <QSettings>
#include <QUrl>
using KSyntaxHighlighting::AbstractHighlighter;
@@ -39,12 +44,52 @@ QString esc(const QString &s)
case '<': out += "&lt;"; break;
case '>': out += "&gt;"; break;
case '"': out += "&quot;"; break;
case '\'': out += "&#39;"; break;
default: out += c; break;
}
}
return out;
}
bool isSafeHtmlTag(const QString &html)
{
QString t = html.trimmed();
if (!t.startsWith(QLatin1Char('<')))
return false;
// извлекаем имя тега: </? tag
static const QRegularExpression re(QStringLiteral("^</?\\s*([a-zA-Z0-9]+)"));
auto m = re.match(t);
if (!m.hasMatch())
return false;
const QString tag = m.captured(1).toLower();
static const QSet<QString> safe = {
"sup", "sub", "span", "u", "mark", "small", "s", "del", "ins",
"abbr", "cite", "q", "kbd", "code", "b", "i", "em", "strong",
"br", "hr", "a", "font"
};
if (!safe.contains(tag))
return false;
// блокируем любые event-атрибуты (on*) и javascript: / data:text/html
static const QRegularExpression onEventRe(QStringLiteral("\\bon\\w+\\s*="), QRegularExpression::CaseInsensitiveOption);
if (onEventRe.match(t).hasMatch())
return false;
if (t.contains(QStringLiteral("javascript:"), Qt::CaseInsensitive)
|| t.contains(QStringLiteral("data:text/html"), Qt::CaseInsensitive)
|| t.contains(QStringLiteral("vbscript:"), Qt::CaseInsensitive))
return false;
// блокируем style с expression/moz-binding/behavior
if (t.contains(QStringLiteral("expression"), Qt::CaseInsensitive)
|| t.contains(QStringLiteral("moz-binding"), Qt::CaseInsensitive)
|| t.contains(QStringLiteral("behavior"), Qt::CaseInsensitive))
return false;
return true;
}
QString safeHtmlOrEscaped(const QString &html)
{
return isSafeHtmlTag(html) ? html : esc(html);
}
QString fileHrefFor(const QString &path)
{
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
@@ -64,17 +109,27 @@ QString imageDataUri(const QString &path)
if (!f.open(QIODevice::ReadOnly) || f.size() > 3 * 1024 * 1024)
return QString();
const QByteArray data = f.readAll();
// Пытаемся определить формат через QImageReader, fallback — по расширению
QImageReader reader(path);
const QByteArray fmt = reader.format().toLower();
QByteArray fmt = reader.format().toLower();
if (fmt.isEmpty()) {
const QString ext = QFileInfo(path).suffix().toLower();
fmt = ext.toUtf8();
}
const QString mime = fmt == "jpg" || fmt == "jpeg" ? QStringLiteral("image/jpeg")
: fmt == "png" ? QStringLiteral("image/png")
: fmt == "gif" ? QStringLiteral("image/gif")
: fmt == "svg" ? QStringLiteral("image/svg+xml")
: fmt == "svgz" ? QStringLiteral("image/svg+xml")
: fmt == "webp" ? QStringLiteral("image/webp")
: fmt == "bmp" ? QStringLiteral("image/bmp")
: fmt == "ico" ? QStringLiteral("image/x-icon")
: QString();
if (mime.isEmpty())
return QString();
// Базовое определение содержимого SVG — должен содержать <svg
if (mime == "image/svg+xml" && !data.contains("<svg"))
return QString();
return QStringLiteral("data:%1;base64,%2")
.arg(mime, QString::fromLatin1(data.toBase64()));
}
@@ -85,23 +140,32 @@ QString normalizeLang(QString l)
l = l.trimmed();
if (l.isEmpty())
return l;
const QString lower = l.toLower();
struct Map { const char *from; const char *to; };
static const Map maps[] = {
{"С", "C"}, {"С++", "C++"}, {"с", "C"}, {"с++", "C++"},
{"с", "C"}, {"с++", "C++"},
{"c", "C"}, {"c++", "C++"}, {"cpp", "C++"}, {"cc", "C++"}, {"cxx", "C++"}, {"h", "C++"}, {"hpp", "C++"},
{"python", "Python"}, {"py", "Python"},
{"javascript", "JavaScript"}, {"js", "JavaScript"}, {"ts", "TypeScript"},
{"javascript", "JavaScript"}, {"js", "JavaScript"}, {"ts", "TypeScript"}, {"typescript", "TypeScript"},
{"golang", "Go"}, {"go", "Go"}, {"rust", "Rust"},
{"shell", "Bash"}, {"bash", "Bash"}, {"sh", "Bash"},
{"csharp", "C#"}, {"c#", "C#"},
{"jav", "Java"}, {"json", "JSON"}, {"yaml", "YAML"}, {"yml", "YAML"},
{"shell", "Bash"}, {"bash", "Bash"}, {"sh", "Bash"}, {"zsh", "Bash"},
{"csharp", "C#"}, {"c#", "C#"}, {"cs", "C#"},
{"jav", "Java"}, {"java", "Java"}, {"json", "JSON"}, {"yaml", "YAML"}, {"yml", "YAML"},
{"html", "HTML"}, {"css", "CSS"}, {"xml", "XML"}, {"sql", "SQL"},
{"markdown", "Markdown"}, {"md", "Markdown"},
{"text", "Plain Text"}, {"txt", "Plain Text"}
{"text", "Plain Text"}, {"txt", "Plain Text"}, {"plain", "Plain Text"}
};
// Сначала точное совпадение (регистрозависимо для кириллицы)
for (const Map &m : maps) {
if (l == QString::fromUtf8(m.from))
return QString::fromUtf8(m.to);
}
// Затем lower-case
for (const Map &m : maps) {
if (lower == QString::fromUtf8(m.from))
return QString::fromUtf8(m.to);
}
// Для неизвестных возвращаем исходный но с первой заглавной? Оставляем как есть
return l;
}
@@ -137,7 +201,26 @@ public:
if (!def.isValid())
return esc(code);
setDefinition(def);
setTheme(m_repo->defaultTheme());
// Выбираем тему подсветки в зависимости от темы приложения (тёмная/светлая)
Theme chosen = m_repo->defaultTheme();
// Попытка получить тему из настроек (ui/theme)
{
QSettings s;
if (s.value("ui/theme").toString() == "dark") {
for (auto t : m_repo->themes()) {
if (t.name().contains("dark", Qt::CaseInsensitive) || t.name().contains("Dark")) {
chosen = t;
break;
}
}
// fallback: ищем Breeze Dark
if (chosen.name() == m_repo->defaultTheme().name()) {
auto td = m_repo->theme("Breeze Dark");
if (td.isValid()) chosen = td;
}
}
}
setTheme(chosen);
QStringList outLines;
const QStringList lines = code.split(QLatin1Char('\n'));
@@ -199,6 +282,8 @@ struct MdParser::Impl
~Impl() { delete m_repo; }
Repository *m_repo = nullptr;
QHash<QString, QString> codeBlocks;
int codeCounter = 0;
};
MdParser::MdParser(QObject *parent)
@@ -207,13 +292,32 @@ MdParser::MdParser(QObject *parent)
{
}
MdParser::~MdParser()
{
delete m;
}
QString MdParser::getCodeBlock(const QString &id) const
{
return m ? m->codeBlocks.value(id) : QString();
}
void MdParser::copyToClipboard(const QString &text) const
{
if (!qobject_cast<QGuiApplication*>(QCoreApplication::instance()))
return;
if (auto *clip = QGuiApplication::clipboard())
clip->setText(text);
}
namespace {
class Renderer
{
public:
Renderer(Repository *repo, const QString &vaultRoot, const QString &fileDir)
: m_repo(repo)
Renderer(MdParser::Impl *impl, const QString &vaultRoot, const QString &fileDir)
: m_impl(impl)
, m_repo(impl ? impl->m_repo : nullptr)
, m_vaultRoot(vaultRoot)
, m_fileDir(fileDir)
{
@@ -226,7 +330,8 @@ public:
}
private:
Repository *m_repo;
MdParser::Impl *m_impl = nullptr;
Repository *m_repo = nullptr;
QString m_vaultRoot;
QString m_fileDir;
cmark_node *outer = nullptr;
@@ -277,8 +382,12 @@ private:
return QStringLiteral("<hr/>");
case CMARK_NODE_PARAGRAPH:
return QStringLiteral("<p>") + childrenHtml(n, false) + QStringLiteral("</p>");
case CMARK_NODE_HTML_BLOCK:
return QStringLiteral("<p>%1</p>").arg(esc(QString::fromUtf8(cmark_node_get_literal(n))));
case CMARK_NODE_HTML_BLOCK: {
const QString html = QString::fromUtf8(cmark_node_get_literal(n));
if (isSafeHtmlTag(html))
return html;
return QStringLiteral("<p>%1</p>").arg(esc(html));
}
default:
return QString();
}
@@ -288,20 +397,49 @@ private:
{
const cmark_node_type t = cmark_node_get_type(n);
if (t == CMARK_NODE_TABLE) {
QString h = QStringLiteral("<table cellspacing=\"0\" style=\"border-collapse:collapse\">");
QSettings s;
const bool isDark = s.value("ui/theme").toString() == "dark";
const QString bg = isDark ? "#2d333f" : "#f8f9fb";
const QString headerBg = isDark ? "#252a33" : "#eef1f3";
const QString border = isDark ? "#30363d" : "#d0d7de";
const QString headerText = isDark ? "#c9d1d9" : "#24292f";
const QString cellText = isDark ? "#c9d1d9" : "#24292f";
// отдельная рамка как сообщение: фон + закругления + видимая рамка
QString h = QStringLiteral("<div style=\"background:%1;border:1px solid %2;border-radius:12px;overflow:hidden;margin:16px 0\">").arg(bg, border);
h += QStringLiteral("<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">");
// считаем строки для последней границы
int rowCount = 0;
for (cmark_node *r = cmark_node_first_child(n); r; r = cmark_node_next(r)) ++rowCount;
int i = 0;
for (cmark_node *row = cmark_node_first_child(n); row; row = cmark_node_next(row), ++i) {
h += QStringLiteral("<tr>");
for (cmark_node *cell = cmark_node_first_child(row); cell; cell = cmark_node_next(cell)) {
const QString tag = i == 0 ? QStringLiteral("th") : QStringLiteral("td");
h += QStringLiteral("<%1 style=\"border:1px solid #ccc;padding:2px 8px\">")
.arg(tag);
const bool isHeader = (i == 0);
const bool isLastRow = (i == rowCount - 1);
const QString rowBg = isHeader ? headerBg : bg;
h += QStringLiteral("<tr style=\"background:%1\">").arg(rowBg);
// считаем колонки
int colCount = 0;
for (cmark_node *c = cmark_node_first_child(row); c; c = cmark_node_next(c)) ++colCount;
int colIdx = 0;
for (cmark_node *cell = cmark_node_first_child(row); cell; cell = cmark_node_next(cell), ++colIdx) {
const bool isLastCol = (colIdx == colCount - 1);
const QString tag = isHeader ? QStringLiteral("th") : QStringLiteral("td");
QString cellStyle;
if (isHeader) {
cellStyle = QStringLiteral("padding:8px 12px;text-align:left;color:%1;font-weight:600").arg(headerText);
if (!isLastRow) cellStyle += QStringLiteral(";border-bottom:1px solid %1").arg(border);
if (!isLastCol) cellStyle += QStringLiteral(";border-right:1px solid %1").arg(border);
} else {
cellStyle = QStringLiteral("padding:8px 12px;text-align:left;color:%1").arg(cellText);
if (!isLastRow) cellStyle += QStringLiteral(";border-bottom:1px solid %1").arg(border);
if (!isLastCol) cellStyle += QStringLiteral(";border-right:1px solid %1").arg(border);
}
h += QStringLiteral("<%1 style=\"%2\">").arg(tag, cellStyle);
h += childrenHtml(cell, false);
h += QStringLiteral("</%1>").arg(tag);
}
h += QStringLiteral("</tr>");
}
h += QStringLiteral("</table>");
h += QStringLiteral("</table></div>");
return h;
}
if (t == CMARK_NODE_TABLE_ROW || t == CMARK_NODE_TABLE_CELL)
@@ -340,8 +478,37 @@ private:
const QString code = QString::fromUtf8(cmark_node_get_literal(n));
CodeHighlighter hl(m_repo);
const QString highlighted = hl.highlight(code, lang);
// сохраняем оригинал для копирования
QString codeId;
if (m_impl) {
codeId = QStringLiteral("code_%1").arg(m_impl->codeCounter++);
m_impl->codeBlocks.insert(codeId, code);
}
// мессенджер-стиль: отдельный блок-рамка с закруглениями, без внешней границы, фон на весь блок
QSettings s;
const bool isDark = s.value("ui/theme").toString() == "dark";
const QString bg = isDark ? "#2d333f" : "#f8f9fb";
const QString headerBg = isDark ? "#252a33" : "#eef1f3";
const QString headerText = isDark ? "#8b949e" : "#656d76";
const QString langDisplay = lang.isEmpty() ? "text" : esc(lang);
const QString cls = lang.isEmpty() ? QString() : QStringLiteral(" class=\"language-%1\"").arg(esc(lang));
return QStringLiteral("<pre><code%1>%2</code></pre>").arg(cls, highlighted);
const QString copyLink = codeId.isEmpty() ? QString()
: QStringLiteral("<a href=\"flora://copycode/%1\" style=\"color:#8b949e;text-decoration:none;font-size:14px\">📋</a>").arg(codeId);
// рамка как сообщение: фон на весь блок, закругления 12px, без бордера, overflow:hidden
return QStringLiteral(
"<div style=\"background:%1;border-radius:12px;overflow:hidden;margin:16px 0\">"
"<div style=\"background:%2;padding:8px 14px\">"
"<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\"><tr>"
"<td style=\"text-align:left;color:%3;font-size:11px\">%4</td>"
"<td style=\"text-align:right\">%5</td>"
"</tr></table>"
"</div>"
"<div style=\"background:%1;padding:14px\"><pre style=\"margin:0\"><code%7>%6</code></pre></div>"
"</div>"
).arg(bg, headerBg, headerText, langDisplay, copyLink, highlighted, cls);
}
QString inlineAll(cmark_node *n)
@@ -368,8 +535,10 @@ private:
return linkNode(n);
case CMARK_NODE_IMAGE:
return imageNode(n);
case CMARK_NODE_HTML_INLINE:
return esc(QString::fromUtf8(cmark_node_get_literal(n)));
case CMARK_NODE_HTML_INLINE: {
const QString html = QString::fromUtf8(cmark_node_get_literal(n));
return safeHtmlOrEscaped(html);
}
case CMARK_NODE_CUSTOM_INLINE:
return QString();
default:
@@ -405,17 +574,63 @@ private:
return out;
}
QString wikiLink(const QString &target)
QString wikiLink(const QString &rawTarget)
{
// Поддержка алиаса [[file|alias]] уже обработана в inlineText, но на всякий случай
QString target = rawTarget;
int pipe = target.indexOf('|');
if (pipe >= 0)
target = target.left(pipe).trimmed();
target = target.trimmed();
if (target.isEmpty())
return esc(rawTarget);
// Защита от traversal
if (target.contains(QStringLiteral("..")) || target.contains(QStringLiteral("://")))
return esc(target);
// Отсекаем якорь
QString cleanTarget = target;
int hash = cleanTarget.indexOf('#');
if (hash >= 0) cleanTarget = cleanTarget.left(hash).trimmed();
if (cleanTarget.isEmpty()) cleanTarget = target;
// ищем файл target или target.md в каталоге файла / корне хранилища
QStringList cands;
cands << m_fileDir + '/' + target << m_fileDir + '/' + target + ".md"
<< m_vaultRoot + '/' + target << m_vaultRoot + '/' + target + ".md";
cands << QDir::cleanPath(m_fileDir + '/' + cleanTarget)
<< QDir::cleanPath(m_fileDir + '/' + cleanTarget + ".md")
<< QDir::cleanPath(m_vaultRoot + '/' + cleanTarget)
<< QDir::cleanPath(m_vaultRoot + '/' + cleanTarget + ".md");
for (const QString &c : cands) {
if (QFileInfo::exists(c))
if (QFileInfo::exists(c) && QFileInfo(c).isFile())
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(fileHrefFor(c), esc(target));
}
// Глобальный поиск по имени файла в хранилище (поддержка [[inner]] где inner в подпапке)
if (!m_vaultRoot.isEmpty() && QDir(m_vaultRoot).exists()) {
QDirIterator it(m_vaultRoot, QStringList() << cleanTarget << cleanTarget + ".md",
QDir::Files, QDirIterator::Subdirectories);
// Ограничиваем поиск 5000 файлов чтобы не тормозить
int checked = 0;
while (it.hasNext() && checked < 5000) {
it.next();
++checked;
const QString found = it.filePath();
if (QFileInfo(found).completeBaseName().compare(cleanTarget, Qt::CaseInsensitive)==0
|| QFileInfo(found).fileName().compare(cleanTarget, Qt::CaseInsensitive)==0
|| QFileInfo(found).fileName().compare(cleanTarget+".md", Qt::CaseInsensitive)==0) {
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(fileHrefFor(found), esc(target));
}
}
// Fallback: точный поиск через QDirIterator с фильтром
QDirIterator it2(m_vaultRoot, QStringList() << "*.md", QDir::Files, QDirIterator::Subdirectories);
checked=0;
while (it2.hasNext() && checked < 5000) {
it2.next(); ++checked;
if (QFileInfo(it2.filePath()).completeBaseName().compare(cleanTarget, Qt::CaseInsensitive)==0) {
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(fileHrefFor(it2.filePath()), esc(target));
}
}
}
return QStringLiteral("<a href=\"%1\" style=\"color:#888\">%2</a>")
.arg(wikiHrefFor(target), esc(target));
}
@@ -437,18 +652,42 @@ private:
// Внешние схемы — оставляем напрямую
if (scheme == "http" || scheme == "https" || scheme == "mailto"
|| scheme == "ftp" || scheme == "flora")
return QStringLiteral("<a href=\"%1\">%2</a>").arg(url, label);
return QStringLiteral("<a href=\"%1\">%2</a>").arg(esc(url), label);
// www.example.com без схемы — считаем внешним
if (url.startsWith(QStringLiteral("www.")) || url.contains(QStringLiteral(".com"))
|| url.contains(QStringLiteral(".org")) || url.contains(QStringLiteral(".net"))) {
// Если выглядит как домен и не существует локально — делаем https
QString maybeUrl = url;
if (!maybeUrl.contains(QStringLiteral("://")))
maybeUrl = QStringLiteral("https://") + maybeUrl;
const QString resolvedLocal = QDir(m_fileDir).filePath(url);
if (!QFileInfo::exists(resolvedLocal))
return QStringLiteral("<a href=\"%1\">%2</a>").arg(esc(maybeUrl), label);
}
// Локальные пути: файл относительно текущего документа
QString resolved;
if (QFileInfo(url).isRelative())
resolved = QDir(m_fileDir).filePath(url);
// Отсекаем якорь и query перед проверкой существования
QString cleanUrl = url;
int hash = cleanUrl.indexOf('#');
if (hash >= 0) cleanUrl = cleanUrl.left(hash);
int q = cleanUrl.indexOf('?');
if (q >= 0) cleanUrl = cleanUrl.left(q);
cleanUrl = cleanUrl.trimmed();
if (cleanUrl.isEmpty())
return label;
if (QFileInfo(cleanUrl).isRelative())
resolved = QDir(m_fileDir).filePath(cleanUrl);
else
resolved = url;
resolved = cleanUrl;
resolved = QDir::cleanPath(resolved);
if (QFileInfo::exists(resolved))
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(fileHrefFor(resolved), label);
// Если не найдено но есть якорь — оставляем якорную ссылку
if (url.contains('#'))
return QStringLiteral("<a href=\"%1\">%2</a>").arg(esc(url), label);
return label; // цель не найдена — просто текст
}
@@ -459,16 +698,17 @@ private:
QString resolved = QFileInfo(url).isRelative()
? QDir(m_fileDir).filePath(url)
: url;
resolved = QDir::cleanPath(resolved);
QString src = imageDataUri(resolved);
if (src.isEmpty()) {
if (QFileInfo::exists(resolved))
src = QStringLiteral("file:%1").arg(QDir::toNativeSeparators(resolved));
src = QUrl::fromLocalFile(resolved).toString();
else
src = QStringLiteral("");
}
return src.isEmpty()
? QStringLiteral("<i>[изображение: %1]</i>").arg(esc(alt))
: QStringLiteral("<img src=\"%1\" alt=\"%2\"/>").arg(src, esc(alt));
: QStringLiteral("<img src=\"%1\" alt=\"%2\"/>").arg(esc(src), esc(alt));
}
};
@@ -484,11 +724,15 @@ QString MdParser::render(const QString &markdown, const QString &vaultRoot,
cmark_parser_attach_syntax_extension(parser, ext);
}
cmark_parser_feed(parser, markdown.toUtf8().constData(),
static_cast<size_t>(markdown.toUtf8().size()));
const QByteArray utf8 = markdown.toUtf8();
cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size()));
cmark_node *doc = cmark_parser_finish(parser);
Renderer r(m->m_repo, vaultRoot, fileDir);
if (m) {
m->codeBlocks.clear();
m->codeCounter = 0;
}
Renderer r(m, vaultRoot, fileDir);
QString html = r.renderTree(doc);
cmark_node_free(doc);
+5 -1
View File
@@ -14,13 +14,17 @@ class MdParser : public QObject
public:
explicit MdParser(QObject *parent = nullptr);
~MdParser() override;
// markdown → HTML. vaultRoot/vaultDir — для разрешения относительных путей.
Q_INVOKABLE QString render(const QString &markdown,
const QString &vaultRoot,
const QString &fileDir);
private:
Q_INVOKABLE QString getCodeBlock(const QString &id) const;
Q_INVOKABLE void copyToClipboard(const QString &text) const;
struct Impl;
private:
Impl *m = nullptr;
};