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:
@@ -42,8 +42,11 @@ set(FLORA_HEADERS
|
|||||||
src/app/WindowController.h
|
src/app/WindowController.h
|
||||||
src/core/DocumentManager.h
|
src/core/DocumentManager.h
|
||||||
src/core/FileStore.h
|
src/core/FileStore.h
|
||||||
|
src/core/Frontmatter.h
|
||||||
src/core/SettingsStore.h
|
src/core/SettingsStore.h
|
||||||
src/core/VaultIndex.h
|
src/core/VaultIndex.h
|
||||||
|
src/core/VaultSettings.h
|
||||||
|
src/db/Database.h
|
||||||
src/git/GitManager.h
|
src/git/GitManager.h
|
||||||
src/markdown/MdParser.h
|
src/markdown/MdParser.h
|
||||||
src/sync/SyncManager.h
|
src/sync/SyncManager.h
|
||||||
@@ -54,8 +57,11 @@ set(FLORA_SOURCES
|
|||||||
src/app/WindowController.cpp
|
src/app/WindowController.cpp
|
||||||
src/core/DocumentManager.cpp
|
src/core/DocumentManager.cpp
|
||||||
src/core/FileStore.cpp
|
src/core/FileStore.cpp
|
||||||
|
src/core/Frontmatter.cpp
|
||||||
src/core/SettingsStore.cpp
|
src/core/SettingsStore.cpp
|
||||||
src/core/VaultIndex.cpp
|
src/core/VaultIndex.cpp
|
||||||
|
src/core/VaultSettings.cpp
|
||||||
|
src/db/Database.cpp
|
||||||
src/git/GitManager.cpp
|
src/git/GitManager.cpp
|
||||||
src/markdown/MdParser.cpp
|
src/markdown/MdParser.cpp
|
||||||
src/sync/SyncManager.cpp
|
src/sync/SyncManager.cpp
|
||||||
@@ -132,6 +138,8 @@ qt_add_qml_module(flora
|
|||||||
qml/dialogs/TableEditor.qml
|
qml/dialogs/TableEditor.qml
|
||||||
qml/dialogs/HistoryDialog.qml
|
qml/dialogs/HistoryDialog.qml
|
||||||
qml/dialogs/SettingsDialog.qml
|
qml/dialogs/SettingsDialog.qml
|
||||||
|
qml/dialogs/VaultSettingsDialog.qml
|
||||||
|
qml/dialogs/FilePropertiesDialog.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
# Theme как синглтон
|
# Theme как синглтон
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
MenuSeparator { }
|
MenuSeparator { }
|
||||||
Action { text: qsTr("Настройки…"); onTriggered: settingsDialog.open() }
|
Action { text: qsTr("Настройки…"); onTriggered: settingsDialog.open() }
|
||||||
|
Action {
|
||||||
|
text: qsTr("Настройки хранилища…")
|
||||||
|
enabled: fileStore.ready
|
||||||
|
onTriggered: vaultSettingsDialog.open()
|
||||||
|
}
|
||||||
Action { text: qsTr("Выход"); onTriggered: window.close() }
|
Action { text: qsTr("Выход"); onTriggered: window.close() }
|
||||||
}
|
}
|
||||||
Menu {
|
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 {
|
Menu {
|
||||||
title: qsTr("&Вид")
|
title: qsTr("&Вид")
|
||||||
@@ -183,6 +200,32 @@ ApplicationWindow {
|
|||||||
id: settingsDialog
|
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 {
|
Popup {
|
||||||
id: vaultSwitcher
|
id: vaultSwitcher
|
||||||
x: 12
|
x: 12
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,16 @@ Item {
|
|||||||
// contentItem.contentY = 0
|
// 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 {
|
Connections {
|
||||||
target: mdParser
|
target: mdParser
|
||||||
function onBlocksReady(reqId, blocks) {
|
function onBlocksReady(reqId, blocks) {
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ Item {
|
|||||||
readonly property bool hasNote: {
|
readonly property bool hasNote: {
|
||||||
const _ = cal.currentMonth
|
const _ = cal.currentMonth
|
||||||
if (modelData <= 0 || !fileStore.ready) return false
|
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 // можно подсветить выбранный день
|
readonly property bool isSelected: false // можно подсветить выбранный день
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ Item {
|
|||||||
function onActiveIndexChanged() { root.refreshLinksTags() }
|
function onActiveIndexChanged() { root.refreshLinksTags() }
|
||||||
function onOpenTabsChanged() { root.refreshLinksTags() }
|
function onOpenTabsChanged() { root.refreshLinksTags() }
|
||||||
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); indexDebounce.restart() } }
|
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); indexDebounce.restart() } }
|
||||||
function onDocumentSaved(idx, path) {
|
// Сохранение обрабатывает C++ (WindowController): сначала индекс, потом база.
|
||||||
if (fileStore.ready && path && path.length > 0) vaultIndex.updateFile(path)
|
// Здесь не дублируем, иначе двойной парс файла.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } }
|
Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } }
|
||||||
// Правка активного файла: обновить только его (один парс), а не всё хранилище
|
// Правка активного файла: обновить только его (один парс), а не всё хранилище
|
||||||
|
|||||||
@@ -5,17 +5,23 @@
|
|||||||
#include <QQmlComponent>
|
#include <QQmlComponent>
|
||||||
#include <QQmlContext>
|
#include <QQmlContext>
|
||||||
#include <QQuickWindow>
|
#include <QQuickWindow>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
#include "core/DocumentManager.h"
|
#include "core/DocumentManager.h"
|
||||||
#include "core/FileStore.h"
|
#include "core/FileStore.h"
|
||||||
#include "core/VaultIndex.h"
|
#include "core/VaultIndex.h"
|
||||||
|
#include "core/VaultSettings.h"
|
||||||
|
#include "db/Database.h"
|
||||||
|
#include "markdown/MdParser.h"
|
||||||
|
|
||||||
WindowController::WindowController(QQmlApplicationEngine *engine,
|
WindowController::WindowController(QQmlApplicationEngine *engine,
|
||||||
const QString &mainWindowUrl,
|
const QString &mainWindowUrl,
|
||||||
|
MdParser *mdParser,
|
||||||
QObject *parent)
|
QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
, m_engine(engine)
|
, m_engine(engine)
|
||||||
, m_mainWindowUrl(mainWindowUrl)
|
, m_mainWindowUrl(mainWindowUrl)
|
||||||
|
, m_mdParser(mdParser)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +82,37 @@ QObject *WindowController::openWindow(const QString &vaultPath)
|
|||||||
FileStore *store = new FileStore(ctx);
|
FileStore *store = new FileStore(ctx);
|
||||||
DocumentManager *docMgr = new DocumentManager(ctx);
|
DocumentManager *docMgr = new DocumentManager(ctx);
|
||||||
VaultIndex *vIndex = new VaultIndex(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("fileStore", store);
|
||||||
ctx->setContextProperty("documentManager", docMgr);
|
ctx->setContextProperty("documentManager", docMgr);
|
||||||
ctx->setContextProperty("vaultIndex", vIndex);
|
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);
|
QObject *obj = m_component->create(ctx);
|
||||||
if (!obj) {
|
if (!obj) {
|
||||||
@@ -98,13 +132,17 @@ QObject *WindowController::openWindow(const QString &vaultPath)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QString cleanVault = vaultPath.isEmpty() ? QString() : QDir::cleanPath(vaultPath);
|
const QString cleanVault = vaultPath.isEmpty() ? QString() : QDir::cleanPath(vaultPath);
|
||||||
m_windows.append({cleanVault, obj, store, docMgr, vIndex, ctx});
|
m_windows.append({cleanVault, obj, store, docMgr, vIndex, vSettings, db, ctx});
|
||||||
connect(obj, &QObject::destroyed, this, [this, obj, ctx]() {
|
MdParser *mdParser = m_mdParser;
|
||||||
|
connect(obj, &QObject::destroyed, this, [this, obj, ctx, mdParser]() {
|
||||||
for (int i = 0; i < m_windows.size(); ++i) {
|
for (int i = 0; i < m_windows.size(); ++i) {
|
||||||
if (m_windows[i].object == obj) {
|
if (m_windows[i].object == obj) {
|
||||||
const QString vault = m_windows[i].vault;
|
const QString vault = m_windows[i].vault;
|
||||||
|
Database *closedDb = m_windows[i].database;
|
||||||
QQmlContext *c = m_windows[i].context;
|
QQmlContext *c = m_windows[i].context;
|
||||||
m_windows.removeAt(i);
|
m_windows.removeAt(i);
|
||||||
|
if (mdParser && closedDb && !vault.isEmpty())
|
||||||
|
mdParser->removeDatabaseForVault(vault, closedDb);
|
||||||
if (c)
|
if (c)
|
||||||
c->deleteLater();
|
c->deleteLater();
|
||||||
emit windowsChanged();
|
emit windowsChanged();
|
||||||
@@ -127,8 +165,19 @@ QObject *WindowController::openWindow(const QString &vaultPath)
|
|||||||
// Устанавливаем через динамическое свойство если поддерживается
|
// Устанавливаем через динамическое свойство если поддерживается
|
||||||
if (obj->property("destroyOnWindowClose").isValid())
|
if (obj->property("destroyOnWindowClose").isValid())
|
||||||
obj->setProperty("destroyOnWindowClose", true);
|
obj->setProperty("destroyOnWindowClose", true);
|
||||||
if (!cleanVault.isEmpty() && QDir(cleanVault).exists())
|
if (!cleanVault.isEmpty() && QDir(cleanVault).exists()) {
|
||||||
store->openVault(cleanVault);
|
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();
|
emit windowsChanged();
|
||||||
if (!vaultPath.isEmpty())
|
if (!vaultPath.isEmpty())
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
class FileStore;
|
class FileStore;
|
||||||
class DocumentManager;
|
class DocumentManager;
|
||||||
class VaultIndex;
|
class VaultIndex;
|
||||||
|
class VaultSettings;
|
||||||
|
class Database;
|
||||||
|
class MdParser;
|
||||||
|
|
||||||
// Создание окон приложения (мульти-хранилища: новое хранилище — новое окно).
|
// Создание окон приложения (мульти-хранилища: новое хранилище — новое окно).
|
||||||
class WindowController : public QObject
|
class WindowController : public QObject
|
||||||
@@ -19,7 +22,7 @@ class WindowController : public QObject
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
WindowController(QQmlApplicationEngine *engine, const QString &mainWindowUrl,
|
WindowController(QQmlApplicationEngine *engine, const QString &mainWindowUrl,
|
||||||
QObject *parent = nullptr);
|
MdParser *mdParser, QObject *parent = nullptr);
|
||||||
|
|
||||||
int windowCount() const;
|
int windowCount() const;
|
||||||
QStringList openVaults() const;
|
QStringList openVaults() const;
|
||||||
@@ -41,11 +44,14 @@ private:
|
|||||||
FileStore *fileStore = nullptr;
|
FileStore *fileStore = nullptr;
|
||||||
DocumentManager *docManager = nullptr;
|
DocumentManager *docManager = nullptr;
|
||||||
VaultIndex *vaultIndex = nullptr;
|
VaultIndex *vaultIndex = nullptr;
|
||||||
|
VaultSettings *vaultSettings = nullptr;
|
||||||
|
Database *database = nullptr;
|
||||||
QQmlContext *context = nullptr;
|
QQmlContext *context = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
QQmlApplicationEngine *m_engine;
|
QQmlApplicationEngine *m_engine;
|
||||||
QString m_mainWindowUrl;
|
QString m_mainWindowUrl;
|
||||||
|
MdParser *m_mdParser = nullptr; // общий (реестр ~sql-баз по хранилищам)
|
||||||
QList<Window> m_windows;
|
QList<Window> m_windows;
|
||||||
QQmlComponent *m_component = nullptr;
|
QQmlComponent *m_component = nullptr;
|
||||||
};
|
};
|
||||||
@@ -251,6 +251,24 @@ void DocumentManager::saveAll()
|
|||||||
saveDocument(i);
|
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)
|
bool DocumentManager::closeTab(int index)
|
||||||
{
|
{
|
||||||
if (index < 0 || index >= m_docs.size())
|
if (index < 0 || index >= m_docs.size())
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ public:
|
|||||||
Q_INVOKABLE void saveDocument(int index);
|
Q_INVOKABLE void saveDocument(int index);
|
||||||
Q_INVOKABLE void saveAll();
|
Q_INVOKABLE void saveAll();
|
||||||
|
|
||||||
|
// Перечитать файл с диска в чистую вкладку (после правки свойств извне
|
||||||
|
// редактора). Грязную вкладку не трогает. Возвращает "" или текст ошибки.
|
||||||
|
Q_INVOKABLE QString reloadDocument(int index);
|
||||||
|
|
||||||
Q_INVOKABLE bool closeTab(int index);
|
Q_INVOKABLE bool closeTab(int index);
|
||||||
Q_INVOKABLE void moveTab(int from, int to);
|
Q_INVOKABLE void moveTab(int from, int to);
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -105,6 +105,24 @@ QString FileStore::openVault(const QString &path)
|
|||||||
return QString();
|
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()
|
void FileStore::rebuild()
|
||||||
{
|
{
|
||||||
QVector<Row> rows;
|
QVector<Row> rows;
|
||||||
@@ -357,7 +375,7 @@ QString FileStore::dailyNotePath(const QString &dateStr)
|
|||||||
static const QRegularExpression dateRe(QStringLiteral("^\\d{4}-\\d{2}-\\d{2}$"));
|
static const QRegularExpression dateRe(QStringLiteral("^\\d{4}-\\d{2}-\\d{2}$"));
|
||||||
if (!dateRe.match(dateStr).hasMatch())
|
if (!dateRe.match(dateStr).hasMatch())
|
||||||
return QString();
|
return QString();
|
||||||
const QString journalDir = QDir::cleanPath(m_vault + "/Журнал");
|
const QString journalDir = QDir::cleanPath(m_vault + QLatin1Char('/') + m_journalDir);
|
||||||
if (!isInsideVault(journalDir))
|
if (!isInsideVault(journalDir))
|
||||||
return QString();
|
return QString();
|
||||||
if (!QDir().mkpath(journalDir))
|
if (!QDir().mkpath(journalDir))
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class FileStore : public QAbstractListModel
|
|||||||
|
|
||||||
Q_PROPERTY(QString vault READ vault NOTIFY vaultChanged)
|
Q_PROPERTY(QString vault READ vault NOTIFY vaultChanged)
|
||||||
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
|
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
|
||||||
|
// Папка заметок дня внутри хранилища (из настроек хранилища, по умолчанию «Журнал»).
|
||||||
|
Q_PROPERTY(QString journalDir READ journalDir WRITE setJournalDir NOTIFY journalDirChanged)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
enum Roles {
|
enum Roles {
|
||||||
@@ -30,6 +32,8 @@ public:
|
|||||||
|
|
||||||
QString vault() const { return m_vault; }
|
QString vault() const { return m_vault; }
|
||||||
bool ready() const { return !m_vault.isEmpty(); }
|
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,
|
QModelIndex index(int row, int column = 0,
|
||||||
const QModelIndex &parent = QModelIndex()) const override;
|
const QModelIndex &parent = QModelIndex()) const override;
|
||||||
@@ -63,6 +67,7 @@ public:
|
|||||||
signals:
|
signals:
|
||||||
void vaultChanged();
|
void vaultChanged();
|
||||||
void readyChanged();
|
void readyChanged();
|
||||||
|
void journalDirChanged();
|
||||||
void fileActivated(const QString &path);
|
void fileActivated(const QString &path);
|
||||||
void statusMessage(const QString &message);
|
void statusMessage(const QString &message);
|
||||||
// Сработало внешнее изменение (синхронизация/другая программа) — дерево уже
|
// Сработало внешнее изменение (синхронизация/другая программа) — дерево уже
|
||||||
@@ -94,6 +99,7 @@ private:
|
|||||||
QString iconFor(const Row &row) const;
|
QString iconFor(const Row &row) const;
|
||||||
|
|
||||||
QString m_vault;
|
QString m_vault;
|
||||||
|
QString m_journalDir = QStringLiteral("Журнал");
|
||||||
QVector<Row> m_rows;
|
QVector<Row> m_rows;
|
||||||
QSet<QString> m_expanded;
|
QSet<QString> m_expanded;
|
||||||
QFileSystemWatcher m_watcher;
|
QFileSystemWatcher m_watcher;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -518,4 +518,9 @@ QVariantList VaultIndex::linksOfFile(const QString &path) const
|
|||||||
QStringList VaultIndex::filesWithTag(const QString &tag) const
|
QStringList VaultIndex::filesWithTag(const QString &tag) const
|
||||||
{
|
{
|
||||||
return m_filesWithTag.value(tag);
|
return m_filesWithTag.value(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList VaultIndex::indexedFiles() const
|
||||||
|
{
|
||||||
|
return m_fileMeta.keys();
|
||||||
}
|
}
|
||||||
@@ -45,6 +45,9 @@ public:
|
|||||||
// Все файлы с тегом tag.
|
// Все файлы с тегом tag.
|
||||||
Q_INVOKABLE QStringList filesWithTag(const QString &tag) const;
|
Q_INVOKABLE QStringList filesWithTag(const QString &tag) const;
|
||||||
|
|
||||||
|
// Все проиндексированные .md (для синхронизации базы данных).
|
||||||
|
Q_INVOKABLE QStringList indexedFiles() const;
|
||||||
|
|
||||||
Q_INVOKABLE QString displayName(const QString &nodeKey) const;
|
Q_INVOKABLE QString displayName(const QString &nodeKey) const;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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%';");
|
||||||
|
}
|
||||||
@@ -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
@@ -67,7 +67,7 @@ int main(int argc, char *argv[])
|
|||||||
engine.rootContext()->setContextProperty("vaultIndex", &vaultIndex);
|
engine.rootContext()->setContextProperty("vaultIndex", &vaultIndex);
|
||||||
|
|
||||||
WindowController windowController(
|
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);
|
engine.rootContext()->setContextProperty("windowController", &windowController);
|
||||||
|
|
||||||
// При открытии любого окна — запоминаем его как последнее выбранное (для следующего запуска)
|
// При открытии любого окна — запоминаем его как последнее выбранное (для следующего запуска)
|
||||||
|
|||||||
+145
-5
@@ -1,5 +1,8 @@
|
|||||||
#include "markdown/MdParser.h"
|
#include "markdown/MdParser.h"
|
||||||
|
|
||||||
|
#include "db/Database.h"
|
||||||
|
#include "core/Frontmatter.h"
|
||||||
|
|
||||||
#include <cmark-gfm.h>
|
#include <cmark-gfm.h>
|
||||||
#include <cmark-gfm-extension_api.h>
|
#include <cmark-gfm-extension_api.h>
|
||||||
#include <cmark-gfm-core-extensions.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);
|
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)
|
QString fileHrefFor(const QString &path)
|
||||||
{
|
{
|
||||||
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
|
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
|
||||||
@@ -419,6 +495,8 @@ struct MdParser::Impl
|
|||||||
Repository *m_repo = nullptr;
|
Repository *m_repo = nullptr;
|
||||||
QHash<QString, QString> codeBlocks;
|
QHash<QString, QString> codeBlocks;
|
||||||
int codeCounter = 0;
|
int codeCounter = 0;
|
||||||
|
// Базы открытых хранилищ (для ~sql). QPointer — окно могли закрыть.
|
||||||
|
QHash<QString, QPointer<Database>> databases;
|
||||||
};
|
};
|
||||||
|
|
||||||
MdParser::MdParser(QObject *parent)
|
MdParser::MdParser(QObject *parent)
|
||||||
@@ -455,6 +533,30 @@ bool MdParser::copyCodeBlock(const QString &id) const
|
|||||||
return true;
|
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 {
|
namespace {
|
||||||
|
|
||||||
class Renderer
|
class Renderer
|
||||||
@@ -619,7 +721,8 @@ private:
|
|||||||
QString code_block(cmark_node *n)
|
QString code_block(cmark_node *n)
|
||||||
{
|
{
|
||||||
const char *info = cmark_node_get_fence_info(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));
|
const QString code = QString::fromUtf8(cmark_node_get_literal(n));
|
||||||
// Сохраняем оригинал для копирования
|
// Сохраняем оригинал для копирования
|
||||||
QString codeId;
|
QString codeId;
|
||||||
@@ -627,6 +730,15 @@ private:
|
|||||||
codeId = QStringLiteral("code_%1").arg(m_impl->codeCounter++);
|
codeId = QStringLiteral("code_%1").arg(m_impl->codeCounter++);
|
||||||
m_impl->codeBlocks.insert(codeId, code);
|
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)
|
// Подсветка — тёмная тема для контраста на синем фоне (как в Telegram)
|
||||||
CodeHighlighter hl(m_repo);
|
CodeHighlighter hl(m_repo);
|
||||||
const QString highlighted = hl.highlight(code, lang, true);
|
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->codeBlocks.clear();
|
||||||
m->codeCounter = 0;
|
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: безопасен для рабочего потока
|
// Файлово-статический рендер с явным Impl: безопасен для рабочего потока
|
||||||
@@ -901,6 +1016,14 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
|||||||
QVariantList blocks;
|
QVariantList blocks;
|
||||||
if (markdown.isEmpty()) return 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) {
|
if (m) {
|
||||||
m->codeBlocks.clear();
|
m->codeBlocks.clear();
|
||||||
@@ -921,7 +1044,7 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
|||||||
};
|
};
|
||||||
|
|
||||||
bool any = false;
|
bool any = false;
|
||||||
for (const QString &part : splitMdBlocks(markdown)) {
|
for (const QString &part : splitMdBlocks(src)) {
|
||||||
QString lang, code;
|
QString lang, code;
|
||||||
if (isFenceBlock(part) && parseFenceBlock(part, &lang, &code)) {
|
if (isFenceBlock(part) && parseFenceBlock(part, &lang, &code)) {
|
||||||
QString codeId;
|
QString codeId;
|
||||||
@@ -929,6 +1052,23 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
|||||||
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
|
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
|
||||||
m->codeBlocks.insert(codeId, code);
|
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);
|
CodeHighlighter hl(m ? m->m_repo : nullptr);
|
||||||
QString highlighted;
|
QString highlighted;
|
||||||
if (m && m->m_repo) {
|
if (m && m->m_repo) {
|
||||||
@@ -954,8 +1094,8 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Если вообще не нашли блоки, но markdown не пустой — вернём как html
|
// Если вообще не нашли блоки, но markdown не пустой — вернём как html
|
||||||
if (!any && !markdown.trimmed().isEmpty())
|
if (!any && !src.trimmed().isEmpty())
|
||||||
appendHtml(renderInner(markdown, vaultRoot, fileDir));
|
appendHtml(renderInner(src, vaultRoot, fileDir));
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
#include <QCache>
|
#include <QCache>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
|
||||||
// Рендер Markdown (CommonMark + GFM) в HTML для режима просмотра.
|
// Рендер Markdown (CommonMark + GFM) в HTML для режима просмотра.
|
||||||
// Задачи/ссылки/переходы кодируются ссылками schemes:
|
// Задачи/ссылки/переходы кодируются ссылками schemes:
|
||||||
// flora://task/N — переключить задачу на строке N (1-based)
|
// flora://task/N — переключить задачу на строке N (1-based)
|
||||||
@@ -50,6 +53,14 @@ public:
|
|||||||
// Скопировать кодовый блок по id (get + copy одним вызовом). false — нечего копировать.
|
// Скопировать кодовый блок по id (get + copy одним вызовом). false — нечего копировать.
|
||||||
Q_INVOKABLE bool copyCodeBlock(const QString &id) const;
|
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:
|
signals:
|
||||||
void blocksReady(int requestId, const QVariantList &blocks);
|
void blocksReady(int requestId, const QVariantList &blocks);
|
||||||
|
|
||||||
|
|||||||
+38
-2
@@ -64,6 +64,12 @@ add_test(NAME flora_docmgr_test COMMAND flora_docmgr_test)
|
|||||||
add_executable(flora_mdparser_test mdparser_test.cpp
|
add_executable(flora_mdparser_test mdparser_test.cpp
|
||||||
../src/markdown/MdParser.cpp
|
../src/markdown/MdParser.cpp
|
||||||
../src/markdown/MdParser.h
|
../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_include_directories(flora_mdparser_test PRIVATE ../src)
|
||||||
target_compile_definitions(flora_mdparser_test PRIVATE
|
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
|
target_link_libraries(flora_mdparser_test PRIVATE
|
||||||
Qt6::Core
|
Qt6::Core
|
||||||
Qt6::Gui
|
Qt6::Gui
|
||||||
|
Qt6::Sql
|
||||||
Qt6::Concurrent
|
Qt6::Concurrent
|
||||||
cmark::cmark
|
cmark::cmark
|
||||||
cmark::cmark_extensions
|
cmark::cmark_extensions
|
||||||
@@ -117,6 +124,10 @@ add_executable(flora_extended_test extended_test.cpp
|
|||||||
../src/core/DocumentManager.h
|
../src/core/DocumentManager.h
|
||||||
../src/core/VaultIndex.cpp
|
../src/core/VaultIndex.cpp
|
||||||
../src/core/VaultIndex.h
|
../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.cpp
|
||||||
../src/markdown/MdParser.h
|
../src/markdown/MdParser.h
|
||||||
../src/git/GitManager.cpp
|
../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_include_directories(flora_extended_test PRIVATE ../src)
|
||||||
target_compile_definitions(flora_extended_test PRIVATE CMARK_GFM_STATIC_DEFINE CMARK_GFM_EXTENSIONS_STATIC_DEFINE)
|
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)
|
if(TARGET KF6SyntaxHighlighting)
|
||||||
target_link_libraries(flora_extended_test PRIVATE KF6SyntaxHighlighting)
|
target_link_libraries(flora_extended_test PRIVATE KF6SyntaxHighlighting)
|
||||||
endif()
|
endif()
|
||||||
@@ -145,6 +156,10 @@ add_executable(flora_integration_test integration_test.cpp
|
|||||||
../src/core/DocumentManager.h
|
../src/core/DocumentManager.h
|
||||||
../src/core/VaultIndex.cpp
|
../src/core/VaultIndex.cpp
|
||||||
../src/core/VaultIndex.h
|
../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.cpp
|
||||||
../src/markdown/MdParser.h
|
../src/markdown/MdParser.h
|
||||||
../src/git/GitManager.cpp
|
../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_include_directories(flora_integration_test PRIVATE ../src)
|
||||||
target_compile_definitions(flora_integration_test PRIVATE CMARK_GFM_STATIC_DEFINE CMARK_GFM_EXTENSIONS_STATIC_DEFINE)
|
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)
|
if(TARGET KF6SyntaxHighlighting)
|
||||||
target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting)
|
target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting)
|
||||||
endif()
|
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)
|
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)
|
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
|
add_executable(flora_vault_select_test vault_select_test.cpp
|
||||||
../src/app/VaultManager.cpp
|
../src/app/VaultManager.cpp
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user