188 lines
6.4 KiB
QML
188 lines
6.4 KiB
QML
|
|
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, ">").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 = "<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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|