diff --git a/CMakeLists.txt b/CMakeLists.txt
index 35ea625..ff4c244 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -140,6 +140,7 @@ qt_add_qml_module(flora
qml/dialogs/SettingsDialog.qml
qml/dialogs/VaultSettingsDialog.qml
qml/dialogs/FilePropertiesDialog.qml
+ qml/dialogs/DatabaseBrowser.qml
)
# Theme как синглтон
diff --git a/qml/MainWindow.qml b/qml/MainWindow.qml
index dea8e95..a87ea6e 100644
--- a/qml/MainWindow.qml
+++ b/qml/MainWindow.qml
@@ -92,6 +92,12 @@ ApplicationWindow {
enabled: fileStore.ready
onTriggered: vaultSettingsDialog.open()
}
+ Action {
+ text: qsTr("База данных…")
+ shortcut: "Ctrl+D"
+ enabled: fileStore.ready
+ onTriggered: dbBrowser.open()
+ }
Action { text: qsTr("Выход"); onTriggered: window.close() }
}
Menu {
@@ -208,6 +214,10 @@ ApplicationWindow {
id: filePropsDialog
}
+ DatabaseBrowser {
+ id: dbBrowser
+ }
+
// Fallback для пути визарда: хранилище открыли прямо в этом окне
// (не через WindowController) — открываем настройки и базу здесь.
// Обычный путь уже подключён в C++, повторный open() безвреден (guard ниже).
diff --git a/qml/dialogs/DatabaseBrowser.qml b/qml/dialogs/DatabaseBrowser.qml
new file mode 100644
index 0000000..759641b
--- /dev/null
+++ b/qml/dialogs/DatabaseBrowser.qml
@@ -0,0 +1,187 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Flora
+
+// Графическая работа с базой хранилища: готовые выборки, свой SQL,
+// результат таблицей (пути кликабельны — открывают заметку).
+Dialog {
+ id: root
+
+ title: qsTr("База данных хранилища")
+ modal: false
+ width: 760
+ height: 620
+ anchors.centerIn: parent
+ closePolicy: Dialog.CloseOnEscape
+ standardButtons: Dialog.Close
+ background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
+
+ property string lastError: ""
+
+ function esc(s) {
+ return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """)
+ }
+
+ function openHref(href) {
+ if (href.startsWith("flora://open/"))
+ documentManager.openDocument(decodeURIComponent(href.substring(13)))
+ }
+
+ function runQuery() {
+ const res = vaultDatabase.queryTable(sqlEdit.text)
+ if (res.error.length > 0) {
+ root.lastError = res.error
+ errorLabel.visible = true
+ resultText.text = ""
+ countLabel.text = ""
+ return
+ }
+ root.lastError = ""
+ errorLabel.visible = false
+ const cols = res.columns
+ const rows = res.rows
+ let h = "
"
+ h += ""
+ for (let c = 0; c < cols.length; ++c)
+ h += "| " + esc(cols[c]) + " | "
+ h += "
"
+ for (let r = 0; r < rows.length; ++r) {
+ h += ""
+ for (let c = 0; c < cols.length; ++c) {
+ const cell = c < rows[r].length ? String(rows[r][c]) : ""
+ // значение похоже на путь внутри хранилища — делаем ссылкой
+ if ((cols[c] === "path" || cell.endsWith(".md"))
+ && fileStore.fileExists(cell)) {
+ h += "| "
+ + esc(cell) + " | "
+ } else {
+ h += "" + esc(cell) + " | "
+ }
+ }
+ h += "
"
+ }
+ h += "
"
+ resultText.text = h
+ countLabel.text = rows.length === 1 ? qsTr("1 строка")
+ : qsTr("%1 строк").arg(rows.length)
+ + (res.truncated ? qsTr(" (показаны первые)") : "")
+ }
+
+ onOpened: {
+ errorLabel.visible = false
+ if (sqlEdit.text.length === 0)
+ presetFiles()
+ else
+ root.runQuery()
+ }
+
+ function presetFiles() {
+ sqlEdit.text = "SELECT title, path, size FROM files ORDER BY title;"
+ root.runQuery()
+ }
+ function presetTags() {
+ sqlEdit.text = "SELECT t.name AS тег, COUNT(*) AS файлов FROM tags t\n"
+ + " JOIN file_tags ft ON ft.tag_id = t.id\n"
+ + " GROUP BY t.name ORDER BY файлов DESC;"
+ root.runQuery()
+ }
+ function presetProps() {
+ sqlEdit.text = "SELECT f.title, p.key, p.value FROM properties p\n"
+ + " JOIN files f ON f.id = p.file_id\n"
+ + " ORDER BY f.title, p.key;"
+ root.runQuery()
+ }
+ function presetLinks() {
+ sqlEdit.text = "SELECT s.title AS от, d.title AS к FROM links l\n"
+ + " JOIN files s ON s.id = l.src_id\n"
+ + " JOIN files d ON d.id = l.dst_id\n"
+ + " ORDER BY от, к;"
+ root.runQuery()
+ }
+
+ // База изменилась (сохранение, свойства) — пересчитать открытое
+ Timer { id: refreshDebounce; interval: 800; onTriggered: root.runQuery() }
+ Connections {
+ target: vaultDatabase
+ function onChanged() { if (root.visible) refreshDebounce.restart() }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 8
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 6
+ Label { text: qsTr("Выборки:"); color: Theme.textSecondary }
+ Button { text: qsTr("Файлы"); onClicked: root.presetFiles() }
+ Button { text: qsTr("Теги"); onClicked: root.presetTags() }
+ Button { text: qsTr("Свойства"); onClicked: root.presetProps() }
+ Button { text: qsTr("Связи"); onClicked: root.presetLinks() }
+ Item { Layout.fillWidth: true }
+ Label {
+ id: countLabel
+ color: Theme.textFaint
+ font.pixelSize: 11
+ }
+ }
+
+ ScrollView {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 130
+ TextArea {
+ id: sqlEdit
+ font.family: "Menlo"
+ wrapMode: TextEdit.NoWrap
+ placeholderText: qsTr("SELECT … (только чтение)")
+ Keys.onPressed: (e) => {
+ if ((e.modifiers & Qt.ControlModifier) && e.key === Qt.Key_Return) {
+ root.runQuery()
+ e.accepted = true
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Button {
+ text: qsTr("Выполнить (Ctrl+Enter)")
+ highlighted: true
+ onClicked: root.runQuery()
+ }
+ Label {
+ Layout.fillWidth: true
+ wrapMode: Text.Wrap
+ color: Theme.textFaint
+ font.pixelSize: 11
+ text: qsTr("Таблицы: files, tags, file_tags, links, properties. Только SELECT / WITH.")
+ }
+ }
+
+ Label {
+ id: errorLabel
+ visible: false
+ Layout.fillWidth: true
+ wrapMode: Text.Wrap
+ color: "#e5484d"
+ text: root.lastError
+ }
+
+ ScrollView {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ Text {
+ id: resultText
+ width: parent.width
+ wrapMode: Text.Wrap
+ textFormat: Text.RichText
+ color: Theme.text
+ onLinkActivated: (href) => root.openHref(href)
+ }
+ }
+ }
+}