SQLite-база хранилища: свойства, ~sql-блоки, настройки хранилища

- 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 зелёных)
This commit is contained in:
2026-09-07 16:30:06 +03:00
parent 0a46471cc6
commit fdf59f5b39
26 changed files with 1972 additions and 17 deletions
+8
View File
@@ -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 как синглтон
+43
View File
@@ -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
+181
View File
@@ -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
}
}
}
+163
View File
@@ -0,0 +1,163 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Flora
// Настройки одного хранилища: лежат в <vault>/.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
}
}
}
+10
View File
@@ -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) {
+1 -1
View File
@@ -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 // можно подсветить выбранный день
+2 -3
View File
@@ -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() } }
// Правка активного файла: обновить только его (один парс), а не всё хранилище
+52 -3
View File
@@ -5,17 +5,23 @@
#include <QQmlComponent>
#include <QQmlContext>
#include <QQuickWindow>
#include <QTimer>
#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())
+7 -1
View File
@@ -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<Window> m_windows;
QQmlComponent *m_component = nullptr;
};
+18
View File
@@ -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())
+4
View File
@@ -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);
+19 -1
View File
@@ -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<Row> 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))
+6
View File
@@ -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<Row> m_rows;
QSet<QString> m_expanded;
QFileSystemWatcher m_watcher;
+93
View File
@@ -0,0 +1,93 @@
#include "core/Frontmatter.h"
#include <QRegularExpression>
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;
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <QString>
#include <QStringList>
#include <QVariantMap>
// 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);
};
+5
View File
@@ -519,3 +519,8 @@ QStringList VaultIndex::filesWithTag(const QString &tag) const
{
return m_filesWithTag.value(tag);
}
QStringList VaultIndex::indexedFiles() const
{
return m_fileMeta.keys();
}
+3
View File
@@ -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:
+130
View File
@@ -0,0 +1,130 @@
#include "core/VaultSettings.h"
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
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();
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <QObject>
#include <QString>
// Настройки одного хранилища (лежат в <vault>/.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;
};
+632
View File
@@ -0,0 +1,632 @@
#include "db/Database.h"
#include "core/Frontmatter.h"
#include "core/VaultIndex.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#include <QSqlError>
#include <QSqlQuery>
#include <QSqlRecord>
#include <QThread>
#include <QVariant>
#include <QtDebug>
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%';");
}
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <QVariantList>
#include <QVariantMap>
#include <QSqlDatabase>
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<VaultIndex> m_index;
bool m_queryEnabled = true;
int m_maxRows = 50;
QString m_connName;
static int s_connSeq;
};
+1 -1
View File
@@ -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);
// При открытии любого окна — запоминаем его как последнее выбранное (для следующего запуска)
+145 -5
View File
@@ -1,5 +1,8 @@
#include "markdown/MdParser.h"
#include "db/Database.h"
#include "core/Frontmatter.h"
#include <cmark-gfm.h>
#include <cmark-gfm-extension_api.h>
#include <cmark-gfm-core-extensions.h>
@@ -219,6 +222,79 @@ QString tintDiffHtml(const QString &inner, bool isDel)
return QStringLiteral("<div style=\"background-color:rgba(34,197,94,0.45);border-left:3px solid #1a7f37;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").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(
"<div style=\"background:rgba(244,63,94,0.15);border:1px solid #e5484d;border-radius:8px;padding:10px 12px;margin:12px 0\">"
"<div style=\"color:#e5484d;font-weight:bold;font-size:12px\">SQL: %1</div>"
"<div style=\"color:%2;font-size:11px;margin-top:6px\">Таблицы: files(path,title,mtime,size), tags(name), file_tags, links, properties. Только SELECT / WITH.</div>"
"</div>")
.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("<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\">");
h += QStringLiteral("<tr style=\"background:%1\">").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("<th style=\"%1\">%2</th>").arg(cs, esc(cols[c]));
}
h += QStringLiteral("</tr>");
int r = 0;
for (const QVariant &rv : rows) {
const QVariantList row = rv.toList();
h += QStringLiteral("<tr style=\"background:%1\">").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("<i style=\"color:%1\">NULL</i>").arg(faint)
: esc(cell);
h += QStringLiteral("<td style=\"%1\">%2</td>").arg(cs, body);
}
h += QStringLiteral("</tr>");
++r;
}
h += QStringLiteral("</table></div>");
QString foot = rows.size() == 1 ? QStringLiteral("1 строка") : QStringLiteral("%1 строк").arg(rows.size());
if (truncated)
foot += QStringLiteral(" (показаны первые)");
if (!codeId.isEmpty()) {
foot += QStringLiteral(" • <a href=\"flora://copycode/%1\" style=\"color:%2;text-decoration:none\">📋 SQL</a>")
.arg(codeId, faint);
}
h += QStringLiteral("<div style=\"color:%1;font-size:11px;margin:-8px 0 12px 2px\">%2</div>").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<QString, QString> codeBlocks;
int codeCounter = 0;
// Базы открытых хранилищ (для ~sql). QPointer — окно могли закрыть.
QHash<QString, QPointer<Database>> 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;
}
+11
View File
@@ -2,9 +2,12 @@
#include <QCache>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QVariantList>
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);
+38 -2
View File
@@ -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
+240
View File
@@ -0,0 +1,240 @@
// Тест SQLite-слоя: Frontmatter, VaultSettings, Database (индекс, запросы,
// свойства) и ~sql-блоки в MdParser (таблица / fallback / ошибка).
#include <QCoreApplication>
#include <QTemporaryDir>
#include <QDir>
#include <QFile>
#include <cstdio>
#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("<table") || !html.contains(">a</td>") || !html.contains(">b</td>"))
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("<table"))
return fail("sql render", h.left(300));
// ошибка запроса → красный бокс, не падение
const QVariantList be = p.parseBlocks("```~sql\nSELECT * FROM нет_таблицы\n```\n", vault, vault);
if (!be[0].toMap().value("html").toString().contains("SQL:"))
return fail("sql errbox");
}
// --- MdParser: ~sql без базы → исходник как код ---
{
MdParser p;
const QVariantList blocks = p.parseBlocks("```~sql\nSELECT 1\n```\n", vault, vault);
if (blocks.size() != 1 || blocks[0].toMap().value("type").toString() != "code")
return fail("sql fallback");
}
// --- MdParser: шапка не светится в просмотре ---
{
MdParser p;
const QString h = p.render("---\nстатус: секрет\n---\n\n# Заголовок\n", vault, vault);
if (h.contains("секрет") || !h.contains("Заголовок"))
return fail("fm hidden", h.left(200));
}
// --- Свойства в файл без шапки: тело сохраняется ---
{
writeFile(vault + "/plain.md", "# Обычная\n\nТекст без шапки\n");
index.rebuild(vault);
db.reindex();
QVariantMap np;
np.insert("k", "v");
if (!db.saveFileProperties(vault + "/plain.md", np).isEmpty())
return fail("plain props save");
QFile f(vault + "/plain.md");
f.open(QIODevice::ReadOnly);
const QString t = QString::fromUtf8(f.readAll());
if (!t.contains("Текст без шапки") || !t.contains("k: v"))
return fail("plain body kept", t.left(200));
}
std::printf("db_test OK\n");
return 0;
}