From fdf59f5b39db8b25cd9936177eabee77ba63cc22 Mon Sep 17 00:00:00 2001 From: grendervill Date: Mon, 7 Sep 2026 16:30:06 +0300 Subject: [PATCH] =?UTF-8?q?SQLite-=D0=B1=D0=B0=D0=B7=D0=B0=20=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B0:=20=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=D1=81=D1=82=D0=B2=D0=B0,=20~sql-=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frontmatter: шапка --- ключ: значение (источник правды свойств) - VaultSettings: .flora/settings.json (журнал, sqlEnabled, sqlMaxRows) - Database: files/tags/file_tags/links/properties, только SELECT/WITH на read-only соединении (mode=ro + query_only + зонд), лимит строк - MdParser: ~sql-блоки таблицей (parseBlocks + Renderer), шапка скрыта в просмотре - Окно: per-window Database/VaultSettings, sync при сохранении, дебаунс reindex - QML: VaultSettingsDialog, FilePropertiesDialog, меню, автообновление ~sql - FileStore.journalDir, DocumentManager.reloadDocument - Тест flora_db_test (23/23 зелёных) --- CMakeLists.txt | 8 + qml/MainWindow.qml | 43 ++ qml/dialogs/FilePropertiesDialog.qml | 181 ++++++++ qml/dialogs/VaultSettingsDialog.qml | 163 +++++++ qml/panels/MarkdownView.qml | 10 + qml/panels/MonthCalendar.qml | 2 +- qml/panels/RightPanel.qml | 5 +- src/app/WindowController.cpp | 55 ++- src/app/WindowController.h | 8 +- src/core/DocumentManager.cpp | 18 + src/core/DocumentManager.h | 4 + src/core/FileStore.cpp | 20 +- src/core/FileStore.h | 6 + src/core/Frontmatter.cpp | 93 ++++ src/core/Frontmatter.h | 31 ++ src/core/VaultIndex.cpp | 5 + src/core/VaultIndex.h | 3 + src/core/VaultSettings.cpp | 130 ++++++ src/core/VaultSettings.h | 44 ++ src/db/Database.cpp | 632 +++++++++++++++++++++++++++ src/db/Database.h | 85 ++++ src/main.cpp | 2 +- src/markdown/MdParser.cpp | 150 ++++++- src/markdown/MdParser.h | 11 + tests/CMakeLists.txt | 40 +- tests/db_test.cpp | 240 ++++++++++ 26 files changed, 1972 insertions(+), 17 deletions(-) create mode 100644 qml/dialogs/FilePropertiesDialog.qml create mode 100644 qml/dialogs/VaultSettingsDialog.qml create mode 100644 src/core/Frontmatter.cpp create mode 100644 src/core/Frontmatter.h create mode 100644 src/core/VaultSettings.cpp create mode 100644 src/core/VaultSettings.h create mode 100644 src/db/Database.cpp create mode 100644 src/db/Database.h create mode 100644 tests/db_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7fc7284..35ea625 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,8 +42,11 @@ set(FLORA_HEADERS src/app/WindowController.h src/core/DocumentManager.h src/core/FileStore.h + src/core/Frontmatter.h src/core/SettingsStore.h src/core/VaultIndex.h + src/core/VaultSettings.h + src/db/Database.h src/git/GitManager.h src/markdown/MdParser.h src/sync/SyncManager.h @@ -54,8 +57,11 @@ set(FLORA_SOURCES src/app/WindowController.cpp src/core/DocumentManager.cpp src/core/FileStore.cpp + src/core/Frontmatter.cpp src/core/SettingsStore.cpp src/core/VaultIndex.cpp + src/core/VaultSettings.cpp + src/db/Database.cpp src/git/GitManager.cpp src/markdown/MdParser.cpp src/sync/SyncManager.cpp @@ -132,6 +138,8 @@ qt_add_qml_module(flora qml/dialogs/TableEditor.qml qml/dialogs/HistoryDialog.qml qml/dialogs/SettingsDialog.qml + qml/dialogs/VaultSettingsDialog.qml + qml/dialogs/FilePropertiesDialog.qml ) # Theme как синглтон diff --git a/qml/MainWindow.qml b/qml/MainWindow.qml index a43b110..dea8e95 100644 --- a/qml/MainWindow.qml +++ b/qml/MainWindow.qml @@ -87,6 +87,11 @@ ApplicationWindow { } MenuSeparator { } Action { text: qsTr("Настройки…"); onTriggered: settingsDialog.open() } + Action { + text: qsTr("Настройки хранилища…") + enabled: fileStore.ready + onTriggered: vaultSettingsDialog.open() + } Action { text: qsTr("Выход"); onTriggered: window.close() } } Menu { @@ -119,6 +124,18 @@ ApplicationWindow { } } } + MenuSeparator { } + Action { + text: qsTr("Свойства заметки…") + enabled: documentManager.activeIndex >= 0 && fileStore.ready + onTriggered: { + const p = documentManager.pathAt(documentManager.activeIndex) + if (p.length>0) { + filePropsDialog.filePath = p + filePropsDialog.open() + } + } + } } Menu { title: qsTr("&Вид") @@ -183,6 +200,32 @@ ApplicationWindow { id: settingsDialog } + VaultSettingsDialog { + id: vaultSettingsDialog + } + + FilePropertiesDialog { + id: filePropsDialog + } + + // Fallback для пути визарда: хранилище открыли прямо в этом окне + // (не через WindowController) — открываем настройки и базу здесь. + // Обычный путь уже подключён в C++, повторный open() безвреден (guard ниже). + function ensureVaultDb() { + if (!fileStore.ready) + return + if (vaultDatabase.isOpen && vaultDatabase.vault === fileStore.vault) + return + vaultSettings.open(fileStore.vault) + if (vaultDatabase.open(fileStore.vault)) + mdParser.setDatabaseForVault(fileStore.vault, vaultDatabase) + } + Connections { + target: fileStore + function onVaultChanged() { window.ensureVaultDb() } + function onReadyChanged() { if (fileStore.ready) window.ensureVaultDb() } + } + Popup { id: vaultSwitcher x: 12 diff --git a/qml/dialogs/FilePropertiesDialog.qml b/qml/dialogs/FilePropertiesDialog.qml new file mode 100644 index 0000000..f9b9628 --- /dev/null +++ b/qml/dialogs/FilePropertiesDialog.qml @@ -0,0 +1,181 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Flora + +// Свойства заметки (Notion-поля): источник правды — frontmatter в самом .md, +// база обновляется следом. Грязную вкладку правим только после сохранения. +Dialog { + id: root + + property string filePath: "" + + title: qsTr("Свойства заметки") + modal: true + width: 520 + height: 560 + anchors.centerIn: parent + closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside + standardButtons: Dialog.NoButton + background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 } + + footer: RowLayout { + spacing: 8 + Item { Layout.fillWidth: true } + Button { + text: qsTr("Отмена") + onClicked: root.reject() + } + Button { + text: qsTr("Сохранить") + highlighted: true + onClicked: root.saveProps() + } + } + + onOpened: { + statusLabel.text = "" + newKeyField.text = "" + newValueField.text = "" + propsModel.clear() + const props = vaultDatabase.fileProperties(root.filePath) + const keys = Object.keys(props).sort() + for (let i = 0; i < keys.length; ++i) + propsModel.append({ key: keys[i], value: props[keys[i]] }) + } + + function saveProps() { + const map = {} + for (let i = 0; i < propsModel.count; ++i) { + const row = propsModel.get(i) + map[row.key] = row.value + } + const err = vaultDatabase.saveFileProperties(root.filePath, map) + if (err.length > 0) { + statusLabel.text = err + return + } + // Обновить открытые вкладки с этим файлом (только чистые) + let dirty = false + for (let t = 0; t < documentManager.openTabs.length; ++t) { + if (documentManager.pathAt(t) === root.filePath) { + if (documentManager.isDirty(t)) { + dirty = true + } else { + documentManager.reloadDocument(t) + } + } + } + vaultIndex.updateFile(root.filePath) + vaultDatabase.syncFile(root.filePath) + if (dirty) { + statusLabel.text = qsTr("Свойства записаны, но вкладка изменена — сохраните её (Ctrl+S) и переоткройте.") + return + } + root.accept() + } + + function addProp() { + const k = newKeyField.text.trim() + const v = newValueField.text + if (k.length === 0) { + statusLabel.text = qsTr("Укажите имя свойства.") + return + } + for (let i = 0; i < propsModel.count; ++i) { + if (propsModel.get(i).key === k) { + statusLabel.text = qsTr("Такое свойство уже есть — правьте его в списке.") + return + } + } + propsModel.append({ key: k, value: v }) + newKeyField.text = "" + newValueField.text = "" + statusLabel.text = "" + } + + ListModel { id: propsModel } + + ColumnLayout { + anchors.fill: parent + spacing: 10 + + Label { + Layout.fillWidth: true + color: Theme.textSecondary + font.pixelSize: 11 + text: root.filePath + elide: Text.ElideMiddle + } + + ListView { + id: propsView + Layout.fillWidth: true + Layout.fillHeight: true + model: propsModel + clip: true + spacing: 6 + delegate: RowLayout { + width: propsView.width + spacing: 8 + Label { + text: model.key + color: Theme.text + font.bold: true + Layout.preferredWidth: 140 + elide: Text.ElideRight + } + TextField { + Layout.fillWidth: true + text: model.value + placeholderText: qsTr("значение") + onTextChanged: propsModel.setProperty(index, "value", text) + } + Button { + text: "✕" + onClicked: propsModel.remove(index) + } + } + Label { + visible: propsModel.count === 0 + anchors.centerIn: parent + text: qsTr("Свойств нет — добавьте первое ниже") + color: Theme.textFaint + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + TextField { + id: newKeyField + Layout.preferredWidth: 150 + placeholderText: qsTr("имя") + onAccepted: root.addProp() + } + TextField { + id: newValueField + Layout.fillWidth: true + placeholderText: qsTr("значение") + onAccepted: root.addProp() + } + Button { + text: qsTr("Добавить") + onClicked: root.addProp() + } + } + + Label { + text: qsTr("Имя: буквы, цифры, _ и - (до 64 символов).") + color: Theme.textFaint + font.pixelSize: 11 + } + Label { + id: statusLabel + Layout.fillWidth: true + wrapMode: Text.Wrap + color: "#e5484d" + font.pixelSize: 11 + } + } +} diff --git a/qml/dialogs/VaultSettingsDialog.qml b/qml/dialogs/VaultSettingsDialog.qml new file mode 100644 index 0000000..406d65a --- /dev/null +++ b/qml/dialogs/VaultSettingsDialog.qml @@ -0,0 +1,163 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Flora + +// Настройки одного хранилища: лежат в /.flora/settings.json, +// в git не попадают (как и сама база). Применяются живьём через vaultSettings. +Dialog { + id: root + + title: qsTr("Настройки хранилища") + modal: true + width: 560 + height: 640 + anchors.centerIn: parent + closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside + standardButtons: Dialog.NoButton + background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 } + + footer: RowLayout { + spacing: 8 + Item { Layout.fillWidth: true } + Button { + text: qsTr("Отмена") + onClicked: { + // откатить живьём применённое (сеттеры уже ушли в базу через changed) + vaultSettings.open(fileStore.vault) + root.reject() + } + } + Button { + text: qsTr("Сохранить") + highlighted: true + onClicked: root.saveSettings() + } + } + + onOpened: { + journalField.text = vaultSettings.journalDir + sqlCheck.checked = vaultSettings.sqlEnabled + maxRowsSpin.value = vaultSettings.sqlMaxRows + root.refreshStats() + statusLabel.text = "" + } + + function saveSettings() { + vaultSettings.journalDir = journalField.text + vaultSettings.sqlEnabled = sqlCheck.checked + vaultSettings.sqlMaxRows = maxRowsSpin.value + // Флаги уже ушли в базу живьём (сигнал changed); здесь только persist + if (!vaultSettings.save()) { + statusLabel.text = qsTr("Не удалось записать .flora/settings.json") + return + } + root.accept() + } + + function refreshStats() { + const s = vaultDatabase.stats() + statFiles.text = s.files + statTags.text = s.tags + statLinks.text = s.links + statProps.text = s.properties + const kb = Math.round(s.dbSize / 1024) + statDb.text = kb < 1024 ? kb + " КБ" : (kb / 1024).toFixed(1) + " МБ" + } + + ColumnLayout { + anchors.fill: parent + spacing: 10 + + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Theme.textSecondary + font.pixelSize: 11 + text: fileStore.ready ? fileStore.vault : qsTr("Хранилище не открыто") + elide: Text.ElideMiddle + } + + GroupBox { + Layout.fillWidth: true + title: qsTr("Заметки дня") + background: Rectangle { color: Theme.bgAlt; border.color: Theme.border; radius: 8 } + label: Label { text: parent.title; color: Theme.text; font.bold: true; leftPadding: 6 } + RowLayout { + anchors.fill: parent + anchors.margins: 8 + Label { text: qsTr("Папка:"); color: Theme.textSecondary } + TextField { + id: journalField + Layout.fillWidth: true + placeholderText: qsTr("Журнал") + } + } + } + + GroupBox { + Layout.fillWidth: true + title: qsTr("SQL-блоки (~sql)") + background: Rectangle { color: Theme.bgAlt; border.color: Theme.border; radius: 8 } + label: Label { text: parent.title; color: Theme.text; font.bold: true; leftPadding: 6 } + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 6 + CheckBox { + id: sqlCheck + text: qsTr("Выполнять ~sql-запросы в просмотре") + } + RowLayout { + Label { text: qsTr("Строк в результате:"); color: Theme.textSecondary } + SpinBox { id: maxRowsSpin; from: 5; to: 500; stepSize: 5; editable: true } + } + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Theme.textFaint + font.pixelSize: 11 + text: qsTr("Таблицы: files(path,title,mtime,size), tags(name), file_tags, links, properties(file_id,key,value). Только чтение (SELECT / WITH).") + } + } + } + + GroupBox { + Layout.fillWidth: true + title: qsTr("База хранилища") + background: Rectangle { color: Theme.bgAlt; border.color: Theme.border; radius: 8 } + label: Label { text: parent.title; color: Theme.text; font.bold: true; leftPadding: 6 } + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 4 + GridLayout { + columns: 2 + columnSpacing: 12 + Label { text: qsTr("Файлов:"); color: Theme.textSecondary } + Label { id: statFiles; color: Theme.text } + Label { text: qsTr("Тегов:"); color: Theme.textSecondary } + Label { id: statTags; color: Theme.text } + Label { text: qsTr("Связей:"); color: Theme.textSecondary } + Label { id: statLinks; color: Theme.text } + Label { text: qsTr("Свойств:"); color: Theme.textSecondary } + Label { id: statProps; color: Theme.text } + Label { text: qsTr("База:"); color: Theme.textSecondary } + Label { id: statDb; color: Theme.text } + } + Button { + text: qsTr("Обновить статистику") + onClicked: root.refreshStats() + } + } + } + + Label { + id: statusLabel + Layout.fillWidth: true + wrapMode: Text.Wrap + color: "#e5484d" + font.pixelSize: 11 + } + } +} diff --git a/qml/panels/MarkdownView.qml b/qml/panels/MarkdownView.qml index 0efb553..88ee1d6 100644 --- a/qml/panels/MarkdownView.qml +++ b/qml/panels/MarkdownView.qml @@ -44,6 +44,16 @@ Item { // contentItem.contentY = 0 } + // База изменилась (переиндексация, правка свойств, вкл/выкл SQL) — + // ~sql-блоки надо пересчитать. Дебаунс против шторма при reindex. + Timer { id: dbDebounce; interval: 400; onTriggered: root.update() } + Connections { + target: vaultDatabase + function onChanged() { dbDebounce.restart() } + function onQueryEnabledChanged() { dbDebounce.restart() } + function onMaxRowsChanged() { dbDebounce.restart() } + } + Connections { target: mdParser function onBlocksReady(reqId, blocks) { diff --git a/qml/panels/MonthCalendar.qml b/qml/panels/MonthCalendar.qml index e4abd4c..c2e8fd7 100644 --- a/qml/panels/MonthCalendar.qml +++ b/qml/panels/MonthCalendar.qml @@ -181,7 +181,7 @@ Item { readonly property bool hasNote: { const _ = cal.currentMonth if (modelData <= 0 || !fileStore.ready) return false - return fileStore.fileExists(fileStore.vault + "/Журнал/" + ds + ".md") + return fileStore.fileExists(fileStore.vault + "/" + fileStore.journalDir + "/" + ds + ".md") } readonly property bool isSelected: false // можно подсветить выбранный день diff --git a/qml/panels/RightPanel.qml b/qml/panels/RightPanel.qml index 09e1568..ecc714f 100644 --- a/qml/panels/RightPanel.qml +++ b/qml/panels/RightPanel.qml @@ -35,9 +35,8 @@ Item { function onActiveIndexChanged() { root.refreshLinksTags() } function onOpenTabsChanged() { root.refreshLinksTags() } function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); indexDebounce.restart() } } - function onDocumentSaved(idx, path) { - if (fileStore.ready && path && path.length > 0) vaultIndex.updateFile(path) - } + // Сохранение обрабатывает C++ (WindowController): сначала индекс, потом база. + // Здесь не дублируем, иначе двойной парс файла. } Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } } // Правка активного файла: обновить только его (один парс), а не всё хранилище diff --git a/src/app/WindowController.cpp b/src/app/WindowController.cpp index ccb62fc..17a6fe2 100644 --- a/src/app/WindowController.cpp +++ b/src/app/WindowController.cpp @@ -5,17 +5,23 @@ #include #include #include +#include #include "core/DocumentManager.h" #include "core/FileStore.h" #include "core/VaultIndex.h" +#include "core/VaultSettings.h" +#include "db/Database.h" +#include "markdown/MdParser.h" WindowController::WindowController(QQmlApplicationEngine *engine, const QString &mainWindowUrl, + MdParser *mdParser, QObject *parent) : QObject(parent) , m_engine(engine) , m_mainWindowUrl(mainWindowUrl) + , m_mdParser(mdParser) { } @@ -76,9 +82,37 @@ QObject *WindowController::openWindow(const QString &vaultPath) FileStore *store = new FileStore(ctx); DocumentManager *docMgr = new DocumentManager(ctx); VaultIndex *vIndex = new VaultIndex(ctx); + VaultSettings *vSettings = new VaultSettings(ctx); + Database *db = new Database(ctx); + db->setIndex(vIndex); ctx->setContextProperty("fileStore", store); ctx->setContextProperty("documentManager", docMgr); ctx->setContextProperty("vaultIndex", vIndex); + ctx->setContextProperty("vaultSettings", vSettings); + ctx->setContextProperty("vaultDatabase", db); + + // Сохранение → сначала индекс, потом база (порядок важен: syncFile читает индекс). + // Заменяет QML-обработчик onDocumentSaved (иначе двойной парс). + connect(docMgr, &DocumentManager::documentSaved, this, + [vIndex, db](int, const QString &path) { + if (path.isEmpty()) + return; + vIndex->updateFile(path); + db->syncFile(path); + }); + // Настройки хранилища → живьём в базу (флаги читаются при каждом запросе). + connect(vSettings, &VaultSettings::changed, this, [vSettings, db, store]() { + db->setQueryEnabled(vSettings->sqlEnabled()); + db->setMaxRows(vSettings->sqlMaxRows()); + store->setJournalDir(vSettings->journalDir()); + }); + // Создание/удаление/переименование/внешние изменения и rebuild: + // точечно не знаем что — дебаунс и полная переиндексация базы (редко, не на каждую печать). + QTimer *dbTimer = new QTimer(ctx); + dbTimer->setSingleShot(true); + dbTimer->setInterval(1500); + connect(vIndex, &VaultIndex::indexChanged, dbTimer, qOverload<>(&QTimer::start)); + connect(dbTimer, &QTimer::timeout, db, [db]() { db->reindex(); }); QObject *obj = m_component->create(ctx); if (!obj) { @@ -98,13 +132,17 @@ QObject *WindowController::openWindow(const QString &vaultPath) } 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]() { + m_windows.append({cleanVault, obj, store, docMgr, vIndex, vSettings, db, ctx}); + MdParser *mdParser = m_mdParser; + connect(obj, &QObject::destroyed, this, [this, obj, ctx, mdParser]() { for (int i = 0; i < m_windows.size(); ++i) { if (m_windows[i].object == obj) { const QString vault = m_windows[i].vault; + Database *closedDb = m_windows[i].database; QQmlContext *c = m_windows[i].context; m_windows.removeAt(i); + if (mdParser && closedDb && !vault.isEmpty()) + mdParser->removeDatabaseForVault(vault, closedDb); if (c) c->deleteLater(); emit windowsChanged(); @@ -127,8 +165,19 @@ QObject *WindowController::openWindow(const QString &vaultPath) // Устанавливаем через динамическое свойство если поддерживается if (obj->property("destroyOnWindowClose").isValid()) obj->setProperty("destroyOnWindowClose", true); - if (!cleanVault.isEmpty() && QDir(cleanVault).exists()) + if (!cleanVault.isEmpty() && QDir(cleanVault).exists()) { store->openVault(cleanVault); + // Настройки → журнал/флаги → база → реестр ~sql. + // (QML ensureVaultDb мог уже открыть базу по vaultChanged — guard от повтора.) + vSettings->open(cleanVault); + store->setJournalDir(vSettings->journalDir()); + db->setQueryEnabled(vSettings->sqlEnabled()); + db->setMaxRows(vSettings->sqlMaxRows()); + if ((!db->isOpen() || db->vault() != cleanVault) && db->open(cleanVault) + && mdParser) + mdParser->setDatabaseForVault(cleanVault, db); + // Индекс подтянет QML (rebuild), базу — дебаунс по indexChanged + } emit windowsChanged(); if (!vaultPath.isEmpty()) diff --git a/src/app/WindowController.h b/src/app/WindowController.h index 10e5f9d..8e44881 100644 --- a/src/app/WindowController.h +++ b/src/app/WindowController.h @@ -8,6 +8,9 @@ class FileStore; class DocumentManager; class VaultIndex; +class VaultSettings; +class Database; +class MdParser; // Создание окон приложения (мульти-хранилища: новое хранилище — новое окно). class WindowController : public QObject @@ -19,7 +22,7 @@ class WindowController : public QObject public: WindowController(QQmlApplicationEngine *engine, const QString &mainWindowUrl, - QObject *parent = nullptr); + MdParser *mdParser, QObject *parent = nullptr); int windowCount() const; QStringList openVaults() const; @@ -41,11 +44,14 @@ private: FileStore *fileStore = nullptr; DocumentManager *docManager = nullptr; VaultIndex *vaultIndex = nullptr; + VaultSettings *vaultSettings = nullptr; + Database *database = nullptr; QQmlContext *context = nullptr; }; QQmlApplicationEngine *m_engine; QString m_mainWindowUrl; + MdParser *m_mdParser = nullptr; // общий (реестр ~sql-баз по хранилищам) QList m_windows; QQmlComponent *m_component = nullptr; }; \ No newline at end of file diff --git a/src/core/DocumentManager.cpp b/src/core/DocumentManager.cpp index 738eb04..4fac6b5 100644 --- a/src/core/DocumentManager.cpp +++ b/src/core/DocumentManager.cpp @@ -251,6 +251,24 @@ void DocumentManager::saveAll() saveDocument(i); } +QString DocumentManager::reloadDocument(int index) +{ + if (index < 0 || index >= m_docs.size()) + return QStringLiteral("Нет такой вкладки."); + Doc &d = m_docs[index]; + if (d.dirty) + return QStringLiteral("Вкладка изменена — сначала сохраните её."); + bool ok = false; + const QString text = readFile(d.path, &ok); + if (!ok) + return QStringLiteral("Не удалось прочитать файл."); + if (d.content == text) + return QString(); + d.content = text; + emit contentChanged(index); + return QString(); +} + bool DocumentManager::closeTab(int index) { if (index < 0 || index >= m_docs.size()) diff --git a/src/core/DocumentManager.h b/src/core/DocumentManager.h index 74edee0..506f96c 100644 --- a/src/core/DocumentManager.h +++ b/src/core/DocumentManager.h @@ -55,6 +55,10 @@ public: Q_INVOKABLE void saveDocument(int index); Q_INVOKABLE void saveAll(); + // Перечитать файл с диска в чистую вкладку (после правки свойств извне + // редактора). Грязную вкладку не трогает. Возвращает "" или текст ошибки. + Q_INVOKABLE QString reloadDocument(int index); + Q_INVOKABLE bool closeTab(int index); Q_INVOKABLE void moveTab(int from, int to); diff --git a/src/core/FileStore.cpp b/src/core/FileStore.cpp index c5de68d..8936788 100644 --- a/src/core/FileStore.cpp +++ b/src/core/FileStore.cpp @@ -105,6 +105,24 @@ QString FileStore::openVault(const QString &path) return QString(); } +void FileStore::setJournalDir(const QString &d) +{ + // Та же валидация, что в настройках: один сегмент, без выхода наверх + QString t = d.trimmed(); + t.replace(QLatin1Char('\\'), QLatin1Char('/')); + while (t.startsWith(QLatin1Char('/'))) + t.remove(0, 1); + while (t.endsWith(QLatin1Char('/')) && t.size() > 1) + t.chop(1); + if (t.isEmpty() || t == "." || t == ".." || t.contains(QLatin1String("..")) + || t.contains(QLatin1Char('/')) || t.startsWith(QLatin1Char('.'))) + t = QStringLiteral("Журнал"); + if (m_journalDir == t) + return; + m_journalDir = t; + emit journalDirChanged(); +} + void FileStore::rebuild() { QVector rows; @@ -357,7 +375,7 @@ QString FileStore::dailyNotePath(const QString &dateStr) 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 + "/Журнал"); + const QString journalDir = QDir::cleanPath(m_vault + QLatin1Char('/') + m_journalDir); if (!isInsideVault(journalDir)) return QString(); if (!QDir().mkpath(journalDir)) diff --git a/src/core/FileStore.h b/src/core/FileStore.h index 2543152..a5df7b2 100644 --- a/src/core/FileStore.h +++ b/src/core/FileStore.h @@ -13,6 +13,8 @@ class FileStore : public QAbstractListModel Q_PROPERTY(QString vault READ vault NOTIFY vaultChanged) Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) + // Папка заметок дня внутри хранилища (из настроек хранилища, по умолчанию «Журнал»). + Q_PROPERTY(QString journalDir READ journalDir WRITE setJournalDir NOTIFY journalDirChanged) public: enum Roles { @@ -30,6 +32,8 @@ public: QString vault() const { return m_vault; } bool ready() const { return !m_vault.isEmpty(); } + QString journalDir() const { return m_journalDir; } + void setJournalDir(const QString &d); QModelIndex index(int row, int column = 0, const QModelIndex &parent = QModelIndex()) const override; @@ -63,6 +67,7 @@ public: signals: void vaultChanged(); void readyChanged(); + void journalDirChanged(); void fileActivated(const QString &path); void statusMessage(const QString &message); // Сработало внешнее изменение (синхронизация/другая программа) — дерево уже @@ -94,6 +99,7 @@ private: QString iconFor(const Row &row) const; QString m_vault; + QString m_journalDir = QStringLiteral("Журнал"); QVector m_rows; QSet m_expanded; QFileSystemWatcher m_watcher; diff --git a/src/core/Frontmatter.cpp b/src/core/Frontmatter.cpp new file mode 100644 index 0000000..43d8ce4 --- /dev/null +++ b/src/core/Frontmatter.cpp @@ -0,0 +1,93 @@ +#include "core/Frontmatter.h" + +#include + +namespace { + +bool isFenceLine(const QString &t) +{ + return t == QLatin1String("---") || t == QLatin1String("..."); +} + +QString unquote(const QString &v) +{ + if (v.size() >= 2) { + const QChar f = v.front(), l = v.back(); + if ((f == '"' && l == '"') || (f == '\'' && l == '\'')) + return v.mid(1, v.size() - 2); + } + return v; +} + +} // namespace + +bool Frontmatter::isKeyValid(const QString &key) +{ + static const QRegularExpression re(QStringLiteral("^[A-Za-z0-9_\\-а-яА-ЯёЁ]+$")); + return !key.isEmpty() && key.size() <= 64 && re.match(key).hasMatch(); +} + +Frontmatter::Parsed Frontmatter::parse(const QString &text) +{ + Parsed out; + // body заполняем всегда: без шапки body = весь текст (иначе serialize + // по такому parse сотрёт тело файла). + out.body = text; + const QStringList lines = text.split(QLatin1Char('\n')); + if (lines.isEmpty() || lines.first().trimmed() != QLatin1String("---")) + return out; // нет шапки: props пустые, body = весь текст + int close = -1; + for (int i = 1; i < lines.size(); ++i) { + if (isFenceLine(lines[i].trimmed())) { + close = i; + break; + } + } + if (close < 0) + return out; // незакрытая шапка — считаем обычным текстом + out.hasFrontmatter = true; + for (int i = 1; i < close; ++i) { + const QString line = lines[i]; + const QString t = line.trimmed(); + if (t.isEmpty() || t.startsWith(QLatin1Char('#'))) { + if (!t.isEmpty()) + out.raw.append(line); + continue; + } + const int colon = line.indexOf(QLatin1Char(':')); + if (colon <= 0) { + out.raw.append(line); + continue; + } + const QString key = line.left(colon).trimmed(); + if (!isKeyValid(key)) { + out.raw.append(line); + continue; + } + out.props.insert(key, unquote(line.mid(colon + 1).trimmed())); + } + out.body = lines.mid(close + 1).join(QLatin1Char('\n')); + return out; +} + +QString Frontmatter::serialize(const QVariantMap &props, const QStringList &raw, const QString &body) +{ + QStringList lines; + lines.append(QStringLiteral("---")); + QStringList keys = props.keys(); + keys.sort(); + for (const QString &k : keys) { + if (!isKeyValid(k)) + continue; + lines.append(k + QStringLiteral(": ") + props.value(k).toString()); + } + for (const QString &r : raw) + lines.append(r); + lines.append(QStringLiteral("---")); + QString head = lines.join(QLatin1Char('\n')); + if (body.isEmpty()) + return head + QLatin1Char('\n'); + if (body.startsWith(QLatin1Char('\n'))) + return head + body; + return head + QLatin1Char('\n') + body; +} diff --git a/src/core/Frontmatter.h b/src/core/Frontmatter.h new file mode 100644 index 0000000..e395148 --- /dev/null +++ b/src/core/Frontmatter.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +// YAML-подобная шапка заметки (Notion-свойства, единый источник правды): +// --- +// статус: в работе +// приоритет: 1 +// --- +// +// Тело заметки... +// +// Правила v1: только скалярные `ключ: значение` (строки); комментарии `#`, +// пустые строки и непонятые строки сохраняются как есть и не теряются. +class Frontmatter +{ +public: + struct Parsed { + QVariantMap props; // ключ -> QString + QStringList raw; // строки, которые не разобрали (комментарии и пр.) + QString body; // текст после шапки + bool hasFrontmatter = false; + }; + + static Parsed parse(const QString &text); + static QString serialize(const QVariantMap &props, const QStringList &raw, const QString &body); + + static bool isKeyValid(const QString &key); +}; diff --git a/src/core/VaultIndex.cpp b/src/core/VaultIndex.cpp index a0abac6..04c6190 100644 --- a/src/core/VaultIndex.cpp +++ b/src/core/VaultIndex.cpp @@ -518,4 +518,9 @@ QVariantList VaultIndex::linksOfFile(const QString &path) const QStringList VaultIndex::filesWithTag(const QString &tag) const { return m_filesWithTag.value(tag); +} + +QStringList VaultIndex::indexedFiles() const +{ + return m_fileMeta.keys(); } \ No newline at end of file diff --git a/src/core/VaultIndex.h b/src/core/VaultIndex.h index c35494d..1f7384d 100644 --- a/src/core/VaultIndex.h +++ b/src/core/VaultIndex.h @@ -45,6 +45,9 @@ public: // Все файлы с тегом tag. Q_INVOKABLE QStringList filesWithTag(const QString &tag) const; + // Все проиндексированные .md (для синхронизации базы данных). + Q_INVOKABLE QStringList indexedFiles() const; + Q_INVOKABLE QString displayName(const QString &nodeKey) const; signals: diff --git a/src/core/VaultSettings.cpp b/src/core/VaultSettings.cpp new file mode 100644 index 0000000..112525e --- /dev/null +++ b/src/core/VaultSettings.cpp @@ -0,0 +1,130 @@ +#include "core/VaultSettings.h" + +#include +#include +#include +#include + +namespace { + +QString cleanJournalDir(const QString &d) +{ + QString t = d.trimmed(); + t.replace(QLatin1Char('\\'), QLatin1Char('/')); + // только один сегмент, без выхода наверх и служебных имён + while (t.startsWith(QLatin1Char('/'))) + t.remove(0, 1); + while (t.endsWith(QLatin1Char('/')) && t.size() > 1) + t.chop(1); + if (t.isEmpty() || t == "." || t == ".." || t.contains(QLatin1String("..")) + || t.contains(QLatin1Char('/')) || t.startsWith(QLatin1Char('.'))) + return QStringLiteral("Журнал"); + return t; +} + +} // namespace + +VaultSettings::VaultSettings(QObject *parent) + : QObject(parent) +{ +} + +void VaultSettings::setJournalDir(const QString &d) +{ + const QString clean = cleanJournalDir(d); + if (m_journalDir == clean) + return; + m_journalDir = clean; + emit changed(); +} + +void VaultSettings::setSqlEnabled(bool on) +{ + if (m_sqlEnabled == on) + return; + m_sqlEnabled = on; + emit changed(); +} + +void VaultSettings::setSqlMaxRows(int n) +{ + if (n < 5) + n = 5; + if (n > 500) + n = 500; + if (m_sqlMaxRows == n) + return; + m_sqlMaxRows = n; + emit changed(); +} + +QString VaultSettings::settingsPath() const +{ + if (m_vault.isEmpty()) + return QString(); + return QDir(m_vault).filePath(QStringLiteral(".flora/settings.json")); +} + +bool VaultSettings::open(const QString &vaultPath) +{ + const QString clean = QDir::cleanPath(vaultPath.trimmed()); + if (clean.isEmpty() || !QDir(clean).exists()) + return false; + if (m_vault != clean) { + m_vault = clean; + emit vaultChanged(); + } + resetDefaults(); + QFile f(settingsPath()); + if (!f.exists()) + return true; // нет файла — дефолты, это нормально + if (!f.open(QIODevice::ReadOnly)) + return false; + QJsonParseError perr{}; + const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &perr); + if (perr.error != QJsonParseError::NoError || !doc.isObject()) + return false; + const QJsonObject o = doc.object(); + // Через сеттеры — с валидацией и без лишних сигналов + setJournalDir(o.value(QStringLiteral("journalDir")).toString(m_journalDir)); + if (o.contains(QStringLiteral("sqlEnabled"))) + setSqlEnabled(o.value(QStringLiteral("sqlEnabled")).toBool(m_sqlEnabled)); + if (o.contains(QStringLiteral("sqlMaxRows"))) + setSqlMaxRows(o.value(QStringLiteral("sqlMaxRows")).toInt(m_sqlMaxRows)); + return true; +} + +bool VaultSettings::save() +{ + if (m_vault.isEmpty()) + return false; + QDir().mkpath(QDir(m_vault).filePath(QStringLiteral(".flora"))); + QFile f(settingsPath()); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + QJsonObject o; + o.insert(QStringLiteral("journalDir"), m_journalDir); + o.insert(QStringLiteral("sqlEnabled"), m_sqlEnabled); + o.insert(QStringLiteral("sqlMaxRows"), m_sqlMaxRows); + f.write(QJsonDocument(o).toJson(QJsonDocument::Indented)); + return true; +} + +void VaultSettings::resetDefaults() +{ + bool touched = false; + if (m_journalDir != QStringLiteral("Журнал")) { + m_journalDir = QStringLiteral("Журнал"); + touched = true; + } + if (!m_sqlEnabled) { + m_sqlEnabled = true; + touched = true; + } + if (m_sqlMaxRows != 50) { + m_sqlMaxRows = 50; + touched = true; + } + if (touched) + emit changed(); +} diff --git a/src/core/VaultSettings.h b/src/core/VaultSettings.h new file mode 100644 index 0000000..511db30 --- /dev/null +++ b/src/core/VaultSettings.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +// Настройки одного хранилища (лежат в /.flora/settings.json, +// в git не попадают — как и сама база). Значения по умолчанию = balanced. +class VaultSettings : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString vault READ vault NOTIFY vaultChanged) + Q_PROPERTY(QString journalDir READ journalDir WRITE setJournalDir NOTIFY changed) + Q_PROPERTY(bool sqlEnabled READ sqlEnabled WRITE setSqlEnabled NOTIFY changed) + Q_PROPERTY(int sqlMaxRows READ sqlMaxRows WRITE setSqlMaxRows NOTIFY changed) + +public: + explicit VaultSettings(QObject *parent = nullptr); + + QString vault() const { return m_vault; } + QString journalDir() const { return m_journalDir; } + void setJournalDir(const QString &d); + bool sqlEnabled() const { return m_sqlEnabled; } + void setSqlEnabled(bool on); + int sqlMaxRows() const { return m_sqlMaxRows; } + void setSqlMaxRows(int n); + + // Открыть настройки хранилища (нет файла — значения по умолчанию). + Q_INVOKABLE bool open(const QString &vaultPath); + Q_INVOKABLE bool save(); + Q_INVOKABLE void resetDefaults(); + +signals: + void vaultChanged(); + void changed(); + +private: + QString settingsPath() const; + + QString m_vault; + QString m_journalDir = QStringLiteral("Журнал"); + bool m_sqlEnabled = true; + int m_sqlMaxRows = 50; +}; diff --git a/src/db/Database.cpp b/src/db/Database.cpp new file mode 100644 index 0000000..c261bb5 --- /dev/null +++ b/src/db/Database.cpp @@ -0,0 +1,632 @@ +#include "db/Database.h" + +#include "core/Frontmatter.h" +#include "core/VaultIndex.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int kSchemaVersion = 1; +constexpr qint64 kFrontmatterHeadBytes = 8192; +constexpr qint64 kPropsFileLimit = 10LL * 1024 * 1024; + +// Сканер: выкидываем комментарии/строки, оставляем «код» для проверки. +QString stripSqlNoise(const QString &sql) +{ + QString code; + code.reserve(sql.size()); + const int n = sql.size(); + bool inStr = false; + QChar strCh; + bool inLine = false, inBlock = false; + for (int i = 0; i < n; ++i) { + const QChar c = sql[i]; + if (inLine) { + if (c == QLatin1Char('\n')) + inLine = false; + continue; + } + if (inBlock) { + if (c == QLatin1Char('*') && i + 1 < n && sql[i + 1] == QLatin1Char('/')) { + inBlock = false; + ++i; + } + continue; + } + if (inStr) { + if (c == strCh) { + if (i + 1 < n && sql[i + 1] == strCh) { + ++i; // удвоенная кавычка — экранирование + continue; + } + inStr = false; + } + continue; + } + if (c == QLatin1Char('-') && i + 1 < n && sql[i + 1] == QLatin1Char('-')) { + inLine = true; + ++i; + continue; + } + if (c == QLatin1Char('/') && i + 1 < n && sql[i + 1] == QLatin1Char('*')) { + inBlock = true; + ++i; + continue; + } + if (c == QLatin1Char('\'') || c == QLatin1Char('"') || c == QLatin1Char('`')) { + inStr = true; + strCh = c; + continue; + } + code += c; + } + return code; +} + +} // namespace + +int Database::s_connSeq = 0; + +Database::Database(QObject *parent) + : QObject(parent) +{ +} + +Database::~Database() +{ + close(); +} + +void Database::setQueryEnabled(bool on) +{ + if (m_queryEnabled == on) + return; + m_queryEnabled = on; + emit queryEnabledChanged(); +} + +void Database::setMaxRows(int n) +{ + if (n < 1) + n = 1; + if (n > 2000) + n = 2000; + if (m_maxRows == n) + return; + m_maxRows = n; + emit maxRowsChanged(); +} + +void Database::setIndex(VaultIndex *index) +{ + m_index = index; +} + +bool Database::open(const QString &vaultPath) +{ + close(); + const QString clean = QDir::cleanPath(vaultPath.trimmed()); + if (clean.isEmpty() || !QDir(clean).exists()) + return false; + QDir().mkpath(QDir(clean).filePath(QStringLiteral(".flora"))); + const QString dbPath = QDir(clean).filePath(QStringLiteral(".flora/flora.db")); + + m_connName = QStringLiteral("flora_%1").arg(++s_connSeq); + m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connName); + m_db.setDatabaseName(dbPath); + m_db.setConnectOptions(QStringLiteral("QSQLITE_BUSY_TIMEOUT=5000")); + if (!m_db.open()) { + qWarning("Database: не открылась %s: %s", qPrintable(dbPath), + qPrintable(m_db.lastError().text())); + close(); + return false; + } + // PRAGMA возвращает строку — вычитываем сразу, иначе висячий стейтмент + // будет мешать SAVEPOINT («SQL statements in progress»). + { + QSqlQuery w(m_db); + w.exec(QStringLiteral("PRAGMA journal_mode=WAL")); + while (w.next()) { } + } + if (!ensureSchema()) { + close(); + return false; + } + m_vault = clean; + emit openChanged(); + // Индекс могли построить до нас (QML rebuild по vaultChanged раньше open) — + // тогда сразу забираем готовые данные, иначе ждём indexChanged (дебаунс в окне). + if (!m_index.isNull() && !m_index->indexedFiles().isEmpty()) + reindex(); + return true; +} + +void Database::close() +{ + // Аккуратно: thread-local ro-соединения потоков пула не трогаем (их мало, + // живут до конца потоков); именованные соединения уникальны на каждый open. + m_vault.clear(); + m_connName.clear(); + if (m_db.isValid()) { + const QString name = m_db.connectionName(); + m_db = QSqlDatabase(); + QSqlDatabase::removeDatabase(name); + } + emit openChanged(); +} + +bool Database::ensureSchema() +{ + static const char *kTables[] = { + "CREATE TABLE IF NOT EXISTS files (" + " id INTEGER PRIMARY KEY," + " path TEXT NOT NULL UNIQUE," + " title TEXT NOT NULL DEFAULT ''," + " mtime INTEGER NOT NULL DEFAULT 0," + " size INTEGER NOT NULL DEFAULT 0)", + "CREATE TABLE IF NOT EXISTS tags (" + " id INTEGER PRIMARY KEY," + " name TEXT NOT NULL UNIQUE)", + "CREATE TABLE IF NOT EXISTS file_tags (" + " file_id INTEGER NOT NULL," + " tag_id INTEGER NOT NULL," + " UNIQUE(file_id, tag_id))", + "CREATE TABLE IF NOT EXISTS links (" + " src_id INTEGER NOT NULL," + " dst_id INTEGER NOT NULL," + " UNIQUE(src_id, dst_id))", + "CREATE TABLE IF NOT EXISTS properties (" + " file_id INTEGER NOT NULL," + " key TEXT NOT NULL," + " value TEXT NOT NULL DEFAULT ''," + " UNIQUE(file_id, key))", + "CREATE TABLE IF NOT EXISTS meta (" + " key TEXT PRIMARY KEY," + " value TEXT NOT NULL DEFAULT '')", + nullptr + }; + QSqlQuery q(m_db); + for (int i = 0; kTables[i]; ++i) { + if (!q.exec(QLatin1String(kTables[i]))) { + qWarning("Database schema: %s", qPrintable(q.lastError().text())); + return false; + } + } + q.prepare(QStringLiteral("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)")); + q.addBindValue(kSchemaVersion); + return q.exec(); +} + +int Database::fileId(const QString &path, bool create) +{ + QSqlQuery q(m_db); + q.prepare(QStringLiteral("SELECT id FROM files WHERE path = ?")); + q.addBindValue(path); + if (q.exec() && q.next()) + return q.value(0).toInt(); + if (!create) + return -1; + const QFileInfo fi(path); + q.prepare(QStringLiteral("INSERT INTO files (path, title, mtime, size) VALUES (?, ?, ?, ?)")); + q.addBindValue(path); + q.addBindValue(fi.completeBaseName()); + q.addBindValue(fi.exists() ? fi.lastModified().toSecsSinceEpoch() : 0); + q.addBindValue(fi.exists() ? fi.size() : 0); + if (!q.exec()) + return -1; + return q.lastInsertId().toInt(); +} + +int Database::tagId(const QString &tag, bool create) +{ + QSqlQuery q(m_db); + q.prepare(QStringLiteral("SELECT id FROM tags WHERE name = ?")); + q.addBindValue(tag); + if (q.exec() && q.next()) + return q.value(0).toInt(); + if (!create) + return -1; + q.prepare(QStringLiteral("INSERT INTO tags (name) VALUES (?)")); + q.addBindValue(tag); + if (!q.exec()) + return -1; + return q.lastInsertId().toInt(); +} + +void Database::deleteFileRows(int fid) +{ + QSqlQuery q(m_db); + q.prepare(QStringLiteral("DELETE FROM links WHERE src_id = ? OR dst_id = ?")); + q.addBindValue(fid); + q.addBindValue(fid); + q.exec(); + q.prepare(QStringLiteral("DELETE FROM file_tags WHERE file_id = ?")); + q.addBindValue(fid); + q.exec(); + q.prepare(QStringLiteral("DELETE FROM properties WHERE file_id = ?")); + q.addBindValue(fid); + q.exec(); + q.prepare(QStringLiteral("DELETE FROM files WHERE id = ?")); + q.addBindValue(fid); + q.exec(); +} + +void Database::deleteOrphanTags() +{ + QSqlQuery q(m_db); + q.exec(QStringLiteral("DELETE FROM tags WHERE id NOT IN (SELECT tag_id FROM file_tags)")); +} + +void Database::indexOneFile(const QString &path) +{ + const QString clean = QDir::cleanPath(path); + const int fid = fileId(clean, true); + if (fid < 0) + return; + const QFileInfo fi(clean); + { + QSqlQuery q(m_db); + q.prepare(QStringLiteral("UPDATE files SET title = ?, mtime = ?, size = ? WHERE id = ?")); + q.addBindValue(fi.completeBaseName()); + q.addBindValue(fi.lastModified().toSecsSinceEpoch()); + q.addBindValue(fi.size()); + q.addBindValue(fid); + q.exec(); + } + // Теги и ссылки — из индекса (парсинг markdown не дублируем) + if (!m_index.isNull()) { + for (const QString &tag : m_index->tagsOfFile(clean)) { + const int tid = tagId(tag, true); + if (tid < 0) + continue; + QSqlQuery q(m_db); + q.prepare(QStringLiteral("INSERT OR IGNORE INTO file_tags (file_id, tag_id) VALUES (?, ?)")); + q.addBindValue(fid); + q.addBindValue(tid); + q.exec(); + } + for (const QVariant &lv : m_index->linksOfFile(clean)) { + const QString dst = QDir::cleanPath(lv.toMap().value("path").toString()); + if (dst.isEmpty()) + continue; + const int did = fileId(dst, true); + if (did < 0 || did == fid) + continue; + QSqlQuery q(m_db); + q.prepare(QStringLiteral("INSERT OR IGNORE INTO links (src_id, dst_id) VALUES (?, ?)")); + q.addBindValue(fid); + q.addBindValue(did); + q.exec(); + } + } + // Свойства — из frontmatter файла (шапка всегда в начале, читаем первые байты). + // Если закрывающего fence в head нет — шапка длиннее head: пропускаем, + // чтобы не индексировать оборванное (parse вернёт hasFrontmatter=false). + QFile f(clean); + if (f.open(QIODevice::ReadOnly)) { + const QString head = QString::fromUtf8(f.read(kFrontmatterHeadBytes)); + const Frontmatter::Parsed fp = Frontmatter::parse(head); + QSqlQuery q(m_db); + for (auto it = fp.props.constBegin(); it != fp.props.constEnd(); ++it) { + q.prepare(QStringLiteral("INSERT OR REPLACE INTO properties (file_id, key, value) VALUES (?, ?, ?)")); + q.addBindValue(fid); + q.addBindValue(it.key()); + q.addBindValue(it.value().toString()); + q.exec(); + } + } +} + +void Database::reindex() +{ + if (!isOpen() || m_index.isNull()) + return; + QSqlQuery q(m_db); + // Без транзакции тоже корректно (просто неатомарно) — например, если кто-то + // вызовет reindex, не дочитав свой запрос на этом соединении. + bool txn = q.exec(QStringLiteral("SAVEPOINT flora_reindex")); + if (!txn) + qWarning("Database: reindex без транзакции: %s", qPrintable(q.lastError().text())); + bool ok = true; + ok = ok && q.exec(QStringLiteral("DELETE FROM links")); + ok = ok && q.exec(QStringLiteral("DELETE FROM file_tags")); + ok = ok && q.exec(QStringLiteral("DELETE FROM properties")); + ok = ok && q.exec(QStringLiteral("DELETE FROM tags")); + ok = ok && q.exec(QStringLiteral("DELETE FROM files")); + if (ok) { + for (const QString &f : m_index->indexedFiles()) + indexOneFile(QDir::cleanPath(f)); + } + if (txn) { + if (ok) + q.exec(QStringLiteral("RELEASE flora_reindex")); + else + q.exec(QStringLiteral("ROLLBACK TO flora_reindex")); + } + emit changed(); +} + +void Database::syncFile(const QString &path) +{ + if (!isOpen() || m_index.isNull()) + return; + const QString clean = QDir::cleanPath(path.trimmed()); + if (clean.isEmpty()) + return; + if (!QFileInfo::exists(clean)) { + syncRemove(clean); + return; + } + if (!m_index->indexedFiles().contains(clean)) { + reindex(); // индекс ещё не знает файл (порядок вызовов) — проще целиком + return; + } + const int fid = fileId(clean, false); + if (fid >= 0) + deleteFileRows(fid); + else { + // строки нет (например, после ручной чистки) — создастся в indexOneFile + } + indexOneFile(clean); + deleteOrphanTags(); + emit changed(); +} + +void Database::syncRemove(const QString &path) +{ + if (!isOpen()) + return; + const QString clean = QDir::cleanPath(path.trimmed()); + if (clean.isEmpty()) + return; + const int fid = fileId(clean, false); + if (fid < 0) + return; + deleteFileRows(fid); + deleteOrphanTags(); + emit changed(); +} + +bool Database::isReadOnlySql(const QString &sql, QString *error) +{ + const QString code = stripSqlNoise(sql); + static const QRegularExpression firstWord(QStringLiteral("^\\s*(\\w+)")); + const auto m = firstWord.match(code); + if (!m.hasMatch()) { + if (error) + *error = QStringLiteral("Пустой запрос"); + return false; + } + const QString first = m.captured(1).toLower(); + if (first != QLatin1String("select") && first != QLatin1String("with") + && first != QLatin1String("values")) { + if (error) + *error = QStringLiteral("Разрешены только читающие запросы (SELECT / WITH). " + "Изменение базы из заметок запрещено."); + return false; + } + static const QRegularExpression banned( + QStringLiteral("\\b(insert|update|delete|drop|alter|create|attach|detach|replace|" + "vacuum|pragma|reindex|savepoint|transaction|analyze|copy)\\b"), + QRegularExpression::CaseInsensitiveOption); + if (banned.match(code).hasMatch()) { + if (error) + *error = QStringLiteral("Изменяющие запросы запрещены — только чтение."); + return false; + } + QString body = code.trimmed(); + if (body.endsWith(QLatin1Char(';'))) + body.chop(1); + if (body.contains(QLatin1Char(';'))) { + if (error) + *error = QStringLiteral("Один блок — один запрос (без «;» внутри)."); + return false; + } + return true; +} + +QSqlDatabase Database::threadRoDb() const +{ + // Соединения QSqlDatabase привязаны к потоку: у каждого потока своё + // read-only соединение (diff считается в пуле потоков). + thread_local QSqlDatabase ro; + thread_local QString roPath; + if (ro.isValid() && ro.isOpen() && roPath == m_vault && !m_vault.isEmpty()) + return ro; + // Новое хранилище/поток — своё соединение с уникальным именем + thread_local int seq = 0; + const QString name = QStringLiteral("flora_ro_%1_%2_%3") + .arg(QString::number(qHash(m_vault), 16)) + .arg((quintptr)QThread::currentThreadId()) + .arg(++seq); + ro = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); + roPath.clear(); + if (m_vault.isEmpty()) + return ro; + // Read-only силами самого SQLite (mode=ro в URI), а не опцией драйвера — + // работает во всех сборках Qt. + const QString dbPath = QDir(m_vault).filePath(QStringLiteral(".flora/flora.db")); + ro.setDatabaseName(QStringLiteral("file:%1?mode=ro").arg(dbPath)); + ro.setConnectOptions(QStringLiteral("QSQLITE_OPEN_URI;QSQLITE_BUSY_TIMEOUT=5000")); + if (!ro.open()) + return ro; + roPath = m_vault; + // Пояс поверх mode=ro: запрещаем запись на этом соединении вообще. + { + QSqlQuery q(ro); + q.exec(QStringLiteral("PRAGMA query_only = ON")); + while (q.next()) { } + } + // Зонд: пробная запись обязана упасть. (BEGIN IMMEDIATE для зонда не годится — + // на read-only соединении он может успешно проходить.) + { + QSqlQuery probe(ro); + if (probe.exec(QStringLiteral("CREATE TABLE __flora_ro_probe (x)"))) { + probe.exec(QStringLiteral("DROP TABLE __flora_ro_probe")); + qWarning("Database: read-only соединение пишет! Пишущие запросы всё равно режутся проверкой текста."); + } + } + return ro; +} + +QVariantMap Database::queryTable(const QString &sql) +{ + QVariantMap out; + out.insert("columns", QStringList()); + out.insert("rows", QVariantList()); + out.insert("error", QString()); + out.insert("truncated", false); + if (!m_queryEnabled) { + out["error"] = tr("SQL-блоки выключены в настройках хранилища."); + return out; + } + if (!isOpen()) { + out["error"] = tr("База данных закрыта."); + return out; + } + QString guardErr; + if (!isReadOnlySql(sql, &guardErr)) { + out["error"] = guardErr; + return out; + } + QSqlDatabase ro = threadRoDb(); + if (!ro.isOpen()) { + out["error"] = tr("Не удалось открыть базу для чтения: %1").arg(ro.lastError().text()); + return out; + } + QSqlQuery q(ro); + q.setForwardOnly(true); + if (!q.exec(sql)) { + out["error"] = tr("Ошибка запроса: %1").arg(q.lastError().text()); + return out; + } + const QSqlRecord rec = q.record(); + if (rec.count() == 0) { + out["error"] = tr("Запрос ничего не вернул."); + return out; + } + const int ncols = qMin(rec.count(), 64); + QStringList cols; + for (int c = 0; c < ncols; ++c) { + const QString n = rec.fieldName(c); + cols.append(n.isEmpty() ? QStringLiteral("col%1").arg(c + 1) : n); + } + QVariantList rows; + int n = 0; + bool truncated = false; + while (q.next()) { + if (++n > m_maxRows) { + truncated = true; + break; + } + QVariantList row; + for (int c = 0; c < ncols; ++c) + row.append(q.isNull(c) ? QStringLiteral("NULL") : q.value(c).toString()); + // QVariant(...) обязателен: иначе вызывается QList::append(const QList&) + // и строка сплющивается в плоский список ячеек. + rows.append(QVariant(row)); + } + out["columns"] = cols; + out["rows"] = rows; + out["truncated"] = truncated; + return out; +} + +QVariantMap Database::stats() +{ + QVariantMap out; + auto count = [&](const char *table) { + QSqlQuery q(m_db); + if (q.exec(QString::fromLatin1("SELECT COUNT(*) FROM ") + QLatin1String(table)) && q.next()) + return q.value(0).toInt(); + return 0; + }; + if (isOpen()) { + out.insert("files", count("files")); + out.insert("tags", count("tags")); + out.insert("links", count("links")); + out.insert("properties", count("properties")); + out.insert("dbSize", QFileInfo(QDir(m_vault).filePath(QStringLiteral(".flora/flora.db"))).size()); + } else { + out.insert("files", 0); + out.insert("tags", 0); + out.insert("links", 0); + out.insert("properties", 0); + out.insert("dbSize", 0); + } + return out; +} + +QVariantMap Database::fileProperties(const QString &path) +{ + QVariantMap out; + if (!isOpen()) + return out; + const int fid = fileId(QDir::cleanPath(path), false); + if (fid < 0) + return out; + QSqlQuery q(m_db); + q.prepare(QStringLiteral("SELECT key, value FROM properties WHERE file_id = ? ORDER BY key")); + q.addBindValue(fid); + if (q.exec()) { + while (q.next()) + out.insert(q.value(0).toString(), q.value(1).toString()); + } + return out; +} + +QString Database::saveFileProperties(const QString &path, const QVariantMap &props) +{ + const QString clean = QDir::cleanPath(path.trimmed()); + if (clean.isEmpty()) + return tr("Пустой путь."); + QFileInfo fi(clean); + if (!fi.exists() || !fi.isFile()) + return tr("Файл не найден."); + if (fi.size() > kPropsFileLimit) + return tr("Файл слишком большой для свойств."); + // Проверяем ключи до записи — мусор в шапку не пишем + for (auto it = props.constBegin(); it != props.constEnd(); ++it) { + if (!Frontmatter::isKeyValid(it.key())) + return tr("Плохое имя свойства: %1 (буквы, цифры, _, -)").arg(it.key()); + } + QFile f(clean); + if (!f.open(QIODevice::ReadOnly)) + return tr("Не удалось прочитать файл."); + const QString text = QString::fromUtf8(f.readAll()); + f.close(); + const Frontmatter::Parsed fp = Frontmatter::parse(text); + const QString newText = Frontmatter::serialize(props, fp.raw, fp.body); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return tr("Не удалось записать файл."); + f.write(newText.toUtf8()); + return QString(); // ок — индекс и базу обновит вызывающий (updateFile/syncFile) +} + +QString Database::schemaHelp() +{ + return QStringLiteral( + "Таблицы базы хранилища (только чтение, SELECT / WITH):\n" + "• files(path, title, mtime, size) — заметки\n" + "• tags(name) — теги; file_tags(file_id, tag_id) — чьи\n" + "• links(src_id, dst_id) — id файлов из files\n" + "• properties(file_id, key, value) — свойства из шапки --- ... ---\n" + "\n" + "Примеры:\n" + "SELECT title, path FROM files ORDER BY title;\n" + "SELECT t.name, COUNT(*) AS n FROM tags t\n" + " JOIN file_tags ft ON ft.tag_id = t.id GROUP BY t.name;\n" + "SELECT p.key, p.value FROM properties p\n" + " JOIN files f ON f.id = p.file_id WHERE f.path LIKE '%inbox%';"); +} diff --git a/src/db/Database.h b/src/db/Database.h new file mode 100644 index 0000000..ebdc0f7 --- /dev/null +++ b/src/db/Database.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class VaultIndex; + +// Notion-подобная база хранилища (.flora/flora.db, SQLite): +// файлы, теги, связи, свойства из frontmatter. Источник правды для +// свойств — frontmatter в самих .md (переносимо, видно в git); +// база — быстрый индекс + движок для ~sql-блоков. +class Database : public QObject +{ + Q_OBJECT + + Q_PROPERTY(bool isOpen READ isOpen NOTIFY openChanged) + Q_PROPERTY(QString vault READ vault NOTIFY openChanged) + Q_PROPERTY(bool queryEnabled READ queryEnabled WRITE setQueryEnabled NOTIFY queryEnabledChanged) + Q_PROPERTY(int maxRows READ maxRows WRITE setMaxRows NOTIFY maxRowsChanged) + +public: + explicit Database(QObject *parent = nullptr); + ~Database() override; + + bool isOpen() const { return m_db.isValid() && m_db.isOpen(); } + QString vault() const { return m_vault; } + bool queryEnabled() const { return m_queryEnabled; } + void setQueryEnabled(bool on); + int maxRows() const { return m_maxRows; } + void setMaxRows(int n); + + // Индекс для подтягивания тегов/ссылок (парсинг markdown не дублируем). + void setIndex(VaultIndex *index); + + Q_INVOKABLE bool open(const QString &vaultPath); + Q_INVOKABLE void close(); + + // Полная переиндексация из VaultIndex. Вызывать после rebuild индекса. + Q_INVOKABLE void reindex(); + // Точечно: файл создан/сохранён/переименован-в (нет файла — как удаление). + Q_INVOKABLE void syncFile(const QString &path); + // Файл удалён/переименован-из. + Q_INVOKABLE void syncRemove(const QString &path); + + // Только SELECT/WITH, на read-only соединении, с лимитом строк. + // Возвращает {columns:[...], rows:[[cell...]], error:"", truncated:bool}. + // Ячейки — строки, NULL как "NULL". + Q_INVOKABLE QVariantMap queryTable(const QString &sql); + + Q_INVOKABLE QVariantMap stats(); + Q_INVOKABLE QVariantMap fileProperties(const QString &path); + Q_INVOKABLE QString saveFileProperties(const QString &path, const QVariantMap &props); + + // Схема для подсказок в UI (какие таблицы/колонки можно спрашивать). + static QString schemaHelp(); + +signals: + void openChanged(); + void queryEnabledChanged(); + void maxRowsChanged(); + void changed(); // данные изменились (для обновления статистики и т.п.) + +private: + bool ensureSchema(); + int fileId(const QString &path, bool create); + int tagId(const QString &tag, bool create); + void deleteFileRows(int fileId); + void deleteOrphanTags(); + void indexOneFile(const QString &path); + static bool isReadOnlySql(const QString &sql, QString *error); + QSqlDatabase threadRoDb() const; // read-only соединение текущего потока + + QString m_vault; + QSqlDatabase m_db; + QPointer m_index; + bool m_queryEnabled = true; + int m_maxRows = 50; + QString m_connName; + static int s_connSeq; +}; diff --git a/src/main.cpp b/src/main.cpp index fbf25bb..fb194c4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -67,7 +67,7 @@ int main(int argc, char *argv[]) engine.rootContext()->setContextProperty("vaultIndex", &vaultIndex); WindowController windowController( - &engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml")); + &engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml"), &mdParser); engine.rootContext()->setContextProperty("windowController", &windowController); // При открытии любого окна — запоминаем его как последнее выбранное (для следующего запуска) diff --git a/src/markdown/MdParser.cpp b/src/markdown/MdParser.cpp index faf424d..8a48916 100644 --- a/src/markdown/MdParser.cpp +++ b/src/markdown/MdParser.cpp @@ -1,5 +1,8 @@ #include "markdown/MdParser.h" +#include "db/Database.h" +#include "core/Frontmatter.h" + #include #include #include @@ -219,6 +222,79 @@ QString tintDiffHtml(const QString &inner, bool isDel) return QStringLiteral("
%1
").arg(inner); } +bool isSqlFence(const QString &lang) +{ + return lang.trimmed().compare(QLatin1String("~sql"), Qt::CaseInsensitive) == 0; +} + +// ~sql-блок → HTML-таблица с результатом (стиль как у GFM-таблиц, с учётом темы). +// codeId нужен для кнопки «скопировать запрос». Пусто = fallback на обычный код. +QString renderSqlBlock(Database *db, const QString &sql, const QString &codeId) +{ + if (!db || !db->isOpen() || !db->queryEnabled()) + return QString(); + const QVariantMap res = db->queryTable(sql); + const QString err = res.value("error").toString(); + 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"; + const QString faint = isDark ? "#8b949e" : "#656d76"; + if (!err.isEmpty()) { + return QStringLiteral( + "
" + "
SQL: %1
" + "
Таблицы: files(path,title,mtime,size), tags(name), file_tags, links, properties. Только SELECT / WITH.
" + "
") + .arg(esc(err), faint); + } + const QStringList cols = res.value("columns").toStringList(); + const QVariantList rows = res.value("rows").toList(); + const bool truncated = res.value("truncated").toBool(); + QString h = QStringLiteral("
").arg(bg, border); + h += QStringLiteral(""); + h += QStringLiteral("").arg(headerBg); + const int colCount = cols.size(); + for (int c = 0; c < colCount; ++c) { + QString cs = QStringLiteral("padding:8px 12px;text-align:left;color:%1;font-weight:600").arg(headerText); + if (c < colCount - 1) + cs += QStringLiteral(";border-right:1px solid %1").arg(border); + h += QStringLiteral("").arg(cs, esc(cols[c])); + } + h += QStringLiteral(""); + int r = 0; + for (const QVariant &rv : rows) { + const QVariantList row = rv.toList(); + h += QStringLiteral("").arg(bg); + for (int c = 0; c < colCount; ++c) { + const QString cell = c < row.size() ? row[c].toString() : QString(); + QString cs = QStringLiteral("padding:8px 12px;text-align:left;color:%1").arg(cellText); + cs += QStringLiteral(";border-top:1px solid %1").arg(border); + if (c < colCount - 1) + cs += QStringLiteral(";border-right:1px solid %1").arg(border); + const QString body = (cell == QLatin1String("NULL")) + ? QStringLiteral("NULL").arg(faint) + : esc(cell); + h += QStringLiteral("").arg(cs, body); + } + h += QStringLiteral(""); + ++r; + } + h += QStringLiteral("
%2
%2
"); + QString foot = rows.size() == 1 ? QStringLiteral("1 строка") : QStringLiteral("%1 строк").arg(rows.size()); + if (truncated) + foot += QStringLiteral(" (показаны первые)"); + if (!codeId.isEmpty()) { + foot += QStringLiteral(" • 📋 SQL") + .arg(codeId, faint); + } + h += QStringLiteral("
%2
").arg(faint, foot); + return h; +} + QString fileHrefFor(const QString &path) { const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path)); @@ -419,6 +495,8 @@ struct MdParser::Impl Repository *m_repo = nullptr; QHash codeBlocks; int codeCounter = 0; + // Базы открытых хранилищ (для ~sql). QPointer — окно могли закрыть. + QHash> databases; }; MdParser::MdParser(QObject *parent) @@ -455,6 +533,30 @@ bool MdParser::copyCodeBlock(const QString &id) const return true; } +void MdParser::setDatabaseForVault(const QString &vaultRoot, Database *db) +{ + if (!m || vaultRoot.trimmed().isEmpty() || !db) + return; + m->databases.insert(QDir::cleanPath(vaultRoot), db); +} + +void MdParser::removeDatabaseForVault(const QString &vaultRoot, Database *db) +{ + if (!m) + return; + const QString key = QDir::cleanPath(vaultRoot); + auto it = m->databases.find(key); + if (it != m->databases.end() && it.value() == db) + m->databases.erase(it); +} + +Database *MdParser::databaseFor(const QString &vaultRoot) const +{ + if (!m || vaultRoot.trimmed().isEmpty()) + return nullptr; + return m->databases.value(QDir::cleanPath(vaultRoot), nullptr); +} + namespace { class Renderer @@ -619,7 +721,8 @@ private: QString code_block(cmark_node *n) { const char *info = cmark_node_get_fence_info(n); - const QString lang = normalizeLang(QString::fromUtf8(info ? info : "")); + const QString rawLang = QString::fromUtf8(info ? info : ""); + const QString lang = normalizeLang(rawLang); const QString code = QString::fromUtf8(cmark_node_get_literal(n)); // Сохраняем оригинал для копирования QString codeId; @@ -627,6 +730,15 @@ private: codeId = QStringLiteral("code_%1").arg(m_impl->codeCounter++); m_impl->codeBlocks.insert(codeId, code); } + // ~sql: результат запроса таблицей; без базы — исходник как код + if (isSqlFence(rawLang)) { + Database *db = m_impl + ? m_impl->databases.value(QDir::cleanPath(m_vaultRoot), nullptr) + : nullptr; + const QString table = renderSqlBlock(db, code, codeId); + if (!table.isEmpty()) + return table; + } // Подсветка — тёмная тема для контраста на синем фоне (как в Telegram) CodeHighlighter hl(m_repo); const QString highlighted = hl.highlight(code, lang, true); @@ -863,7 +975,10 @@ QString MdParser::render(const QString &markdown, const QString &vaultRoot, m->codeBlocks.clear(); m->codeCounter = 0; } - return renderInner(markdown, vaultRoot, fileDir); + // Шапка свойств — метаданные, не контент: в просмотре её не показываем + // (в истории версий текст идёт как есть, там не режем). + const Frontmatter::Parsed fp = Frontmatter::parse(markdown); + return renderInner(fp.hasFrontmatter ? fp.body : markdown, vaultRoot, fileDir); } // Файлово-статический рендер с явным Impl: безопасен для рабочего потока @@ -901,6 +1016,14 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault QVariantList blocks; if (markdown.isEmpty()) return blocks; + // Шапка свойств — метаданные: в блочном просмотре её нет (как в render). + QString src = markdown; + { + const Frontmatter::Parsed fp = Frontmatter::parse(src); + if (fp.hasFrontmatter) + src = fp.body; + } + // Сбрасываем счётчик для нового парса if (m) { m->codeBlocks.clear(); @@ -921,7 +1044,7 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault }; bool any = false; - for (const QString &part : splitMdBlocks(markdown)) { + for (const QString &part : splitMdBlocks(src)) { QString lang, code; if (isFenceBlock(part) && parseFenceBlock(part, &lang, &code)) { QString codeId; @@ -929,6 +1052,23 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault codeId = QStringLiteral("code_%1").arg(m->codeCounter++); m->codeBlocks.insert(codeId, code); } + // ~sql: запрос к базе хранилища, результат — таблицей (как в Просмотре) + if (isSqlFence(lang)) { + const QString table = renderSqlBlock(databaseFor(vaultRoot), code, codeId); + if (!table.isEmpty()) { + QVariantMap block; + block["type"] = "html"; + block["html"] = table; + block["codeText"] = ""; + block["language"] = ""; + block["codeId"] = ""; + block["highlightedText"] = ""; + blocks.append(block); + any = true; + continue; + } + // без базы/выключено — показываем исходник запроса как код + } CodeHighlighter hl(m ? m->m_repo : nullptr); QString highlighted; if (m && m->m_repo) { @@ -954,8 +1094,8 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault } } // Если вообще не нашли блоки, но markdown не пустой — вернём как html - if (!any && !markdown.trimmed().isEmpty()) - appendHtml(renderInner(markdown, vaultRoot, fileDir)); + if (!any && !src.trimmed().isEmpty()) + appendHtml(renderInner(src, vaultRoot, fileDir)); return blocks; } diff --git a/src/markdown/MdParser.h b/src/markdown/MdParser.h index 11ebb19..d7d6795 100644 --- a/src/markdown/MdParser.h +++ b/src/markdown/MdParser.h @@ -2,9 +2,12 @@ #include #include +#include #include #include +class Database; + // Рендер Markdown (CommonMark + GFM) в HTML для режима просмотра. // Задачи/ссылки/переходы кодируются ссылками schemes: // flora://task/N — переключить задачу на строке N (1-based) @@ -50,6 +53,14 @@ public: // Скопировать кодовый блок по id (get + copy одним вызовом). false — нечего копировать. Q_INVOKABLE bool copyCodeBlock(const QString &id) const; + // База хранилища для ~sql-блоков (по одному на хранилище; окна не мешают друг другу). + // Регистрирует WindowController; из QML — только fallback для пути визарда. + Q_INVOKABLE void setDatabaseForVault(const QString &vaultRoot, Database *db); + Q_INVOKABLE void removeDatabaseForVault(const QString &vaultRoot, Database *db); + +private: + Database *databaseFor(const QString &vaultRoot) const; + signals: void blocksReady(int requestId, const QVariantList &blocks); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d28e8a4..eebc82f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -64,6 +64,12 @@ add_test(NAME flora_docmgr_test COMMAND flora_docmgr_test) add_executable(flora_mdparser_test mdparser_test.cpp ../src/markdown/MdParser.cpp ../src/markdown/MdParser.h + ../src/db/Database.cpp + ../src/db/Database.h + ../src/core/Frontmatter.cpp + ../src/core/Frontmatter.h + ../src/core/VaultIndex.cpp + ../src/core/VaultIndex.h ) target_include_directories(flora_mdparser_test PRIVATE ../src) target_compile_definitions(flora_mdparser_test PRIVATE @@ -72,6 +78,7 @@ target_compile_definitions(flora_mdparser_test PRIVATE target_link_libraries(flora_mdparser_test PRIVATE Qt6::Core Qt6::Gui + Qt6::Sql Qt6::Concurrent cmark::cmark cmark::cmark_extensions @@ -117,6 +124,10 @@ add_executable(flora_extended_test extended_test.cpp ../src/core/DocumentManager.h ../src/core/VaultIndex.cpp ../src/core/VaultIndex.h + ../src/core/Frontmatter.cpp + ../src/core/Frontmatter.h + ../src/db/Database.cpp + ../src/db/Database.h ../src/markdown/MdParser.cpp ../src/markdown/MdParser.h ../src/git/GitManager.cpp @@ -124,7 +135,7 @@ add_executable(flora_extended_test extended_test.cpp ) target_include_directories(flora_extended_test PRIVATE ../src) target_compile_definitions(flora_extended_test PRIVATE CMARK_GFM_STATIC_DEFINE CMARK_GFM_EXTENSIONS_STATIC_DEFINE) -target_link_libraries(flora_extended_test PRIVATE Qt6::Core Qt6::Gui Qt6::Concurrent cmark::cmark cmark::cmark_extensions Flora_libgit2_static) +target_link_libraries(flora_extended_test PRIVATE Qt6::Core Qt6::Gui Qt6::Sql Qt6::Concurrent cmark::cmark cmark::cmark_extensions Flora_libgit2_static) if(TARGET KF6SyntaxHighlighting) target_link_libraries(flora_extended_test PRIVATE KF6SyntaxHighlighting) endif() @@ -145,6 +156,10 @@ add_executable(flora_integration_test integration_test.cpp ../src/core/DocumentManager.h ../src/core/VaultIndex.cpp ../src/core/VaultIndex.h + ../src/core/Frontmatter.cpp + ../src/core/Frontmatter.h + ../src/db/Database.cpp + ../src/db/Database.h ../src/markdown/MdParser.cpp ../src/markdown/MdParser.h ../src/git/GitManager.cpp @@ -152,7 +167,7 @@ add_executable(flora_integration_test integration_test.cpp ) target_include_directories(flora_integration_test PRIVATE ../src) target_compile_definitions(flora_integration_test PRIVATE CMARK_GFM_STATIC_DEFINE CMARK_GFM_EXTENSIONS_STATIC_DEFINE) -target_link_libraries(flora_integration_test PRIVATE Qt6::Core Qt6::Gui Qt6::Concurrent cmark::cmark cmark::cmark_extensions Flora_libgit2_static) +target_link_libraries(flora_integration_test PRIVATE Qt6::Core Qt6::Gui Qt6::Sql Qt6::Concurrent cmark::cmark cmark::cmark_extensions Flora_libgit2_static) if(TARGET KF6SyntaxHighlighting) target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting) endif() @@ -169,6 +184,27 @@ target_include_directories(flora_git_history_test PRIVATE ../src) target_link_libraries(flora_git_history_test PRIVATE Qt6::Core Flora_libgit2_static) add_test(NAME flora_git_history_test COMMAND flora_git_history_test) +# Тест SQLite-слоя (Frontmatter, VaultSettings, Database, ~sql-блоки) +add_executable(flora_db_test db_test.cpp + ../src/core/Frontmatter.cpp + ../src/core/Frontmatter.h + ../src/core/VaultSettings.cpp + ../src/core/VaultSettings.h + ../src/core/VaultIndex.cpp + ../src/core/VaultIndex.h + ../src/db/Database.cpp + ../src/db/Database.h + ../src/markdown/MdParser.cpp + ../src/markdown/MdParser.h +) +target_include_directories(flora_db_test PRIVATE ../src) +target_compile_definitions(flora_db_test PRIVATE CMARK_GFM_STATIC_DEFINE CMARK_GFM_EXTENSIONS_STATIC_DEFINE) +target_link_libraries(flora_db_test PRIVATE Qt6::Core Qt6::Gui Qt6::Sql Qt6::Concurrent cmark::cmark cmark::cmark_extensions) +if(TARGET KF6SyntaxHighlighting) + target_link_libraries(flora_db_test PRIVATE KF6SyntaxHighlighting) +endif() +add_test(NAME flora_db_test COMMAND flora_db_test) + # Тест выбора хранилища (последнее выбранное, а не добавленное) add_executable(flora_vault_select_test vault_select_test.cpp ../src/app/VaultManager.cpp diff --git a/tests/db_test.cpp b/tests/db_test.cpp new file mode 100644 index 0000000..5325af5 --- /dev/null +++ b/tests/db_test.cpp @@ -0,0 +1,240 @@ +// Тест SQLite-слоя: Frontmatter, VaultSettings, Database (индекс, запросы, +// свойства) и ~sql-блоки в MdParser (таблица / fallback / ошибка). +#include +#include +#include +#include +#include + +#include "core/Frontmatter.h" +#include "core/VaultSettings.h" +#include "core/VaultIndex.h" +#include "db/Database.h" +#include "markdown/MdParser.h" + +static int fail(const char *msg, const QString &detail = QString()) +{ + std::fprintf(stderr, "FAIL: %s | %s\n", msg, qPrintable(detail)); + return 1; +} + +static void writeFile(const QString &path, const QString &text) +{ + QFile f(path); + f.open(QIODevice::WriteOnly | QIODevice::Truncate); + f.write(text.toUtf8()); +} + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + QCoreApplication::setApplicationName("FloraTest"); + QCoreApplication::setOrganizationName("FloraTest"); + + // --- Frontmatter: парс --- + { + Frontmatter::Parsed fp = Frontmatter::parse( + "---\nстатус: в работе\nприоритет: 1\n# коммент\nмусор без двоеточия\n---\n\nТело\n"); + if (!fp.hasFrontmatter) + return fail("fm has"); + if (fp.props.value("статус").toString() != "в работе") + return fail("fm статус", fp.props.value("статус").toString()); + if (fp.props.value("приоритет").toString() != "1") + return fail("fm приоритет"); + if (fp.raw.size() != 2) + return fail("fm raw", QString::number(fp.raw.size())); + if (!fp.body.contains("Тело")) + return fail("fm body", fp.body); + } + // --- Frontmatter: нет шапки / незакрытая (body при этом целый!) --- + { + Frontmatter::Parsed a = Frontmatter::parse("Просто текст\n"); + if (a.hasFrontmatter || !a.props.isEmpty()) + return fail("fm no fence"); + if (a.body != "Просто текст\n") + return fail("fm body kept", a.body); + Frontmatter::Parsed b = Frontmatter::parse("---\nключ: 1\nбез закрытия\n"); + if (b.hasFrontmatter) + return fail("fm unclosed = текст"); + } + // --- Frontmatter: roundtrip --- + { + QVariantMap props; + props.insert("b", "2"); + props.insert("a", "1"); + const QString s = Frontmatter::serialize(props, QStringList() << "# raw", "Тело"); + Frontmatter::Parsed fp = Frontmatter::parse(s); + if (fp.props.value("a").toString() != "1" || fp.props.value("b").toString() != "2") + return fail("fm roundtrip props", s); + if (fp.raw.size() != 1 || fp.body.trimmed() != "Тело") + return fail("fm roundtrip raw/body", s); + } + + // --- VaultSettings: дефолты + persist --- + QTemporaryDir dir; + if (!dir.isValid()) + return fail("temp dir"); + const QString vault = dir.path(); + { + VaultSettings vs; + if (!vs.open(vault)) + return fail("vs open"); + if (vs.journalDir() != "Журнал" || !vs.sqlEnabled() || vs.sqlMaxRows() != 50) + return fail("vs defaults"); + vs.setJournalDir("../../etc"); // мусор → дефолт + if (vs.journalDir() != "Журнал") + return fail("vs sanitize", vs.journalDir()); + vs.setJournalDir("Дневник"); + vs.setSqlMaxRows(20); + if (!vs.save()) + return fail("vs save"); + VaultSettings vs2; + vs2.open(vault); + if (vs2.journalDir() != "Дневник" || vs2.sqlMaxRows() != 20) + return fail("vs roundtrip"); + } + + // --- Хранилище: два файла со свойствами, тегами и ссылкой --- + writeFile(vault + "/a.md", + "---\nстатус: в работе\n---\n\nТекст #тег1 [[b]]\n"); + writeFile(vault + "/b.md", "# Бэ\n\nТекст #тег1 #тег2\n"); + VaultIndex index; + index.rebuild(vault); + if (index.fileCount() != 2) + return fail("index count", QString::number(index.fileCount())); + + Database db; + db.setIndex(&index); + if (!db.open(vault)) + return fail("db open"); + // open сам подхватил готовый индекс + QVariantMap st = db.stats(); + if (st.value("files").toInt() != 2) + return fail("db files", QString::number(st.value("files").toInt())); + if (st.value("tags").toInt() != 2) + return fail("db tags", QString::number(st.value("tags").toInt())); + if (st.value("links").toInt() != 1) + return fail("db links", QString::number(st.value("links").toInt())); + if (st.value("properties").toInt() != 1) + return fail("db props", QString::number(st.value("properties").toInt())); + + // --- SELECT работает (включая многоколоночные: строки не сплющиваются) --- + { + QVariantMap r = db.queryTable("SELECT title FROM files ORDER BY title"); + if (!r.value("error").toString().isEmpty()) + return fail("select err", r.value("error").toString()); + if (r.value("columns").toStringList() != QStringList() << "title") + return fail("select cols", r.value("columns").toStringList().join(",")); + const QVariantList rows = r.value("rows").toList(); + if (rows.size() != 2) + return fail("select rows"); + if (rows[0].toList().size() != 1 || rows[0].toList()[0].toString() != "a") + return fail("select cell"); + } + { + QVariantMap r = db.queryTable("SELECT path, title FROM files ORDER BY title"); + const QVariantList rows = r.value("rows").toList(); + if (rows.size() != 2 || rows[0].toList().size() != 2) + return fail("select shape"); + if (!rows[0].toList()[0].toString().endsWith("/a.md") + || rows[0].toList()[1].toString() != "a") + return fail("select cells", rows[0].toList()[0].toString()); + } + // --- Пишущие запросы режутся --- + { + QVariantMap r = db.queryTable("INSERT INTO tags (name) VALUES ('x')"); + if (r.value("error").toString().isEmpty()) + return fail("insert не зарезан"); + QVariantMap r2 = db.queryTable("SELECT * FROM tags; DELETE FROM tags"); + if (r2.value("error").toString().isEmpty()) + return fail("multi не зарезан"); + // SELECT с запрещённым словом внутри строки — можно (фильтр по коду, не по строке) + QVariantMap r3 = db.queryTable("SELECT 'update' AS u"); + if (!r3.value("error").toString().isEmpty()) + return fail("строка update зря зарезана", r3.value("error").toString()); + } + // --- Свойства: чтение + запись --- + { + QVariantMap p = db.fileProperties(vault + "/a.md"); + if (p.value("статус").toString() != "в работе") + return fail("props read"); + QVariantMap np; + np.insert("статус", "готово"); + np.insert("оценка", "5"); + const QString err = db.saveFileProperties(vault + "/a.md", np); + if (!err.isEmpty()) + return fail("props save", err); + index.updateFile(vault + "/a.md"); + db.syncFile(vault + "/a.md"); + QVariantMap p2 = db.fileProperties(vault + "/a.md"); + if (p2.value("статус").toString() != "готово" || p2.value("оценка").toString() != "5") + return fail("props roundtrip"); + // тело и ссылка не пострадали + QFile f(vault + "/a.md"); + f.open(QIODevice::ReadOnly); + const QString t = QString::fromUtf8(f.readAll()); + if (!t.contains("[[b]]") || !t.contains("#тег1")) + return fail("props body", t); + // плохое имя свойства + QVariantMap bad; + bad.insert("плохо имя!", "x"); + if (db.saveFileProperties(vault + "/a.md", bad).isEmpty()) + return fail("props bad key"); + } + + // --- MdParser: ~sql с базой → таблица --- + { + MdParser p; + p.setDatabaseForVault(vault, &db); + const QVariantList blocks = p.parseBlocks( + "Текст\n\n```~sql\nSELECT title FROM files ORDER BY title\n```\n", vault, vault); + if (blocks.size() != 2) + return fail("sql blocks", QString::number(blocks.size())); + if (blocks[1].toMap().value("type").toString() != "html") + return fail("sql type", blocks[1].toMap().value("type").toString()); + const QString html = blocks[1].toMap().value("html").toString(); + // title = имя файла без расширения + if (!html.contains("a") || !html.contains(">b")) + return fail("sql table", html.left(300)); + // plain render тоже таблицей + const QString h = p.render("```~sql\nSELECT title FROM files\n```\n", vault, vault); + if (!h.contains("