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:
+280
-36
@@ -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 += "<"; break;
|
||||
case '>': out += ">"; break;
|
||||
case '"': out += """; break;
|
||||
case '\'': out += "'"; 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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user