Графический браузер базы данных (DatabaseBrowser)

- Диалог: готовые выборки (файлы/теги/свойства/связи), свой SQL,
  результат таблицей, пути кликабельны, ошибки красным
- Ctrl+Enter для выполнения, автопересчёт при изменении базы
- Меню Файл -> База данных (Ctrl+D)
This commit is contained in:
2026-09-07 16:39:32 +03:00
parent f6e1814945
commit 8e8cf3ce70
3 changed files with 198 additions and 0 deletions
+1
View File
@@ -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 как синглтон
+10
View File
@@ -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 ниже).
+187
View File
@@ -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, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;")
}
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 = "<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">"
h += "<tr>"
for (let c = 0; c < cols.length; ++c)
h += "<th align=\"left\">" + esc(cols[c]) + "</th>"
h += "</tr>"
for (let r = 0; r < rows.length; ++r) {
h += "<tr>"
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 += "<td><a href=\"flora://open/" + encodeURIComponent(cell) + "\">"
+ esc(cell) + "</a></td>"
} else {
h += "<td>" + esc(cell) + "</td>"
}
}
h += "</tr>"
}
h += "</table>"
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)
}
}
}
}