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:
@@ -87,6 +87,11 @@ ApplicationWindow {
|
||||
}
|
||||
MenuSeparator { }
|
||||
Action { text: qsTr("Настройки…"); onTriggered: settingsDialog.open() }
|
||||
Action {
|
||||
text: qsTr("Настройки хранилища…")
|
||||
enabled: fileStore.ready
|
||||
onTriggered: vaultSettingsDialog.open()
|
||||
}
|
||||
Action { text: qsTr("Выход"); onTriggered: window.close() }
|
||||
}
|
||||
Menu {
|
||||
@@ -119,6 +124,18 @@ ApplicationWindow {
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuSeparator { }
|
||||
Action {
|
||||
text: qsTr("Свойства заметки…")
|
||||
enabled: documentManager.activeIndex >= 0 && fileStore.ready
|
||||
onTriggered: {
|
||||
const p = documentManager.pathAt(documentManager.activeIndex)
|
||||
if (p.length>0) {
|
||||
filePropsDialog.filePath = p
|
||||
filePropsDialog.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
title: qsTr("&Вид")
|
||||
@@ -183,6 +200,32 @@ ApplicationWindow {
|
||||
id: settingsDialog
|
||||
}
|
||||
|
||||
VaultSettingsDialog {
|
||||
id: vaultSettingsDialog
|
||||
}
|
||||
|
||||
FilePropertiesDialog {
|
||||
id: filePropsDialog
|
||||
}
|
||||
|
||||
// Fallback для пути визарда: хранилище открыли прямо в этом окне
|
||||
// (не через WindowController) — открываем настройки и базу здесь.
|
||||
// Обычный путь уже подключён в C++, повторный open() безвреден (guard ниже).
|
||||
function ensureVaultDb() {
|
||||
if (!fileStore.ready)
|
||||
return
|
||||
if (vaultDatabase.isOpen && vaultDatabase.vault === fileStore.vault)
|
||||
return
|
||||
vaultSettings.open(fileStore.vault)
|
||||
if (vaultDatabase.open(fileStore.vault))
|
||||
mdParser.setDatabaseForVault(fileStore.vault, vaultDatabase)
|
||||
}
|
||||
Connections {
|
||||
target: fileStore
|
||||
function onVaultChanged() { window.ensureVaultDb() }
|
||||
function onReadyChanged() { if (fileStore.ready) window.ensureVaultDb() }
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: vaultSwitcher
|
||||
x: 12
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// База изменилась (переиндексация, правка свойств, вкл/выкл SQL) —
|
||||
// ~sql-блоки надо пересчитать. Дебаунс против шторма при reindex.
|
||||
Timer { id: dbDebounce; interval: 400; onTriggered: root.update() }
|
||||
Connections {
|
||||
target: vaultDatabase
|
||||
function onChanged() { dbDebounce.restart() }
|
||||
function onQueryEnabledChanged() { dbDebounce.restart() }
|
||||
function onMaxRowsChanged() { dbDebounce.restart() }
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: mdParser
|
||||
function onBlocksReady(reqId, blocks) {
|
||||
|
||||
@@ -181,7 +181,7 @@ Item {
|
||||
readonly property bool hasNote: {
|
||||
const _ = cal.currentMonth
|
||||
if (modelData <= 0 || !fileStore.ready) return false
|
||||
return fileStore.fileExists(fileStore.vault + "/Журнал/" + ds + ".md")
|
||||
return fileStore.fileExists(fileStore.vault + "/" + fileStore.journalDir + "/" + ds + ".md")
|
||||
}
|
||||
readonly property bool isSelected: false // можно подсветить выбранный день
|
||||
|
||||
|
||||
@@ -35,9 +35,8 @@ Item {
|
||||
function onActiveIndexChanged() { root.refreshLinksTags() }
|
||||
function onOpenTabsChanged() { root.refreshLinksTags() }
|
||||
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); indexDebounce.restart() } }
|
||||
function onDocumentSaved(idx, path) {
|
||||
if (fileStore.ready && path && path.length > 0) vaultIndex.updateFile(path)
|
||||
}
|
||||
// Сохранение обрабатывает C++ (WindowController): сначала индекс, потом база.
|
||||
// Здесь не дублируем, иначе двойной парс файла.
|
||||
}
|
||||
Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } }
|
||||
// Правка активного файла: обновить только его (один парс), а не всё хранилище
|
||||
|
||||
Reference in New Issue
Block a user