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
+48 -12
View File
@@ -4,6 +4,7 @@
#include "git/GitManager.h"
#include <QDir>
#include <QFileInfo>
VaultManager::VaultManager(SettingsStore *settings, GitManager *git, QObject *parent)
: QObject(parent)
@@ -26,37 +27,72 @@ QString VaultManager::currentVault() const
return m_settings->currentVault();
}
static bool isValidVaultPath(const QString &clean, QString *err)
{
if (clean.isEmpty()) {
if (err) *err = QStringLiteral("Не указан путь к хранилищу.");
return false;
}
// Запрещаем корень файловой системы и системные папки
if (clean == "/" || clean == "/tmp" || clean == "/etc" || clean == "/usr" || clean == "/var") {
if (err) *err = QStringLiteral("Недопустимый путь к хранилищу: %1").arg(clean);
return false;
}
QFileInfo fi(clean);
if (fi.exists() && !fi.isDir()) {
if (err) *err = QStringLiteral("Путь «%1» существует и не является папкой.").arg(clean);
return false;
}
// Проверяем что путь не внутри другого хранилища (вложенность)
// и родитель существует или может быть создан
return true;
}
QString VaultManager::createVault(const QString &path)
{
if (path.trimmed().isEmpty())
return QStringLiteral("Не указан путь к хранилищу.");
const QString clean = QDir::cleanPath(path.trimmed());
QString err;
if (!isValidVaultPath(clean, &err))
return err;
if (!QDir().mkpath(path))
return QStringLiteral("Не удалось создать папку «%1».").arg(path);
if (!QDir().mkpath(clean))
return QStringLiteral("Не удалось создать папку «%1». Проверьте права доступа.").arg(clean);
if (!m_git->initRepo(path)) {
// Проверяем что папка действительно создана и доступна для записи
QFileInfo fi(clean);
if (!fi.isDir() || !fi.isWritable())
return QStringLiteral("Нет прав записи в папку «%1».").arg(clean);
if (!m_git->initRepo(clean)) {
emit operationError(m_git->lastError());
return m_git->lastError();
}
m_settings->addVault(path);
m_settings->addVault(clean);
return QString();
}
QString VaultManager::openVault(const QString &path)
{
if (path.trimmed().isEmpty())
return QStringLiteral("Не указан путь к хранилищу.");
const QString clean = QDir::cleanPath(path.trimmed());
QString err;
if (!isValidVaultPath(clean, &err))
return err;
if (!QDir(path).exists())
return QStringLiteral("Папка «%1» не существует.").arg(path);
QFileInfo fi(clean);
if (!fi.exists())
return QStringLiteral("Папка «%1» не существует.").arg(clean);
if (!fi.isDir())
return QStringLiteral("Путь «%1» не является папкой.").arg(clean);
if (!fi.isReadable())
return QStringLiteral("Нет прав чтения папки «%1».").arg(clean);
if (!m_git->initRepo(path)) {
if (!m_git->initRepo(clean)) {
emit operationError(m_git->lastError());
return m_git->lastError();
}
m_settings->addVault(path);
m_settings->addVault(clean);
return QString();
}
+56 -7
View File
@@ -1,10 +1,14 @@
#include "app/WindowController.h"
#include <QDir>
#include <QEventLoop>
#include <QQmlComponent>
#include <QQmlContext>
#include <QQuickWindow>
#include "core/DocumentManager.h"
#include "core/FileStore.h"
#include "core/VaultIndex.h"
WindowController::WindowController(QQmlApplicationEngine *engine,
const QString &mainWindowUrl,
@@ -31,8 +35,9 @@ QStringList WindowController::openVaults() const
int WindowController::indexOfVault(const QString &vaultPath) const
{
const QString clean = QDir::cleanPath(vaultPath);
for (int i = 0; i < m_windows.size(); ++i)
if (m_windows[i].vault == vaultPath)
if (QDir::cleanPath(m_windows[i].vault) == clean)
return i;
return -1;
}
@@ -49,37 +54,81 @@ QObject *WindowController::openWindow(const QString &vaultPath)
return nullptr;
}
}
if (m_component->status() != QQmlComponent::Ready) {
if (m_component->isError()) {
qWarning("WindowController: component not ready: %s", qPrintable(m_component->errorString()));
return nullptr;
}
// Если компонент ещё загружается — ждём синхронно (локальный файл, быстро)
qWarning("WindowController: component status %d, waiting", int(m_component->status()));
if (m_component->status() == QQmlComponent::Loading) {
QEventLoop loop;
connect(m_component, &QQmlComponent::statusChanged, &loop, &QEventLoop::quit);
loop.exec();
if (m_component->status() != QQmlComponent::Ready) {
qWarning("WindowController: after wait status %d error %s", int(m_component->status()), qPrintable(m_component->errorString()));
return nullptr;
}
}
}
QQmlContext *ctx = new QQmlContext(m_engine->rootContext(), this);
FileStore *store = new FileStore(ctx);
DocumentManager *docMgr = new DocumentManager(ctx);
VaultIndex *vIndex = new VaultIndex(ctx);
ctx->setContextProperty("fileStore", store);
ctx->setContextProperty("documentManager", docMgr);
ctx->setContextProperty("vaultIndex", vIndex);
QObject *obj = m_component->create(ctx);
if (!obj) {
qWarning("WindowController: create failed: %s",
qPrintable(m_component->errorString()));
ctx->deleteLater();
return nullptr;
}
m_windows.append({vaultPath, obj, store});
connect(obj, &QObject::destroyed, this, [this, obj]() {
// Проверяем что создание дало QQuickWindow
if (auto *win = qobject_cast<QQuickWindow*>(obj)) {
// Связываем закрытие окна с удалением объекта
connect(win, &QQuickWindow::closing, this, [obj](auto *) {
// Отложенное удаление чтобы не нарушать цикл событий
obj->deleteLater();
});
}
const QString cleanVault = vaultPath.isEmpty() ? QString() : QDir::cleanPath(vaultPath);
m_windows.append({cleanVault, obj, store, docMgr, vIndex, ctx});
connect(obj, &QObject::destroyed, this, [this, obj, ctx]() {
for (int i = 0; i < m_windows.size(); ++i) {
if (m_windows[i].object == obj) {
const QString vault = m_windows[i].vault;
QQmlContext *c = m_windows[i].context;
m_windows.removeAt(i);
if (c)
c->deleteLater();
emit windowsChanged();
if (!vault.isEmpty())
emit windowClosed(vault);
break;
}
}
// fallback: если окно уже удалено из списка, всё равно чистим контекст
if (ctx && ctx->parent() == this) {
bool still = false;
for (const auto &w : std::as_const(m_windows))
if (w.context == ctx) { still = true; break; }
if (!still) ctx->deleteLater();
}
});
obj->setProperty("windowVault", vaultPath);
obj->setProperty("windowVault", cleanVault);
// Закрытие окна уничтожает QML-объект (чтобы последнее окно завершало приложение).
obj->setProperty("destroyOnWindowClose", true);
if (!vaultPath.isEmpty() && QDir(vaultPath).exists())
store->openVault(vaultPath);
// Устанавливаем через динамическое свойство если поддерживается
if (obj->property("destroyOnWindowClose").isValid())
obj->setProperty("destroyOnWindowClose", true);
if (!cleanVault.isEmpty() && QDir(cleanVault).exists())
store->openVault(cleanVault);
emit windowsChanged();
if (!vaultPath.isEmpty())
+5
View File
@@ -6,6 +6,8 @@
#include <QList>
class FileStore;
class DocumentManager;
class VaultIndex;
// Создание окон приложения (мульти-хранилища: новое хранилище — новое окно).
class WindowController : public QObject
@@ -37,6 +39,9 @@ private:
QString vault;
QPointer<QObject> object;
FileStore *fileStore = nullptr;
DocumentManager *docManager = nullptr;
VaultIndex *vaultIndex = nullptr;
QQmlContext *context = nullptr;
};
QQmlApplicationEngine *m_engine;
+83 -11
View File
@@ -1,22 +1,25 @@
#include "core/DocumentManager.h"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QTextStream>
#include <QTimer>
DocumentManager::DocumentManager(QObject *parent)
: QObject(parent)
{
QTimer *autosave = new QTimer(this);
autosave->setInterval(m_autosaveMs);
autosave->setSingleShot(false);
connect(autosave, &QTimer::timeout, this, &DocumentManager::saveAll);
autosave->start();
m_autosave = new QTimer(this);
m_autosave->setInterval(m_autosaveMs);
m_autosave->setSingleShot(false);
connect(m_autosave, &QTimer::timeout, this, &DocumentManager::saveAll);
m_autosave->start();
// Защита при выходе
connect(qApp, &QCoreApplication::aboutToQuit, this, &DocumentManager::saveAll);
if (qApp)
connect(qApp, &QCoreApplication::aboutToQuit, this, &DocumentManager::saveAll);
}
QStringList DocumentManager::openTabs() const
@@ -31,9 +34,13 @@ QStringList DocumentManager::openTabs() const
int DocumentManager::activeIndex() const { return m_active; }
void DocumentManager::setActiveIndex(int index)
{
qDebug("setActiveIndex: from %d to %d", m_active, index);
if (m_active == index)
return;
if (index < -1 || index >= m_docs.size()) {
// allow -1 for no active, but clamp
if (index != -1 && (index < 0 || index >= m_docs.size()))
return;
}
m_active = index;
emit activeIndexChanged();
}
@@ -59,9 +66,14 @@ void DocumentManager::setSideBySide(bool on)
int DocumentManager::autosaveIntervalMs() const { return m_autosaveMs; }
void DocumentManager::setAutosaveIntervalMs(int ms)
{
// Ограничиваем интервал 500мс .. 120с, защита от спама/заморозки
if (ms < 500) ms = 500;
if (ms > 120000) ms = 120000;
if (m_autosaveMs == ms)
return;
m_autosaveMs = ms;
if (m_autosave)
m_autosave->setInterval(ms);
emit autosaveIntervalChanged();
}
@@ -69,7 +81,23 @@ QString DocumentManager::lastStatus() const { return m_lastStatus; }
int DocumentManager::openDocument(const QString &path)
{
const QString clean = QFileInfo(path).absoluteFilePath();
if (path.trimmed().isEmpty()) {
m_lastStatus = QStringLiteral("Путь пустой");
emit lastStatusChanged();
return -1;
}
const QString clean = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
QFileInfo fi(clean);
if (fi.isDir()) {
m_lastStatus = QStringLiteral("Это папка, а не файл: %1").arg(clean);
emit lastStatusChanged();
return -1;
}
if (fi.exists() && fi.size() > 10 * 1024 * 1024) {
m_lastStatus = QStringLiteral("Файл слишком большой (>10 МБ): %1").arg(clean);
emit lastStatusChanged();
return -1;
}
for (int i = 0; i < m_docs.size(); ++i) {
if (m_docs[i].path == clean) {
setActiveIndex(i);
@@ -92,8 +120,6 @@ int DocumentManager::openDocument(const QString &path)
m_docs.append(doc);
const int idx = m_docs.size() - 1;
qDebug("openDocument: path=%s idx=%d content_len=%d",
qPrintable(clean), idx, content.length());
setActiveIndex(idx);
emit openTabsChanged();
return idx;
@@ -101,13 +127,27 @@ int DocumentManager::openDocument(const QString &path)
QString DocumentManager::readFile(const QString &path, bool *ok) const
{
QFileInfo fi(path);
if (fi.exists() && fi.size() > 10 * 1024 * 1024) {
if (ok) *ok = false;
return QString();
}
QFile f(path);
if (!f.open(QIODevice::ReadOnly)) {
if (ok) *ok = false;
return QString();
}
const QByteArray bytes = f.readAll();
// Ограничиваем чтение 10 МБ для защиты от OOM
const QByteArray bytes = f.read(10 * 1024 * 1024 + 1);
if (bytes.size() > 10 * 1024 * 1024) {
if (ok) *ok = false;
return QString();
}
if (ok) *ok = true;
// Пытаемся детектировать бинарный файл по нулевым байтам
if (bytes.contains('\0')) {
// Считаем бинарным, но всё равно возвращаем как текст для просмотра с предупреждением
}
return QString::fromUtf8(bytes);
}
@@ -194,7 +234,14 @@ bool DocumentManager::closeTab(int index)
if (index < 0 || index >= m_docs.size())
return false;
// При закрытии тоже сохраняем (защита от потери текста).
// Если сохранение не удалось и файл всё ещё dirty — не закрываем, чтобы не потерять данные
bool wasDirty = isDirty(index);
saveDocument(index);
if (wasDirty && isDirty(index)) {
m_lastStatus = QStringLiteral("Не удалось сохранить, вкладка не закрыта: %1").arg(m_docs[index].path);
emit lastStatusChanged();
return false;
}
m_docs.removeAt(index);
@@ -212,4 +259,29 @@ bool DocumentManager::closeTab(int index)
emit activeIndexChanged();
emit secondaryIndexChanged();
return true;
}
void DocumentManager::moveTab(int from, int to)
{
if (from < 0 || from >= m_docs.size() || to < 0 || to >= m_docs.size() || from == to)
return;
m_docs.move(from, to);
// корректируем активные индексы
if (m_active == from) {
m_active = to;
} else if (from < m_active && m_active <= to) {
--m_active;
} else if (to <= m_active && m_active < from) {
++m_active;
}
if (m_secondary == from) {
m_secondary = to;
} else if (from < m_secondary && m_secondary <= to) {
--m_secondary;
} else if (to <= m_secondary && m_secondary < from) {
++m_secondary;
}
emit openTabsChanged();
emit activeIndexChanged();
emit secondaryIndexChanged();
}
+6 -4
View File
@@ -24,13 +24,13 @@ public:
QStringList openTabs() const;
int activeIndex() const;
void setActiveIndex(int index);
Q_INVOKABLE void setActiveIndex(int index);
int secondaryIndex() const;
void setSecondaryIndex(int index);
Q_INVOKABLE void setSecondaryIndex(int index);
bool sideBySide() const;
void setSideBySide(bool on);
Q_INVOKABLE void setSideBySide(bool on);
int autosaveIntervalMs() const;
void setAutosaveIntervalMs(int ms);
Q_INVOKABLE void setAutosaveIntervalMs(int ms);
QString lastStatus() const;
// Открывает документ (при необходимости создаётся вкладка). Возвращает индекс.
@@ -52,6 +52,7 @@ public:
Q_INVOKABLE void saveAll();
Q_INVOKABLE bool closeTab(int index);
Q_INVOKABLE void moveTab(int from, int to);
signals:
void openTabsChanged();
@@ -84,4 +85,5 @@ private:
bool m_sideBySide = false;
int m_autosaveMs = 5000;
QString m_lastStatus;
QTimer *m_autosave = nullptr;
};
+204 -46
View File
@@ -3,6 +3,9 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#include <QSet>
#include <functional>
FileStore::FileStore(QObject *parent)
: QAbstractListModel(parent)
@@ -83,9 +86,14 @@ QString FileStore::iconFor(const Row &r) const
QString FileStore::openVault(const QString &path)
{
const QString clean = QDir::cleanPath(path);
if (!QDir(clean).exists())
const QString clean = QDir::cleanPath(path.trimmed());
if (clean.isEmpty())
return QStringLiteral("Путь к хранилищу не указан.");
QFileInfo fi(clean);
if (!fi.exists())
return QStringLiteral("Папка «%1» не существует.").arg(clean);
if (!fi.isDir())
return QStringLiteral("Путь «%1» не является папкой.").arg(clean);
m_vault = clean;
m_expanded.clear();
@@ -99,8 +107,6 @@ QString FileStore::openVault(const QString &path)
void FileStore::rebuild()
{
if (!m_watcher.directories().isEmpty())
m_watcher.removePaths(m_watcher.directories());
QVector<Row> rows;
collectDir(m_vault, 0, rows);
@@ -108,11 +114,7 @@ void FileStore::rebuild()
m_rows = std::move(rows);
endResetModel();
// Регистрируем все каталоги для слежения
for (const Row &r : m_rows) {
if (r.isDir && !m_watcher.directories().contains(r.path))
m_watcher.addPath(r.path);
}
registerWatcher();
}
void FileStore::collectDir(const QString &dir, int depth, QVector<Row> &out) const
@@ -133,11 +135,14 @@ void FileStore::collectDir(const QString &dir, int depth, QVector<Row> &out) con
row.depth = depth;
row.isDir = fi.isDir();
if (row.isDir) {
const int childCount = QDir(path)
.entryList(QDir::AllEntries | QDir::NoDotAndDotDot
| QDir::System)
.count();
row.hasChildren = childCount > 0;
QDir cd(path);
cd.setFilter(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::System | QDir::NoSymLinks);
const QFileInfoList children = cd.entryInfoList();
int visible = 0;
for (const auto &c : children)
if (!isIgnored(c.absoluteFilePath()))
++visible;
row.hasChildren = visible > 0;
}
row.expanded = row.isDir && m_expanded.contains(path);
(row.isDir ? dirs : files).append(row);
@@ -163,7 +168,8 @@ bool FileStore::isIgnored(const QString &path) const
{
const QString name = QFileInfo(path).fileName();
return name == QLatin1String(".git") || name == QLatin1String(".flora")
|| name == QLatin1String(".stfolder");
|| name == QLatin1String(".stfolder") || name == QLatin1String(".DS_Store")
|| name == QLatin1String("Thumbs.db");
}
void FileStore::registerWatcher()
@@ -171,14 +177,44 @@ void FileStore::registerWatcher()
const auto dirs = m_watcher.directories();
for (const QString &d : dirs)
m_watcher.removePath(d);
if (m_vault.isEmpty() || !QDir(m_vault).exists())
return;
// Следим за всеми каталогами хранилища, даже свёрнутыми, чтобы внешние изменения
// внутри свёрнутых папок приводили к rebuild (иначе watcher пропустит события).
QVector<QString> dirsToWatch;
dirsToWatch.append(m_vault);
// Собираем рекурсивно все подкаталоги, игнорируя служебные
std::function<void(const QString&)> collect = [&](const QString &cur) {
QDir d(cur);
d.setFilter(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks | QDir::System);
const QFileInfoList subs = d.entryInfoList();
for (const QFileInfo &fi : subs) {
if (isIgnored(fi.absoluteFilePath()))
continue;
const QString clean = QDir::cleanPath(fi.absoluteFilePath());
if (!dirsToWatch.contains(clean))
dirsToWatch.append(clean);
collect(fi.absoluteFilePath());
}
};
collect(m_vault);
// Также добавляем видимые dirs на случай рассинхрона
for (const Row &r : m_rows)
if (r.isDir)
if (r.isDir && !dirsToWatch.contains(r.path))
dirsToWatch.append(r.path);
if (!dirsToWatch.isEmpty())
m_watcher.addPaths(dirsToWatch);
if (!m_vault.isEmpty() && QDir(m_vault).exists())
m_watcher.addPath(m_vault);
// Ограничение QFileSystemWatcher: лимит дескрипторов (обычно 256-512)
// При превышении addPaths вернёт пустой список без ошибки - проверяем
if (dirsToWatch.size() > 512) {
// Оставляем только верхний уровень + видимые для экономии дескрипторов
QVector<QString> limited;
limited.append(m_vault);
for (const Row &r : m_rows)
if (r.isDir && !limited.contains(r.path))
limited.append(r.path);
dirsToWatch = limited;
}
const QStringList failed = m_watcher.addPaths(dirsToWatch);
Q_UNUSED(failed)
}
void FileStore::scheduleRebuild()
@@ -226,13 +262,42 @@ QString FileStore::validateName(const QString &name) const
const QString n = name.trimmed();
if (n.isEmpty())
return QStringLiteral("Имя не может быть пустым.");
if (n.contains('/') || n.contains('\\') || n == "." || n == "..")
return QStringLiteral("Имя содержит недопустимые символы (/, \\).");
if (n == "." || n == "..")
return QStringLiteral("Имя не может быть «.» или «..».");
if (n.startsWith('.'))
return QStringLiteral("Имя не может начинаться с точки.");
// Запрещённые символы (кроссплатформенно): / \ : * ? " < > | и управляющие 0x00-0x1F
static const QRegularExpression invalidRe(QStringLiteral("[\\\\/:*?\"<>|\\x00-\\x1F]"));
if (n.contains('/') || n.contains('\\') || invalidRe.match(n).hasMatch())
return QStringLiteral("Имя содержит недопустимые символы (/, \\, :, *, ?, \", <, >, |).");
// Зарезервированные имена Windows (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
static const QSet<QString> reserved = {
"CON","PRN","AUX","NUL",
"COM1","COM2","COM3","COM4","COM5","COM6","COM7","COM8","COM9",
"LPT1","LPT2","LPT3","LPT4","LPT5","LPT6","LPT7","LPT8","LPT9"
};
const QString upper = n.split('.').first().toUpper();
if (reserved.contains(upper))
return QStringLiteral("Имя зарезервировано системой.");
if (n.length() > 200)
return QStringLiteral("Имя слишком длинное.");
if (n.endsWith(' ') || n.endsWith('.'))
return QStringLiteral("Имя не может заканчиваться пробелом или точкой.");
return QString();
}
bool FileStore::isInsideVault(const QString &path) const
{
if (m_vault.isEmpty())
return false;
const QString vault = QDir::cleanPath(m_vault);
const QString p = QDir::cleanPath(path);
if (p == vault)
return true;
// Должен начинаться с vault + '/'
return p.startsWith(vault + '/');
}
QString FileStore::parentDirOfRow(int row) const
{
if (row <= 0)
@@ -246,7 +311,14 @@ QString FileStore::parentDirOfRow(int row) const
QString FileStore::createFile(const QString &parentPath, const QString &name)
{
const QString dir = parentPath.isEmpty() ? m_vault : parentPath;
if (m_vault.isEmpty())
return QStringLiteral("Хранилище не открыто.");
QString dir = parentPath.isEmpty() ? m_vault : QDir::cleanPath(parentPath);
if (!isInsideVault(dir))
return QStringLiteral("Путь вне хранилища: %1").arg(dir);
QFileInfo dirInfo(dir);
if (!dirInfo.exists() || !dirInfo.isDir())
return QStringLiteral("Папка не существует: %1").arg(dir);
const QString trimmed = name.trimmed();
if (trimmed.isEmpty())
return QStringLiteral("Имя файла не может быть пустым.");
@@ -258,6 +330,9 @@ QString FileStore::createFile(const QString &parentPath, const QString &name)
if (!err.isEmpty())
return err;
const QString path = QDir(dir).filePath(actualName);
const QString cleanPath = QDir::cleanPath(path);
if (!isInsideVault(cleanPath))
return QStringLiteral("Итоговый путь вне хранилища.");
if (QFileInfo::exists(path))
return QStringLiteral("Файл «%1» уже существует в этом каталоге.").arg(actualName);
QFile f(path);
@@ -272,9 +347,19 @@ QString FileStore::dailyNotePath(const QString &dateStr)
{
if (m_vault.isEmpty())
return QString();
if (!QDir().mkpath(m_vault + "/Журнал"))
// Валидация формата даты: YYYY-MM-DD
static const QRegularExpression dateRe(QStringLiteral("^\\d{4}-\\d{2}-\\d{2}$"));
if (!dateRe.match(dateStr).hasMatch())
return QString();
const QString journalDir = QDir::cleanPath(m_vault + "/Журнал");
if (!isInsideVault(journalDir))
return QString();
if (!QDir().mkpath(journalDir))
return QString();
const QString path = QDir(journalDir).filePath(dateStr + ".md");
const QString cleanPath = QDir::cleanPath(path);
if (!isInsideVault(cleanPath))
return QString();
const QString path = m_vault + "/Журнал/" + dateStr + ".md";
if (!QFileInfo::exists(path)) {
QFile f(path);
if (f.open(QIODevice::WriteOnly)) {
@@ -283,47 +368,76 @@ QString FileStore::dailyNotePath(const QString &dateStr)
} else {
return QString();
}
m_expanded.insert(m_vault + "/Журнал");
scheduleRebuild();
m_expanded.insert(journalDir);
rebuild();
}
return path;
return cleanPath;
}
QString FileStore::createFolder(const QString &parentPath, const QString &name)
{
const QString dir = parentPath.isEmpty() ? m_vault : parentPath;
const QString err = validateName(name);
if (m_vault.isEmpty())
return QStringLiteral("Хранилище не открыто.");
QString dir = parentPath.isEmpty() ? m_vault : QDir::cleanPath(parentPath);
if (!isInsideVault(dir))
return QStringLiteral("Путь вне хранилища: %1").arg(dir);
QFileInfo dirInfo(dir);
if (!dirInfo.exists() || !dirInfo.isDir())
return QStringLiteral("Папка не существует: %1").arg(dir);
const QString err = validateName(name.trimmed());
if (!err.isEmpty())
return err;
const QString path = QDir(dir).filePath(name);
const QString path = QDir(dir).filePath(name.trimmed());
const QString cleanPath = QDir::cleanPath(path);
if (!isInsideVault(cleanPath))
return QStringLiteral("Итоговый путь вне хранилища.");
if (QFileInfo::exists(path))
return QStringLiteral("Папка с таким именем уже существует.");
if (!QDir().mkpath(path))
return QStringLiteral("Не удалось создать папку: %1").arg(path);
m_expanded.insert(path);
m_expanded.insert(QDir::cleanPath(path));
rebuild();
return QString();
}
QString FileStore::renameItem(const QString &path, const QString &newName)
{
if (m_vault.isEmpty())
return QStringLiteral("Хранилище не открыто.");
const QString cleanPath = QDir::cleanPath(path);
if (!isInsideVault(cleanPath))
return QStringLiteral("Путь вне хранилища: %1").arg(path);
QFileInfo fi(cleanPath);
if (!fi.exists())
return QStringLiteral("Элемент не существует: %1").arg(path);
const QString n = newName.trimmed();
const QString err = validateName(n);
if (!err.isEmpty())
return err;
QFileInfo fi(path);
const QString newPath = QDir(fi.absolutePath()).filePath(n);
if (path == newPath)
const QString cleanNew = QDir::cleanPath(newPath);
if (!isInsideVault(cleanNew))
return QStringLiteral("Новый путь вне хранилища.");
if (cleanPath == cleanNew)
return QString();
if (QFileInfo::exists(newPath))
return QStringLiteral("Имя уже занято.");
if (!QFile::rename(path, newPath) && !QDir().rename(path, newPath))
if (!QFile::rename(cleanPath, newPath) && !QDir().rename(cleanPath, newPath))
return QStringLiteral("Не удалось переименовать: %1").arg(path);
if (fi.isDir()) {
const QString oldAbs = QDir::cleanPath(path);
const QString newAbs = QDir::cleanPath(newPath);
if (m_expanded.remove(oldAbs))
m_expanded.insert(newAbs);
const QString oldAbs = cleanPath;
const QString newAbs = cleanNew;
// Обновляем все раскрытые пути с префиксом oldAbs (включая вложенные)
QSet<QString> updated;
for (const QString &p : std::as_const(m_expanded)) {
if (p == oldAbs)
updated.insert(newAbs);
else if (p.startsWith(oldAbs + '/'))
updated.insert(newAbs + p.mid(oldAbs.size()));
else
updated.insert(p);
}
m_expanded = std::move(updated);
}
rebuild();
return QString();
@@ -331,14 +445,28 @@ QString FileStore::renameItem(const QString &path, const QString &newName)
QString FileStore::removeItem(const QString &path)
{
QFileInfo fi(path);
if (m_vault.isEmpty())
return QStringLiteral("Хранилище не открыто.");
const QString clean = QDir::cleanPath(path);
if (!isInsideVault(clean))
return QStringLiteral("Путь вне хранилища: %1").arg(path);
if (clean == QDir::cleanPath(m_vault))
return QStringLiteral("Нельзя удалить корень хранилища.");
QFileInfo fi(clean);
if (!fi.exists())
return QStringLiteral("Элемент не существует: %1").arg(path);
if (fi.isDir()) {
QDir d(path);
QDir d(clean);
if (!d.removeRecursively())
return QStringLiteral("Не удалось удалить папку «%1».").arg(path);
m_expanded.remove(QDir::cleanPath(path));
QSet<QString> updated;
for (const QString &p : std::as_const(m_expanded)) {
if (p != clean && !p.startsWith(clean + '/'))
updated.insert(p);
}
m_expanded = std::move(updated);
} else {
if (!QFile::remove(path))
if (!QFile::remove(clean))
return QStringLiteral("Не удалось удалить файл «%1».").arg(path);
}
rebuild();
@@ -347,19 +475,34 @@ QString FileStore::removeItem(const QString &path)
QString FileStore::moveItem(const QString &srcPath, const QString &destDir)
{
if (m_vault.isEmpty())
return QStringLiteral("Хранилище не открыто.");
if (srcPath.isEmpty() || destDir.isEmpty())
return QStringLiteral("Не указаны пути.");
const QString src = QDir::cleanPath(srcPath);
const QString dstDir = QDir::cleanPath(destDir);
if (src == dstDir || QFileInfo(dstDir).filePath() == QFileInfo(src).absolutePath())
if (!isInsideVault(src) || !isInsideVault(dstDir))
return QStringLiteral("Путь вне хранилища.");
QFileInfo srcInfo(src);
QFileInfo dstInfo(dstDir);
if (!srcInfo.exists())
return QStringLiteral("Источник не существует: %1").arg(src);
if (!dstInfo.exists() || !dstInfo.isDir())
return QStringLiteral("Папка назначения не существует или не является папкой: %1").arg(dstDir);
if (src == QDir::cleanPath(m_vault))
return QStringLiteral("Нельзя переместить корень хранилища.");
if (src == dstDir || dstDir == QDir::cleanPath(QFileInfo(src).absolutePath()))
return QString();
// Запрещаем перемещение папки в саму себя
if (src.startsWith(dstDir + '/'))
// Запрещаем перемещение папки в саму себя или в подпапку
if (dstDir == src || dstDir.startsWith(src + '/'))
return QStringLiteral("Нельзя переместить папку в саму себя.");
const QFileInfo fi(src);
const QString newPath = QDir(dstDir).filePath(fi.fileName());
const QString cleanNew = QDir::cleanPath(newPath);
if (!isInsideVault(cleanNew))
return QStringLiteral("Итоговый путь вне хранилища.");
if (QFileInfo::exists(newPath))
return QStringLiteral("Элемент с таким именем уже есть в папке назначения.");
@@ -371,6 +514,21 @@ QString FileStore::moveItem(const QString &srcPath, const QString &destDir)
if (!ok)
return QStringLiteral("Не удалось переместить «%1».").arg(src);
// переносим состояние раскрытия для перемещаемой папки и её подпапок
if (fi.isDir()) {
const QString oldAbs = src;
const QString newAbs = cleanNew;
QSet<QString> updated;
for (const QString &p : std::as_const(m_expanded)) {
if (p == oldAbs)
updated.insert(newAbs);
else if (p.startsWith(oldAbs + '/'))
updated.insert(newAbs + p.mid(oldAbs.size()));
else
updated.insert(p);
}
m_expanded = std::move(updated);
}
rebuild();
return QString();
}
+2 -1
View File
@@ -84,7 +84,8 @@ private:
void registerWatcher();
void scheduleRebuild();
QString validateName(const QString &name) const;
QString validateName(const QString &name) const;
bool isInsideVault(const QString &path) const;
QString parentDirOfRow(int row) const;
QString iconFor(const Row &row) const;
+187 -5
View File
@@ -1,12 +1,31 @@
#include "core/SettingsStore.h"
#include <QDir>
#include <QSettings>
#include <QUrl>
#include <algorithm>
namespace {
constexpr auto kGroupVaults = "vaults";
constexpr auto kKeyVaults = "vaults/vaultList";
constexpr auto kKeyCurrent = "vaults/current";
constexpr auto kKeyProviders = "sync/providers";
constexpr auto kKeyTheme = "ui/theme";
constexpr auto kKeyLeftW = "ui/leftPanelWidth";
constexpr auto kKeyRightW = "ui/rightPanelWidth";
constexpr auto kKeyViewModes = "ui/viewModes";
QString encodeKey(const QString &s) {
// Процент-кодирование чтобы избежать интерпретации "/" как разделителя групп в QSettings Ini
return QString::fromLatin1(QUrl::toPercentEncoding(s, QByteArray(), QByteArray("/")));
}
QString encodeRel(const QString &rel) {
// Для относительных путей тоже кодируем "/" как %2F чтобы ключ остался плоским
return QString::fromLatin1(QUrl::toPercentEncoding(rel));
}
QString decodeKey(const QString &s) {
return QUrl::fromPercentEncoding(s.toLatin1());
}
}
SettingsStore::SettingsStore(QObject *parent)
@@ -31,7 +50,8 @@ QString SettingsStore::currentVault() const
void SettingsStore::setCurrentVault(const QString &path)
{
QSettings s;
s.setValue(kKeyCurrent, path);
s.setValue(kKeyCurrent, QDir::cleanPath(path));
s.sync();
emit currentVaultChanged();
}
@@ -42,18 +62,26 @@ QVariantList SettingsStore::providers() const
void SettingsStore::addVault(const QString &path)
{
const QString clean = QDir::cleanPath(path);
if (clean.isEmpty())
return;
QStringList list = vaults();
if (!list.contains(path))
list.prepend(path);
// нормализуем существующие записи
for (QString &v : list) v = QDir::cleanPath(v);
list.removeAll(clean);
list.prepend(clean);
QSettings s;
s.setValue(kKeyVaults, list);
s.setValue(kKeyCurrent, path);
s.setValue(kKeyCurrent, clean);
s.sync();
emit vaultsChanged();
emit currentVaultChanged();
}
void SettingsStore::addProvider(const QString &provider, const QVariantMap &config)
{
if (provider.isEmpty())
return;
QVariantList list = m_providers;
for (auto &item : list) {
auto m = item.toMap();
@@ -74,12 +102,18 @@ void SettingsStore::addProvider(const QString &provider, const QVariantMap &conf
save:
QSettings s;
s.setValue(kKeyProviders, m_providers);
s.sync();
emit providersChanged();
}
bool SettingsStore::hasVault(const QString &path) const
{
return vaults().contains(path);
const QString clean = QDir::cleanPath(path);
const QStringList list = vaults();
for (const QString &v : list)
if (QDir::cleanPath(v) == clean)
return true;
return false;
}
void SettingsStore::clearProvider(const QString &provider)
@@ -93,5 +127,153 @@ void SettingsStore::clearProvider(const QString &provider)
m_providers = list;
QSettings s;
s.setValue(kKeyProviders, m_providers);
s.sync();
emit providersChanged();
}
QString SettingsStore::theme() const
{
QSettings s;
const QString t = s.value(kKeyTheme, QStringLiteral("light")).toString();
return (t == "dark" || t == "light") ? t : QStringLiteral("light");
}
void SettingsStore::setTheme(const QString &t)
{
const QString clean = (t == "dark") ? QStringLiteral("dark") : QStringLiteral("light");
QSettings s;
if (s.value(kKeyTheme).toString() == clean)
return;
s.setValue(kKeyTheme, clean);
s.sync();
emit themeChanged();
}
int SettingsStore::leftPanelWidth() const
{
QSettings s;
return s.value(kKeyLeftW, 220).toInt();
}
void SettingsStore::setLeftPanelWidth(int w)
{
if (w < 100) w = 100;
if (w > 800) w = 800;
QSettings s;
if (s.value(kKeyLeftW).toInt() == w)
return;
s.setValue(kKeyLeftW, w);
s.sync();
emit leftPanelWidthChanged();
}
int SettingsStore::rightPanelWidth() const
{
QSettings s;
return s.value(kKeyRightW, 220).toInt();
}
void SettingsStore::setRightPanelWidth(int w)
{
if (w < 100) w = 100;
if (w > 800) w = 800;
QSettings s;
if (s.value(kKeyRightW).toInt() == w)
return;
s.setValue(kKeyRightW, w);
s.sync();
emit rightPanelWidthChanged();
}
QString SettingsStore::viewModeForFile(const QString &vault, const QString &filePath) const
{
if (vault.isEmpty() || filePath.isEmpty())
return QStringLiteral("edit");
QSettings s;
const QVariantMap root = s.value(kKeyViewModes).toMap();
const QString cleanVault = QDir::cleanPath(vault);
const QString cleanPath = QDir::cleanPath(filePath);
QString rel = cleanPath;
if (cleanPath.startsWith(cleanVault)) {
rel = cleanPath.mid(cleanVault.length());
while (rel.startsWith("/") || rel.startsWith("\\")) rel.remove(0,1);
} else {
rel = cleanPath;
}
const QString oldKey = cleanVault + "|" + rel;
if (root.contains(oldKey)) {
const QString m = root.value(oldKey).toString();
if (m == "preview" || m == "live" || m == "edit") return m;
}
const QString encVault = encodeKey(cleanVault);
const QString encRel = encodeRel(rel);
const QString encKey = encVault + "|" + encRel;
if (root.contains(encKey)) {
const QString m = root.value(encKey).toString();
if (m == "preview" || m == "live" || m == "edit") return m;
}
// Обратная совместимость: старый вложенный формат
QVariantMap vaultMap = root.value(encVault).toMap();
if (vaultMap.isEmpty())
vaultMap = root.value(cleanVault).toMap();
QString mode = vaultMap.value(encRel).toString();
if (mode.isEmpty())
mode = vaultMap.value(rel).toString();
if (mode == "preview" || mode == "live" || mode == "edit")
return mode;
return QStringLiteral("edit");
}
void SettingsStore::setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode)
{
if (vault.isEmpty() || filePath.isEmpty())
return;
QString cleanMode = mode;
if (cleanMode != "preview" && cleanMode != "live")
cleanMode = "edit";
QSettings s;
QVariantMap root = s.value(kKeyViewModes).toMap();
const QString cleanVault = QDir::cleanPath(vault);
const QString cleanPath = QDir::cleanPath(filePath);
QString rel = cleanPath;
if (cleanPath.startsWith(cleanVault)) {
rel = cleanPath.mid(cleanVault.length());
while (rel.startsWith("/") || rel.startsWith("\\")) rel.remove(0,1);
} else {
rel = cleanPath;
}
const QString encVault = encodeKey(cleanVault);
const QString encRel = encodeRel(rel);
const QString encKey = encVault + "|" + encRel;
// мигрируем старые ключи
const QString oldKey = cleanVault + "|" + rel;
if (root.contains(oldKey))
root.remove(oldKey);
// мигрируем старый вложенный формат если есть
if (root.contains(encVault) && root.value(encVault).typeId() == QMetaType::QVariantMap) {
QVariantMap vm = root.value(encVault).toMap();
if (!vm.isEmpty()) {
// переносим все вложенные записи в плоский формат
for (auto it = vm.constBegin(); it != vm.constEnd(); ++it) {
const QString innerKey = encVault + "|" + it.key();
if (!root.contains(innerKey))
root.insert(innerKey, it.value());
}
root.remove(encVault);
}
}
if (root.contains(cleanVault) && root.value(cleanVault).typeId() == QMetaType::QVariantMap) {
QVariantMap vm = root.value(cleanVault).toMap();
root.remove(cleanVault);
for (auto it = vm.constBegin(); it != vm.constEnd(); ++it) {
const QString k = encodeKey(cleanVault) + "|" + encodeRel(it.key());
if (!root.contains(k))
root.insert(k, it.value());
}
}
if (root.value(encKey).toString() == cleanMode)
return;
root.insert(encKey, cleanMode);
s.setValue(kKeyViewModes, root);
s.sync();
}
+16
View File
@@ -14,6 +14,9 @@ class SettingsStore : public QObject
Q_PROPERTY(QString currentVault READ currentVault WRITE setCurrentVault
NOTIFY currentVaultChanged)
Q_PROPERTY(QVariantList providers READ providers NOTIFY providersChanged)
Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged)
Q_PROPERTY(int leftPanelWidth READ leftPanelWidth WRITE setLeftPanelWidth NOTIFY leftPanelWidthChanged)
Q_PROPERTY(int rightPanelWidth READ rightPanelWidth WRITE setRightPanelWidth NOTIFY rightPanelWidthChanged)
public:
explicit SettingsStore(QObject *parent = nullptr);
@@ -33,10 +36,23 @@ public:
Q_INVOKABLE bool hasVault(const QString &path) const;
Q_INVOKABLE void clearProvider(const QString &provider);
QString theme() const;
void setTheme(const QString &t);
int leftPanelWidth() const;
void setLeftPanelWidth(int w);
int rightPanelWidth() const;
void setRightPanelWidth(int w);
Q_INVOKABLE QString viewModeForFile(const QString &vault, const QString &filePath) const;
Q_INVOKABLE void setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode);
signals:
void vaultsChanged();
void currentVaultChanged();
void providersChanged();
void themeChanged();
void leftPanelWidthChanged();
void rightPanelWidthChanged();
private:
QVariantList m_providers;
+84 -17
View File
@@ -1,13 +1,15 @@
#include "core/VaultIndex.h"
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#include <QSet>
namespace {
QStringList kIgnoredNames = {".git", ".flora", ".stfolder"};
QStringList kIgnoredNames = {".git", ".flora", ".stfolder", ".DS_Store", "Thumbs.db"};
// Извлекает теги #tag из текста (пропуская блоки кода).
QStringList extractTags(const QString &text)
@@ -15,19 +17,24 @@ QStringList extractTags(const QString &text)
QStringList tags;
static const QRegularExpression tagRe(
QStringLiteral("(?:^|[^\\w#])#([\\p{L}\\w][\\p{L}\\w-]*)"));
static const QRegularExpression inlineCodeRe(QStringLiteral("`[^`]*`"));
bool inCode = false;
const QStringList lines = text.split(QLatin1Char('\n'));
for (const QString &line : lines) {
const QString t = line.trimmed();
if (t.startsWith(QLatin1String("```"))) {
for (const QString &origLine : lines) {
const QString t = origLine.trimmed();
if (t.startsWith(QLatin1String("```")) || t.startsWith(QLatin1String("~~~"))) {
inCode = !inCode;
continue;
}
if (inCode)
continue;
// Заголовок "# Заголовок" — не тег (после # идёт пробел)
if (t.startsWith(QLatin1String("# ")) || t.startsWith(QLatin1String("## ")))
continue;
// Убираем инлайн-код `code #tag` — теги внутри не считаем
QString line = origLine;
line.remove(inlineCodeRe);
// Не пропускаем заголовки целиком — только сам маркер "# " в начале,
// теги после текста заголовка всё равно должны учитываться
static const QRegularExpression headerMarkerRe(QStringLiteral("^\\s*#{1,6}\\s+"));
line.remove(headerMarkerRe);
auto it = tagRe.globalMatch(line);
while (it.hasNext()) {
const QString tag = it.next().captured(1);
@@ -98,14 +105,33 @@ QString VaultIndex::resolveLink(const QString &fileDir, const QString &target) c
return QString();
if (t.contains(QLatin1String("://")))
return QString(); // внешняя
// Отсекаем якорь #section и query ?
int hash = t.indexOf('#');
if (hash >= 0) t = t.left(hash).trimmed();
int q = t.indexOf('?');
if (q >= 0) t = t.left(q).trimmed();
if (t.isEmpty())
return QString();
// Защита от traversal
if (t.contains(QStringLiteral("..")))
return QString();
QStringList cands;
cands << cleanDir(fileDir) + '/' + t
<< cleanDir(fileDir) + '/' + t + ".md";
for (const QString &c : cands)
if (QFileInfo::exists(c))
cands << QDir::cleanPath(cleanDir(fileDir) + '/' + t)
<< QDir::cleanPath(cleanDir(fileDir) + '/' + t + ".md");
for (const QString &c : cands) {
if (QFileInfo::exists(c) && QFileInfo(c).isFile())
return QDir::cleanPath(c);
// поиск по имени файла по всему хранилищу (без расширения)
auto it = m_fileByBase.constFind(t);
}
// поиск по имени файла по всему хранилищу (без расширения), case-insensitive
const QString lower = t.toLower();
auto it = m_fileByBase.constFind(lower);
if (it != m_fileByBase.constEnd())
return it.value();
it = m_fileByBase.constFind(lower + ".md");
if (it != m_fileByBase.constEnd())
return it.value();
// fallback точный регистр
it = m_fileByBase.constFind(t);
if (it != m_fileByBase.constEnd())
return it.value();
it = m_fileByBase.constFind(t + ".md");
@@ -116,6 +142,20 @@ QString VaultIndex::resolveLink(const QString &fileDir, const QString &target) c
void VaultIndex::rebuild(const QString &vault)
{
if (vault.isEmpty() || !QDir(vault).exists()) {
m_nodes.clear();
m_edges.clear();
m_nodeById.clear();
m_tagsOfFile.clear();
m_linksOfFile.clear();
m_filesWithTag.clear();
m_fileByBase.clear();
m_fileCount = 0;
emit indexChanged();
return;
}
if (m_busy)
return;
m_busy = true;
emit busyChanged();
@@ -134,12 +174,27 @@ void VaultIndex::rebuild(const QString &vault)
m_fileCount = files.size();
for (const QString &f : files) {
const QString base = QFileInfo(f).completeBaseName();
const QString key = base.toLower();
if (!base.isEmpty() && !m_fileByBase.contains(key))
m_fileByBase.insert(key, f);
// также храним оригинал для точного поиска
if (!base.isEmpty() && !m_fileByBase.contains(base))
m_fileByBase.insert(base, f);
}
// Проход 2: узлы/теги/ссылки
for (const QString &f : files)
parseFile(f);
// Дедупликация рёбер
QSet<QString> seen;
QVariantList uniq;
for (const auto &e : std::as_const(m_edges)) {
const QString k = e.toMap().value("from").toString() + "->" + e.toMap().value("to").toString();
if (!seen.contains(k)) {
seen.insert(k);
uniq.append(e);
}
}
m_edges = uniq;
m_busy = false;
emit busyChanged();
@@ -177,8 +232,13 @@ void VaultIndex::parseFile(const QString &path)
ensureNode(key, QFileInfo(path).fileName(), QStringLiteral("file"));
{
const QString base = QFileInfo(path).completeBaseName();
if (!base.isEmpty() && !m_fileByBase.contains(base))
m_fileByBase.insert(base, path);
if (!base.isEmpty()) {
const QString lower = base.toLower();
if (!m_fileByBase.contains(lower))
m_fileByBase.insert(lower, path);
if (!m_fileByBase.contains(base))
m_fileByBase.insert(base, path);
}
}
// Теги
@@ -191,12 +251,19 @@ void VaultIndex::parseFile(const QString &path)
m_filesWithTag[tag].append(path);
}
// Ссылки [[X]] и [text](rel)
// Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki)
QVariantList links;
static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]"));
auto wi = wikiRe.globalMatch(text);
while (wi.hasNext()) {
const QString target = wi.next().captured(1);
QString target = wi.next().captured(1);
// [[file|alias]] → берём file
const int pipe = target.indexOf('|');
if (pipe >= 0)
target = target.left(pipe).trimmed();
target = target.trimmed();
if (target.isEmpty())
continue;
const QString resolved = resolveLink(fileDir, target);
if (!resolved.isEmpty()) {
links.append(QVariantMap{{"text", target}, {"path", resolved}});
+92 -13
View File
@@ -27,22 +27,25 @@ struct CommitInfo {
} // namespace
bool GitManager::s_gitInitialized = false;
GitManager::GitManager(QObject *parent)
: QObject(parent)
{
if (!m_gitInitialized) {
if (!s_gitInitialized) {
git_libgit2_init();
m_gitInitialized = true;
s_gitInitialized = true;
}
}
QString GitManager::repoExists(const QString &path)
{
const QString clean = QDir::cleanPath(path);
git_repository *repo = nullptr;
if (git_repository_open(&repo, path.toUtf8().constData()) < 0)
if (git_repository_open(&repo, clean.toUtf8().constData()) < 0)
return QString();
git_repository_free(repo);
return path;
return clean;
}
void GitManager::setRepoIdentity(git_repository *repo)
@@ -72,11 +75,18 @@ void GitManager::setLastError(const QString &msg)
bool GitManager::initRepo(const QString &path)
{
const QByteArray p = path.toUtf8();
const QString clean = QDir::cleanPath(path.trimmed());
if (clean.isEmpty()) {
setLastError(QStringLiteral("Путь к хранилищу пустой"));
return false;
}
const QByteArray p = clean.toUtf8();
git_repository *repo = nullptr;
if (git_repository_open(&repo, p.constData()) == 0) {
setRepoIdentity(repo);
git_repository_free(repo);
setLastError(QString());
return true; // .git уже есть
}
@@ -98,18 +108,89 @@ bool GitManager::initRepo(const QString &path)
bool GitManager::hasCommits(const QString &path) const
{
const QString clean = QDir::cleanPath(path);
git_repository *repo = nullptr;
if (git_repository_open(&repo, path.toUtf8().constData()) < 0)
if (git_repository_open(&repo, clean.toUtf8().constData()) < 0)
return false;
const bool has = git_repository_head_unborn(repo) == 0;
git_repository_free(repo);
return has;
}
QString GitManager::fileGitStatus(const QString &vaultPath, const QString &filePath) const
{
if (vaultPath.isEmpty() || filePath.isEmpty())
return QStringLiteral("—");
const QString vault = QDir::cleanPath(vaultPath);
const QString file = QDir::cleanPath(filePath);
if (!file.startsWith(vault + '/') && file != vault)
return QStringLiteral("—");
git_repository *repo = nullptr;
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
return QStringLiteral("—");
// относительный путь внутри репозитория
QString rel = file;
if (rel == vault) {
git_repository_free(repo);
return QStringLiteral("—");
}
if (rel.startsWith(vault)) {
rel = rel.mid(vault.length());
while (rel.startsWith("/") || rel.startsWith("\\"))
rel.remove(0, 1);
}
if (rel.isEmpty()) {
git_repository_free(repo);
return QStringLiteral("—");
}
unsigned int status = 0;
const int rc = git_status_file(&status, repo, rel.toUtf8().constData());
git_repository_free(repo);
if (rc != 0) {
// файл не найден в индексе или ошибка — считаем неотслеживаемым если файл существует
if (QFile::exists(filePath))
return QStringLiteral("неотслеживается");
return QStringLiteral("—");
}
if (status == GIT_STATUS_CURRENT)
return QStringLiteral("чисто");
if (status & GIT_STATUS_CONFLICTED)
return QStringLiteral("конфликт");
if (status & GIT_STATUS_WT_NEW)
return QStringLiteral("неотслеживается");
if (status & GIT_STATUS_INDEX_NEW)
return QStringLiteral("в индексе (новый)");
if (status & GIT_STATUS_INDEX_MODIFIED)
return QStringLiteral("в индексе");
if (status & GIT_STATUS_WT_MODIFIED)
return QStringLiteral("изменён");
if (status & GIT_STATUS_WT_DELETED)
return QStringLiteral("удалён");
if (status & GIT_STATUS_IGNORED)
return QStringLiteral("игнорируется");
return QStringLiteral("чисто");
}
QString GitManager::fileGitStatusShort(const QString &vaultPath, const QString &filePath) const
{
const QString s = fileGitStatus(vaultPath, filePath);
if (s == "чисто") return QStringLiteral("чисто");
if (s == "изменён") return QStringLiteral("изменён");
if (s == "неотслеживается") return QStringLiteral("новый");
if (s.contains("конфликт")) return QStringLiteral("конфликт");
if (s == "—") return QStringLiteral("—");
return s;
}
bool GitManager::commitAll(const QString &path, const QString &message)
{
const QString cleanPath = QDir::cleanPath(path);
CommitInfo info;
if (git_repository_open(&info.repo, path.toUtf8().constData()) < 0) {
if (git_repository_open(&info.repo, cleanPath.toUtf8().constData()) < 0) {
setLastError(QStringLiteral("Хранилище не является git-репозиторием"));
return false;
}
@@ -119,18 +200,16 @@ bool GitManager::commitAll(const QString &path, const QString &message)
return false;
}
// Добавляем все файлы, включая новые (с учётом .gitignore)
git_index_add_all(info.index, nullptr, 0, nullptr, nullptr);
// Добавляем все файлы, включая новые и удаления (с учётом .gitignore)
git_index_add_all(info.index, nullptr, GIT_INDEX_ADD_DEFAULT, nullptr, nullptr);
const int wrc = git_index_write(info.index);
if (wrc < 0) {
setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr()));
return false;
}
if (git_index_entrycount(info.index) == 0) {
setLastError(QStringLiteral("Нет изменений для коммита"));
return false;
}
// Не проверяем entrycount==0 отдельно — пустой индекс может означать удаление всех файлов,
// что тоже нужно коммитить. Проверяем равенство дерева ниже.
// Создаём дерево
git_tree *tree = nullptr;
+7 -1
View File
@@ -23,6 +23,12 @@ public:
// Есть ли в хранилище хотя бы один коммит.
Q_INVOKABLE bool hasCommits(const QString &path) const;
// Статус файла в git: "чисто", "изменён", "неотслеживается", "в индексе", "конфликт", "—" (не git)
Q_INVOKABLE QString fileGitStatus(const QString &vaultPath, const QString &filePath) const;
// Короткий статус для отображения в UI: "сохранено" / "изменён" / "новый" etc
Q_INVOKABLE QString fileGitStatusShort(const QString &vaultPath, const QString &filePath) const;
// Последнее сообщение об ошибке (русскоязычное, из каталога ошибок).
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
@@ -38,5 +44,5 @@ private:
void setLastError(const QString &msg);
QString m_lastError;
bool m_gitInitialized = false;
static bool s_gitInitialized;
};
+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;
};
+59 -7
View File
@@ -1,5 +1,6 @@
#include "sync/SyncManager.h"
#include <QTimer>
#include <QUrl>
namespace {
@@ -100,18 +101,49 @@ bool SyncManager::busy() const { return m_busy; }
QString SyncManager::lastStatus() const { return m_lastStatus; }
void SyncManager::testConnection(int provider, const QString &url,
const QString &username, const QString &token)
const QString &username, const QString &token)
{
if (provider < 0 || provider > Syncthing) {
finish(provider, false, NetworkError, QStringLiteral("Неизвестный провайдер"), QString(), QString());
return;
}
if (token.trimmed().isEmpty()) {
finish(provider, false, Unauthorized, QStringLiteral("Токен не указан"),
QStringLiteral("Вставьте токен/пароль."), QString());
return;
}
QUrl base(stripUrl(url));
if (!base.isValid() || base.toString().isEmpty()) {
finish(provider, false, NetworkError, QStringLiteral("Неверный URL"),
QStringLiteral("Проверьте адрес сервера."), QString());
return;
}
QNetworkRequest req;
req.setTransferTimeout(15000);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
switch (static_cast<Provider>(provider)) {
case GitHub:
req.setUrl(QUrl(QStringLiteral("https://api.github.com/user")));
case GitHub: {
// Поддержка GitHub Enterprise: если указан кастомный URL, используем его
QUrl ghUrl = base;
if (base.host().contains(QStringLiteral("github.com")) || base.toString() == stripUrl(QStringLiteral("https://github.com"))
|| base.toString().isEmpty()) {
ghUrl = QUrl(QStringLiteral("https://api.github.com/user"));
} else {
// Enterprise: https://github.example.com/api/v3/user
QString ep = base.toString();
if (!ep.endsWith(QStringLiteral("/api/v3/user")) && !ep.endsWith(QStringLiteral("/api/v3")))
ghUrl = QUrl(ep + QStringLiteral("/api/v3/user"));
else if (ep.endsWith(QStringLiteral("/api/v3")))
ghUrl = QUrl(ep + QStringLiteral("/user"));
else
ghUrl = base;
}
req.setUrl(ghUrl);
req.setRawHeader("Authorization", "Bearer " + token.toUtf8());
req.setRawHeader("Accept", "application/vnd.github+json");
break;
}
case GitLab: {
req.setUrl(QUrl(base.toString() + QStringLiteral("/api/v4/user")));
req.setRawHeader("PRIVATE-TOKEN", token.toUtf8());
@@ -123,6 +155,11 @@ void SyncManager::testConnection(int provider, const QString &url,
break;
}
case WebDav: {
if (!base.scheme().startsWith(QStringLiteral("http"))) {
finish(provider, false, NetworkError, QStringLiteral("Неверный URL WebDAV"),
QStringLiteral("Адрес должен начинаться с http:// или https://"), QString());
return;
}
req.setUrl(base);
req.setRawHeader("Depth", "0");
const QString u = username + ':' + token;
@@ -137,11 +174,16 @@ void SyncManager::testConnection(int provider, const QString &url,
}
}
m_pending++;
m_busy = true;
emit busyChanged();
QNetworkReply *reply = m_nam.sendCustomRequest(req,
static_cast<Provider>(provider) == WebDav ? "PROPFIND" : "GET");
reply->setProperty("flora_provider", provider);
// Таймаут на случай если QNetworkAccessManager не сработает
QTimer::singleShot(16000, reply, [reply](){
if (reply->isRunning()) reply->abort();
});
}
void SyncManager::handleReply(QNetworkReply *reply, int provider)
@@ -150,10 +192,19 @@ void SyncManager::handleReply(QNetworkReply *reply, int provider)
const int httpStatus = reply->attribute(
QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString raw = QString::fromUtf8(reply->readAll());
const QString errStr = reply->errorString();
// Приоритет отдаём HTTP-статусу если он есть, даже при наличии network error
if (httpStatus != 0) {
const auto [code, pair] = catalog(provider, httpStatus, errStr);
const bool ok = (code == Ok);
finish(provider, ok, code, pair.first, pair.second,
raw.isEmpty() ? errStr : raw);
return;
}
if (reply->error() != QNetworkReply::NoError) {
const auto [code, pair] = catalog(provider, 0, reply->errorString());
const auto [code, pair] = catalog(provider, 0, errStr);
finish(provider, false, code, pair.first, pair.second,
reply->errorString() + (raw.isEmpty() ? QString() : QStringLiteral(" | ") + raw));
errStr + (raw.isEmpty() ? QString() : QStringLiteral(" | ") + raw));
return;
}
const auto [code, pair] = catalog(provider, httpStatus, QString());
@@ -161,9 +212,10 @@ void SyncManager::handleReply(QNetworkReply *reply, int provider)
}
void SyncManager::finish(int provider, bool ok, int code, const QString &title,
const QString &hint, const QString &raw)
const QString &hint, const QString &raw)
{
m_busy = false;
if (m_pending > 0) m_pending--;
m_busy = (m_pending > 0);
m_lastStatus = ok ? QStringLiteral("Подключение установлено")
: title + (hint.isEmpty() ? QString() : QStringLiteral(" — ") + hint);
emit busyChanged();
+1
View File
@@ -64,5 +64,6 @@ private:
QNetworkAccessManager m_nam;
bool m_busy = false;
int m_pending = 0;
QString m_lastStatus;
};