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:
+48
-12
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -64,5 +64,6 @@ private:
|
||||
|
||||
QNetworkAccessManager m_nam;
|
||||
bool m_busy = false;
|
||||
int m_pending = 0;
|
||||
QString m_lastStatus;
|
||||
};
|
||||
Reference in New Issue
Block a user