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

- Frontmatter: шапка --- ключ: значение (источник правды свойств)
- VaultSettings: .flora/settings.json (журнал, sqlEnabled, sqlMaxRows)
- Database: files/tags/file_tags/links/properties, только SELECT/WITH
  на read-only соединении (mode=ro + query_only + зонд), лимит строк
- MdParser: ~sql-блоки таблицей (parseBlocks + Renderer), шапка скрыта в просмотре
- Окно: per-window Database/VaultSettings, sync при сохранении, дебаунс reindex
- QML: VaultSettingsDialog, FilePropertiesDialog, меню, автообновление ~sql
- FileStore.journalDir, DocumentManager.reloadDocument
- Тест flora_db_test (23/23 зелёных)
This commit is contained in:
2026-09-07 16:30:06 +03:00
parent 0a46471cc6
commit fdf59f5b39
26 changed files with 1972 additions and 17 deletions
+181
View File
@@ -0,0 +1,181 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Flora
// Свойства заметки (Notion-поля): источник правды — frontmatter в самом .md,
// база обновляется следом. Грязную вкладку правим только после сохранения.
Dialog {
id: root
property string filePath: ""
title: qsTr("Свойства заметки")
modal: true
width: 520
height: 560
anchors.centerIn: parent
closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside
standardButtons: Dialog.NoButton
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
footer: RowLayout {
spacing: 8
Item { Layout.fillWidth: true }
Button {
text: qsTr("Отмена")
onClicked: root.reject()
}
Button {
text: qsTr("Сохранить")
highlighted: true
onClicked: root.saveProps()
}
}
onOpened: {
statusLabel.text = ""
newKeyField.text = ""
newValueField.text = ""
propsModel.clear()
const props = vaultDatabase.fileProperties(root.filePath)
const keys = Object.keys(props).sort()
for (let i = 0; i < keys.length; ++i)
propsModel.append({ key: keys[i], value: props[keys[i]] })
}
function saveProps() {
const map = {}
for (let i = 0; i < propsModel.count; ++i) {
const row = propsModel.get(i)
map[row.key] = row.value
}
const err = vaultDatabase.saveFileProperties(root.filePath, map)
if (err.length > 0) {
statusLabel.text = err
return
}
// Обновить открытые вкладки с этим файлом (только чистые)
let dirty = false
for (let t = 0; t < documentManager.openTabs.length; ++t) {
if (documentManager.pathAt(t) === root.filePath) {
if (documentManager.isDirty(t)) {
dirty = true
} else {
documentManager.reloadDocument(t)
}
}
}
vaultIndex.updateFile(root.filePath)
vaultDatabase.syncFile(root.filePath)
if (dirty) {
statusLabel.text = qsTr("Свойства записаны, но вкладка изменена — сохраните её (Ctrl+S) и переоткройте.")
return
}
root.accept()
}
function addProp() {
const k = newKeyField.text.trim()
const v = newValueField.text
if (k.length === 0) {
statusLabel.text = qsTr("Укажите имя свойства.")
return
}
for (let i = 0; i < propsModel.count; ++i) {
if (propsModel.get(i).key === k) {
statusLabel.text = qsTr("Такое свойство уже есть — правьте его в списке.")
return
}
}
propsModel.append({ key: k, value: v })
newKeyField.text = ""
newValueField.text = ""
statusLabel.text = ""
}
ListModel { id: propsModel }
ColumnLayout {
anchors.fill: parent
spacing: 10
Label {
Layout.fillWidth: true
color: Theme.textSecondary
font.pixelSize: 11
text: root.filePath
elide: Text.ElideMiddle
}
ListView {
id: propsView
Layout.fillWidth: true
Layout.fillHeight: true
model: propsModel
clip: true
spacing: 6
delegate: RowLayout {
width: propsView.width
spacing: 8
Label {
text: model.key
color: Theme.text
font.bold: true
Layout.preferredWidth: 140
elide: Text.ElideRight
}
TextField {
Layout.fillWidth: true
text: model.value
placeholderText: qsTr("значение")
onTextChanged: propsModel.setProperty(index, "value", text)
}
Button {
text: "✕"
onClicked: propsModel.remove(index)
}
}
Label {
visible: propsModel.count === 0
anchors.centerIn: parent
text: qsTr("Свойств нет — добавьте первое ниже")
color: Theme.textFaint
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
TextField {
id: newKeyField
Layout.preferredWidth: 150
placeholderText: qsTr("имя")
onAccepted: root.addProp()
}
TextField {
id: newValueField
Layout.fillWidth: true
placeholderText: qsTr("значение")
onAccepted: root.addProp()
}
Button {
text: qsTr("Добавить")
onClicked: root.addProp()
}
}
Label {
text: qsTr("Имя: буквы, цифры, _ и - (до 64 символов).")
color: Theme.textFaint
font.pixelSize: 11
}
Label {
id: statusLabel
Layout.fillWidth: true
wrapMode: Text.Wrap
color: "#e5484d"
font.pixelSize: 11
}
}
}
+163
View File
@@ -0,0 +1,163 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Flora
// Настройки одного хранилища: лежат в <vault>/.flora/settings.json,
// в git не попадают (как и сама база). Применяются живьём через vaultSettings.
Dialog {
id: root
title: qsTr("Настройки хранилища")
modal: true
width: 560
height: 640
anchors.centerIn: parent
closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside
standardButtons: Dialog.NoButton
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
footer: RowLayout {
spacing: 8
Item { Layout.fillWidth: true }
Button {
text: qsTr("Отмена")
onClicked: {
// откатить живьём применённое (сеттеры уже ушли в базу через changed)
vaultSettings.open(fileStore.vault)
root.reject()
}
}
Button {
text: qsTr("Сохранить")
highlighted: true
onClicked: root.saveSettings()
}
}
onOpened: {
journalField.text = vaultSettings.journalDir
sqlCheck.checked = vaultSettings.sqlEnabled
maxRowsSpin.value = vaultSettings.sqlMaxRows
root.refreshStats()
statusLabel.text = ""
}
function saveSettings() {
vaultSettings.journalDir = journalField.text
vaultSettings.sqlEnabled = sqlCheck.checked
vaultSettings.sqlMaxRows = maxRowsSpin.value
// Флаги уже ушли в базу живьём (сигнал changed); здесь только persist
if (!vaultSettings.save()) {
statusLabel.text = qsTr("Не удалось записать .flora/settings.json")
return
}
root.accept()
}
function refreshStats() {
const s = vaultDatabase.stats()
statFiles.text = s.files
statTags.text = s.tags
statLinks.text = s.links
statProps.text = s.properties
const kb = Math.round(s.dbSize / 1024)
statDb.text = kb < 1024 ? kb + " КБ" : (kb / 1024).toFixed(1) + " МБ"
}
ColumnLayout {
anchors.fill: parent
spacing: 10
Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
color: Theme.textSecondary
font.pixelSize: 11
text: fileStore.ready ? fileStore.vault : qsTr("Хранилище не открыто")
elide: Text.ElideMiddle
}
GroupBox {
Layout.fillWidth: true
title: qsTr("Заметки дня")
background: Rectangle { color: Theme.bgAlt; border.color: Theme.border; radius: 8 }
label: Label { text: parent.title; color: Theme.text; font.bold: true; leftPadding: 6 }
RowLayout {
anchors.fill: parent
anchors.margins: 8
Label { text: qsTr("Папка:"); color: Theme.textSecondary }
TextField {
id: journalField
Layout.fillWidth: true
placeholderText: qsTr("Журнал")
}
}
}
GroupBox {
Layout.fillWidth: true
title: qsTr("SQL-блоки (~sql)")
background: Rectangle { color: Theme.bgAlt; border.color: Theme.border; radius: 8 }
label: Label { text: parent.title; color: Theme.text; font.bold: true; leftPadding: 6 }
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 6
CheckBox {
id: sqlCheck
text: qsTr("Выполнять ~sql-запросы в просмотре")
}
RowLayout {
Label { text: qsTr("Строк в результате:"); color: Theme.textSecondary }
SpinBox { id: maxRowsSpin; from: 5; to: 500; stepSize: 5; editable: true }
}
Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
color: Theme.textFaint
font.pixelSize: 11
text: qsTr("Таблицы: files(path,title,mtime,size), tags(name), file_tags, links, properties(file_id,key,value). Только чтение (SELECT / WITH).")
}
}
}
GroupBox {
Layout.fillWidth: true
title: qsTr("База хранилища")
background: Rectangle { color: Theme.bgAlt; border.color: Theme.border; radius: 8 }
label: Label { text: parent.title; color: Theme.text; font.bold: true; leftPadding: 6 }
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 4
GridLayout {
columns: 2
columnSpacing: 12
Label { text: qsTr("Файлов:"); color: Theme.textSecondary }
Label { id: statFiles; color: Theme.text }
Label { text: qsTr("Тегов:"); color: Theme.textSecondary }
Label { id: statTags; color: Theme.text }
Label { text: qsTr("Связей:"); color: Theme.textSecondary }
Label { id: statLinks; color: Theme.text }
Label { text: qsTr("Свойств:"); color: Theme.textSecondary }
Label { id: statProps; color: Theme.text }
Label { text: qsTr("База:"); color: Theme.textSecondary }
Label { id: statDb; color: Theme.text }
}
Button {
text: qsTr("Обновить статистику")
onClicked: root.refreshStats()
}
}
}
Label {
id: statusLabel
Layout.fillWidth: true
wrapMode: Text.Wrap
color: "#e5484d"
font.pixelSize: 11
}
}
}