Compare commits
11 Commits
e292f6586e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e304f503e5 | |||
| 51372ace4e | |||
| 8e8cf3ce70 | |||
| f6e1814945 | |||
| fdf59f5b39 | |||
| 0a46471cc6 | |||
| 417d0273b7 | |||
| 1f140d3433 | |||
| 0846605c2b | |||
| f6074df0fc | |||
| 781e1dfc12 |
+12
-1
@@ -23,7 +23,7 @@ message(STATUS "OpenSSL: ${OPENSSL_VERSION}")
|
||||
# Qt 6
|
||||
find_package(Qt6 6.4 REQUIRED COMPONENTS
|
||||
Quick QuickControls2 QuickTemplates2 QuickLayouts
|
||||
Sql Network Pdf PrintSupport Svg)
|
||||
Sql Network Pdf PrintSupport Svg Concurrent)
|
||||
|
||||
qt_standard_project_setup(REQUIRES 6.4)
|
||||
|
||||
@@ -42,8 +42,11 @@ set(FLORA_HEADERS
|
||||
src/app/WindowController.h
|
||||
src/core/DocumentManager.h
|
||||
src/core/FileStore.h
|
||||
src/core/Frontmatter.h
|
||||
src/core/SettingsStore.h
|
||||
src/core/VaultIndex.h
|
||||
src/core/VaultSettings.h
|
||||
src/db/Database.h
|
||||
src/git/GitManager.h
|
||||
src/markdown/MdParser.h
|
||||
src/sync/SyncManager.h
|
||||
@@ -54,8 +57,11 @@ set(FLORA_SOURCES
|
||||
src/app/WindowController.cpp
|
||||
src/core/DocumentManager.cpp
|
||||
src/core/FileStore.cpp
|
||||
src/core/Frontmatter.cpp
|
||||
src/core/SettingsStore.cpp
|
||||
src/core/VaultIndex.cpp
|
||||
src/core/VaultSettings.cpp
|
||||
src/db/Database.cpp
|
||||
src/git/GitManager.cpp
|
||||
src/markdown/MdParser.cpp
|
||||
src/sync/SyncManager.cpp
|
||||
@@ -81,6 +87,7 @@ target_link_libraries(flora_core PUBLIC
|
||||
Qt6::Pdf
|
||||
Qt6::PrintSupport
|
||||
Qt6::Svg
|
||||
Qt6::Concurrent
|
||||
cmark::cmark
|
||||
)
|
||||
if(TARGET cmark::cmark_extensions)
|
||||
@@ -130,6 +137,10 @@ qt_add_qml_module(flora
|
||||
qml/dialogs/ConfirmDialog.qml
|
||||
qml/dialogs/TableEditor.qml
|
||||
qml/dialogs/HistoryDialog.qml
|
||||
qml/dialogs/SettingsDialog.qml
|
||||
qml/dialogs/VaultSettingsDialog.qml
|
||||
qml/dialogs/FilePropertiesDialog.qml
|
||||
qml/dialogs/DatabaseBrowser.qml
|
||||
)
|
||||
|
||||
# Theme как синглтон
|
||||
|
||||
@@ -49,6 +49,15 @@ ApplicationWindow {
|
||||
// статус можно показать в UI, пока просто игнорируем
|
||||
}
|
||||
|
||||
function applyAutosavePreset() {
|
||||
if (settingsStore.autosaveEnabled) {
|
||||
documentManager.setAutosaveIntervalMs(settingsStore.autosaveDiskMs)
|
||||
documentManager.setAutosaveEnabled(true)
|
||||
} else {
|
||||
documentManager.setAutosaveEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (windowVault.length > 0) {
|
||||
fileStore.openVault(windowVault)
|
||||
@@ -58,6 +67,11 @@ ApplicationWindow {
|
||||
if (vaultManager.vaults.length === 0) {
|
||||
welcomeWizard.open()
|
||||
}
|
||||
applyAutosavePreset()
|
||||
}
|
||||
Connections {
|
||||
target: settingsStore
|
||||
function onAutosavePresetChanged() { applyAutosavePreset() }
|
||||
}
|
||||
|
||||
menuBar: MenuBar {
|
||||
@@ -72,6 +86,18 @@ ApplicationWindow {
|
||||
Action { text: qsTr("В PDF…"); enabled: false }
|
||||
}
|
||||
MenuSeparator { }
|
||||
Action { text: qsTr("Настройки…"); onTriggered: settingsDialog.open() }
|
||||
Action {
|
||||
text: qsTr("Настройки хранилища…")
|
||||
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 {
|
||||
@@ -104,6 +130,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("&Вид")
|
||||
@@ -164,6 +202,40 @@ ApplicationWindow {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDialog {
|
||||
id: settingsDialog
|
||||
}
|
||||
|
||||
VaultSettingsDialog {
|
||||
id: vaultSettingsDialog
|
||||
}
|
||||
|
||||
FilePropertiesDialog {
|
||||
id: filePropsDialog
|
||||
}
|
||||
|
||||
DatabaseBrowser {
|
||||
id: dbBrowser
|
||||
}
|
||||
|
||||
// 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,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, ">").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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,13 @@ Dialog {
|
||||
property var historyModel: []
|
||||
property int selectedIndex: -1
|
||||
property string selectedHash: ""
|
||||
property string diffHtml: ""
|
||||
property string versionContent: ""
|
||||
property string currentContent: ""
|
||||
property string emptyMessage: qsTr("Нет версии для просмотра")
|
||||
property bool busy: false
|
||||
// Пагинация: подгружаем порциями, чтобы revwalk не бежал по всей истории сразу
|
||||
property int historyLimit: 60
|
||||
property bool hasMore: false
|
||||
|
||||
function fileDirOf(p) {
|
||||
const i = p.lastIndexOf("/")
|
||||
@@ -62,33 +65,38 @@ Dialog {
|
||||
historyModel = []
|
||||
versionContent = ""
|
||||
currentContent = ""
|
||||
diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Файл не выбран") + "</p>"
|
||||
emptyMessage = qsTr("Файл не выбран")
|
||||
return
|
||||
}
|
||||
busy = true
|
||||
// берём историю
|
||||
const hist = gitManager.fileHistory(vaultPath, filePath)
|
||||
historyModel = hist
|
||||
listView.model = hist
|
||||
if (hist.length>0) {
|
||||
selectedIndex = 0
|
||||
selectedHash = hist[0].hash
|
||||
// берём историю порцией limit+1: лишний элемент — признак «есть ещё»
|
||||
const keepHash = selectedHash
|
||||
const hist = gitManager.fileHistory(vaultPath, filePath, historyLimit + 1)
|
||||
hasMore = hist.length > historyLimit
|
||||
const shown = hasMore ? hist.slice(0, historyLimit) : hist
|
||||
historyModel = shown
|
||||
listView.model = shown
|
||||
if (shown.length>0) {
|
||||
// сохраняем выбор при «Показать ещё»/обновлении
|
||||
let idx = 0
|
||||
if (keepHash.length > 0) {
|
||||
for (let i = 0; i < shown.length; ++i) {
|
||||
if (shown[i].hash === keepHash) { idx = i; break }
|
||||
}
|
||||
}
|
||||
selectedIndex = idx
|
||||
selectedHash = shown[idx].hash
|
||||
updateDiff()
|
||||
} else {
|
||||
selectedIndex = -1
|
||||
selectedHash = ""
|
||||
versionContent = ""
|
||||
currentContent = ""
|
||||
const hasFile = filePath.length>0
|
||||
if (hasFile) {
|
||||
const st = gitManager.fileGitStatus(vaultPath, filePath)
|
||||
if (st === "неотслеживается" || st === "новый") {
|
||||
diffHtml = "<p style='color:" + Theme.warningText + "'>" + qsTr("История пуста — файл ещё не сохранён в git. Сохраните файл, и здесь появится первая версия.") + "</p>"
|
||||
} else {
|
||||
diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет версий для этого файла") + "</p>"
|
||||
}
|
||||
const st = gitManager.fileGitStatus(vaultPath, filePath)
|
||||
if (st === "неотслеживается" || st === "новый") {
|
||||
emptyMessage = qsTr("История пуста — файл ещё не сохранён в git. Сохраните файл, и здесь появится первая версия.")
|
||||
} else {
|
||||
diffHtml = ""
|
||||
emptyMessage = qsTr("Нет версий для этого файла")
|
||||
}
|
||||
}
|
||||
busy = false
|
||||
@@ -96,19 +104,17 @@ Dialog {
|
||||
|
||||
function updateDiff() {
|
||||
if (selectedHash.length===0) {
|
||||
diffHtml = ""
|
||||
versionContent = ""
|
||||
currentContent = ""
|
||||
busy = false
|
||||
return
|
||||
}
|
||||
// свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге
|
||||
currentContent = gitManager.workdirContent(filePath)
|
||||
const html = gitManager.fileDiffHtml(vaultPath, filePath, selectedHash)
|
||||
if (html.length===0) diffHtml = "<p style='color:" + Theme.textFaint + "'>" + qsTr("Нет изменений") + "</p>"
|
||||
else diffHtml = html
|
||||
// содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре»,
|
||||
// с подсветкой отличий от текущего файла прямо там
|
||||
// с подсветкой отличий от текущего файла прямо там (считается в рабочем потоке)
|
||||
versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash)
|
||||
busy = currentContent.length > 0 && versionContent.length > 0
|
||||
}
|
||||
|
||||
onOpened: reload()
|
||||
@@ -289,63 +295,34 @@ Dialog {
|
||||
font.family: "Menlo, monospace"
|
||||
}
|
||||
}
|
||||
Button {
|
||||
visible: root.hasMore
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Показать ещё")
|
||||
onClicked: { root.historyLimit += 60; root.reload() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Правая панель — diff и превью
|
||||
// Правая панель — версия как кнопка «Просмотр», с подсветкой отличий прямо там
|
||||
ColumnLayout {
|
||||
SplitView.fillWidth: true
|
||||
SplitView.minimumWidth: 300
|
||||
spacing: 0
|
||||
|
||||
TabBar {
|
||||
id: tabBar
|
||||
Layout.fillWidth: true
|
||||
TabButton { text: qsTr("Изменения") }
|
||||
TabButton { text: qsTr("Просмотр версии") }
|
||||
}
|
||||
|
||||
StackLayout {
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
currentIndex: tabBar.currentIndex
|
||||
|
||||
// Diff вкладка
|
||||
Rectangle {
|
||||
color: Theme.editorBg
|
||||
border.color: Theme.divider
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
clip: true
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
TextArea {
|
||||
id: diffArea
|
||||
readOnly: true
|
||||
textFormat: Text.RichText
|
||||
wrapMode: Text.Wrap
|
||||
color: Theme.text
|
||||
selectionColor: Theme.selectionBg
|
||||
text: root.diffHtml
|
||||
background: Rectangle { color: Theme.editorBg }
|
||||
font.family: "Menlo, monospace"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Просмотр версии — как кнопка «Просмотр» в редакторе
|
||||
Rectangle {
|
||||
color: Theme.editorBg
|
||||
border.color: Theme.divider
|
||||
ScrollView {
|
||||
id: versionScroll
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
clip: true
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
Column {
|
||||
width: versionScroll.availableWidth
|
||||
color: Theme.editorBg
|
||||
border.color: Theme.divider
|
||||
ScrollView {
|
||||
id: versionScroll
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
clip: true
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
Column {
|
||||
width: versionScroll.availableWidth
|
||||
MarkdownView {
|
||||
id: versionPreview
|
||||
width: parent.width
|
||||
@@ -353,26 +330,23 @@ Dialog {
|
||||
compareMarkdown: root.currentContent
|
||||
vaultRoot: root.vaultPath
|
||||
fileDir: root.fileDirOf(root.filePath)
|
||||
emptyText: qsTr("Нет версии для просмотра")
|
||||
emptyText: root.emptyMessage
|
||||
onLinkActivated: (href) => root.handleHistoryLink(href)
|
||||
onDiffApplied: root.busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Легенда — цвета отличий от текущего файла (обе вкладки)
|
||||
// Легенда — цвета отличий от текущего файла
|
||||
Row {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 6
|
||||
spacing: 12
|
||||
Row { spacing: 4; Rectangle { width:12; height:12; color:"#b7f5c6"; border.color:"#1a7f37"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("добавлено"); color: Theme.textMuted; font.pixelSize:10 } }
|
||||
Row { spacing: 4; Rectangle { width:12; height:12; color:"#ffd6dd"; border.color:"#e5484d"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } }
|
||||
Row { visible: tabBar.currentIndex === 0; spacing: 4; Rectangle { width:12; height:12; color:"#ddf4ff"; border.color:"#0969da"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("заголовок"); color: Theme.textMuted; font.pixelSize:10 } }
|
||||
Row { spacing: 4; Rectangle { width:12; height:12; color:"#7322C55E"; border.color:"#1a7f37"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("добавлено"); color: Theme.textMuted; font.pixelSize:10 } }
|
||||
Row { spacing: 4; Rectangle { width:12; height:12; color:"#73F43F5E"; border.color:"#e5484d"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } }
|
||||
Label {
|
||||
text: tabBar.currentIndex === 0
|
||||
? qsTr("Сравнение выбранной версии с текущим файлом")
|
||||
: qsTr("Версия с подсветкой отличий от текущего файла")
|
||||
text: qsTr("Версия с подсветкой отличий от текущего файла")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: 10
|
||||
Layout.fillWidth: true
|
||||
@@ -403,7 +377,10 @@ Dialog {
|
||||
text: qsTr("Восстановить эту версию")
|
||||
enabled: root.selectedHash.length>0
|
||||
highlighted: true
|
||||
onClicked: confirmRestore.open()
|
||||
onClicked: {
|
||||
confirmRestore.willDelete = !gitManager.fileExistedAt(root.vaultPath, root.filePath, root.selectedHash)
|
||||
confirmRestore.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,6 +388,7 @@ Dialog {
|
||||
// Подтверждение восстановления
|
||||
Dialog {
|
||||
id: confirmRestore
|
||||
property bool willDelete: false
|
||||
title: qsTr("Восстановить версию?")
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
@@ -421,10 +399,12 @@ Dialog {
|
||||
anchors.fill: parent
|
||||
spacing: 12
|
||||
Label {
|
||||
text: qsTr("Файл будет заменён на выбранную версию и сохранён как новая версия. Вы сможете снова вернуться к текущей версии через историю.")
|
||||
text: confirmRestore.willDelete
|
||||
? qsTr("ВНИМАНИЕ: в выбранной версии этого файла не было — файл будет УДАЛЁН (это тоже сохранится как новая версия, отменить можно через историю).")
|
||||
: qsTr("Файл будет заменён на выбранную версию и сохранён как новая версия. Вы сможете снова вернуться к текущей версии через историю.")
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
color: Theme.text
|
||||
color: confirmRestore.willDelete ? Theme.errorText : Theme.text
|
||||
}
|
||||
Label {
|
||||
text: qsTr("Версия: %1 (%2)").arg(root.selectedHash.substring(0,7)).arg(root.selectedIndex>=0 && historyModel.length>root.selectedIndex ? historyModel[root.selectedIndex].timeStr : "")
|
||||
@@ -437,7 +417,7 @@ Dialog {
|
||||
Item { Layout.fillWidth: true }
|
||||
Button { text: qsTr("Отмена"); onClicked: confirmRestore.close() }
|
||||
Button {
|
||||
text: qsTr("Восстановить")
|
||||
text: confirmRestore.willDelete ? qsTr("Удалить файл") : qsTr("Восстановить")
|
||||
highlighted: true
|
||||
onClicked: {
|
||||
confirmRestore.close()
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Flora
|
||||
|
||||
Dialog {
|
||||
id: root
|
||||
|
||||
// Наведённый вариант (для живой подсказки), иначе — выбранный
|
||||
property string hoveredPreset: ""
|
||||
function presetById(id) {
|
||||
for (let i = 0; i < presets.length; ++i)
|
||||
if (presets[i].id === id) return presets[i]
|
||||
return presets[1]
|
||||
}
|
||||
property var shownPreset: presetById(hoveredPreset.length > 0 ? hoveredPreset : settingsStore.autosavePreset)
|
||||
|
||||
property var presets: [
|
||||
{
|
||||
id: "safe", name: qsTr("Максимум сохранности"),
|
||||
desc: qsTr("На диск — каждые 2 с, версия в историю — после 5 с паузы"),
|
||||
pros: [qsTr("Текст почти невозможно потерять"), qsTr("Подробная история — легко откатить любой момент")],
|
||||
cons: [qsTr("Больше коммитов при долгой печати"), qsTr("Чаще пишет на диск")],
|
||||
meters: [["disk", 5], ["commits", 4], ["load", 4]]
|
||||
},
|
||||
{
|
||||
id: "balanced", name: qsTr("Сбалансированный"),
|
||||
desc: qsTr("На диск — каждые 5 с, версия в историю — после 15 с паузы"),
|
||||
pros: [qsTr("Разумный баланс потерь и чистоты истории"), qsTr("Версии только после паузы — без мусора")],
|
||||
cons: [qsTr("До 5 секунд текста живёт только в памяти")],
|
||||
meters: [["disk", 3], ["commits", 3], ["load", 3]]
|
||||
},
|
||||
{
|
||||
id: "eco", name: qsTr("Экономный"),
|
||||
desc: qsTr("На диск — каждые 10 с, версия в историю — после минуты паузы"),
|
||||
pros: [qsTr("Минимум нагрузки — для слабых дисков и огромных хранилищ"), qsTr("История чистая, только вехи")],
|
||||
cons: [qsTr("До 10 секунд текста живёт только в памяти"), qsTr("Промежуточные мысли в историю не попадут")],
|
||||
meters: [["disk", 2], ["commits", 2], ["load", 2]]
|
||||
},
|
||||
{
|
||||
id: "manual", name: qsTr("Вручную"),
|
||||
desc: qsTr("Без таймеров: Ctrl+S сохраняет на диск и делает версию"),
|
||||
pros: [qsTr("Полный контроль, ноль фоновой работы")],
|
||||
cons: [qsTr("Забыли Ctrl+S — потеряли"), qsTr("История растёт только от ваших рук")],
|
||||
meters: [["disk", 1], ["commits", 1], ["load", 1]]
|
||||
}
|
||||
]
|
||||
|
||||
function meterName(key) {
|
||||
if (key === "disk") return qsTr("Запись на диск")
|
||||
if (key === "commits") return qsTr("Коммиты в историю")
|
||||
return qsTr("Фоновая нагрузка")
|
||||
}
|
||||
|
||||
title: qsTr("Настройки")
|
||||
modal: true
|
||||
width: 640
|
||||
height: 620
|
||||
anchors.centerIn: parent
|
||||
closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside
|
||||
standardButtons: Dialog.Close
|
||||
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
// Подсказка: как читать шкалу
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
color: Theme.bgAlt
|
||||
border.color: Theme.border
|
||||
radius: 8
|
||||
Layout.preferredHeight: hintLabel.implicitHeight + 16
|
||||
Label {
|
||||
id: hintLabel
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
wrapMode: Text.Wrap
|
||||
color: Theme.textSecondary
|
||||
font.pixelSize: 11
|
||||
text: qsTr("Как выбирать: чем меньше галочек — тем проще и легче решение. " +
|
||||
"Берите самое простое, которого вам хватает: обычно это «Сбалансированный», " +
|
||||
"для слабого железа или огромных хранилищ — «Экономный».")
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: qsTr("Автосохранение")
|
||||
font.bold: true
|
||||
color: Theme.text
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
ButtonGroup { id: presetGroup }
|
||||
|
||||
Repeater {
|
||||
model: root.presets
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
height: 52
|
||||
radius: 8
|
||||
color: modelData.id === settingsStore.autosavePreset ? Theme.surfaceSelected
|
||||
: (rowHover.hovered ? Theme.surfaceHover : Theme.bgAlt)
|
||||
border.color: modelData.id === settingsStore.autosavePreset ? Theme.accent : Theme.border
|
||||
border.width: modelData.id === settingsStore.autosavePreset ? 1 : 0
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 8
|
||||
RadioButton {
|
||||
checked: modelData.id === settingsStore.autosavePreset
|
||||
ButtonGroup.group: presetGroup
|
||||
onClicked: settingsStore.autosavePreset = modelData.id
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Label {
|
||||
text: modelData.name
|
||||
font.bold: true
|
||||
color: Theme.text
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Label {
|
||||
text: modelData.desc
|
||||
color: Theme.textMuted
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
HoverHandler {
|
||||
id: rowHover
|
||||
onHoveredChanged: {
|
||||
if (hovered) root.hoveredPreset = modelData.id
|
||||
else if (root.hoveredPreset === modelData.id) root.hoveredPreset = ""
|
||||
}
|
||||
}
|
||||
TapHandler {
|
||||
onTapped: settingsStore.autosavePreset = modelData.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Детали: плюсы/минусы + шкалы выбранного (или наведённого) варианта
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.bgAlt
|
||||
border.color: Theme.border
|
||||
radius: 8
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 6
|
||||
Label {
|
||||
text: root.shownPreset.name
|
||||
font.bold: true
|
||||
color: Theme.text
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Repeater {
|
||||
model: root.shownPreset.pros
|
||||
delegate: Label {
|
||||
required property string modelData
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
color: Theme.successText
|
||||
font.pixelSize: 11
|
||||
text: "+ " + modelData
|
||||
}
|
||||
}
|
||||
Repeater {
|
||||
model: root.shownPreset.cons
|
||||
delegate: Label {
|
||||
required property string modelData
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
color: Theme.errorText
|
||||
font.pixelSize: 11
|
||||
text: "− " + modelData
|
||||
}
|
||||
}
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider }
|
||||
Repeater {
|
||||
model: root.shownPreset.meters
|
||||
delegate: RowLayout {
|
||||
id: meterRow
|
||||
required property var modelData
|
||||
property string meterKey: modelData[0]
|
||||
property int meterLevel: modelData[1]
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Label {
|
||||
text: root.meterName(meterRow.meterKey)
|
||||
color: Theme.textSecondary
|
||||
font.pixelSize: 11
|
||||
Layout.preferredWidth: 140
|
||||
}
|
||||
Row {
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: 5
|
||||
Label {
|
||||
required property int index
|
||||
text: "✓"
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
color: index < meterRow.meterLevel ? Theme.accent : Theme.textFaint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,9 +157,7 @@ Item {
|
||||
background: Rectangle { color: Theme.editorBg }
|
||||
onLinkActivated: (href) => {
|
||||
if (href.startsWith("flora://copycode/")) {
|
||||
const id = href.substring(17)
|
||||
const code = mdParser.getCodeBlock(id)
|
||||
if (code.length > 0) { mdParser.copyToClipboard(code); copyFeedback.visible = true; copyTimer.restart() }
|
||||
if (mdParser.copyCodeBlock(href.substring(17))) { copyFeedback.visible = true; copyTimer.restart() }
|
||||
return
|
||||
}
|
||||
if (href.startsWith("flora://task/")) {
|
||||
|
||||
+26
-17
@@ -658,10 +658,7 @@ Item {
|
||||
|
||||
function handleLink(href) {
|
||||
if (href.startsWith("flora://copycode/")) {
|
||||
const id = href.substring(17)
|
||||
const code = mdParser.getCodeBlock(id)
|
||||
if (code.length > 0) {
|
||||
mdParser.copyToClipboard(code)
|
||||
if (mdParser.copyCodeBlock(href.substring(17))) {
|
||||
copyFeedback.text = qsTr("Скопировано!")
|
||||
copyFeedback.visible = true
|
||||
copyTimer.restart()
|
||||
@@ -785,23 +782,26 @@ Item {
|
||||
}
|
||||
refreshGitStatus()
|
||||
}
|
||||
function armAutoCommit(path) {
|
||||
// Коммит только после паузы в печати (idle), а не на каждое сохранение
|
||||
if (!settingsStore.autocommitEnabled || !fileStore.ready || !path || path.length === 0)
|
||||
return
|
||||
autoCommitDebounce.filePath = path
|
||||
autoCommitDebounce.restart()
|
||||
}
|
||||
Connections {
|
||||
target: documentManager
|
||||
function onActiveIndexChanged() { root.loadContent() }
|
||||
function onContentChanged(idx) { if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent(); refreshGitStatus() }
|
||||
function onContentChanged(idx) {
|
||||
if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent()
|
||||
refreshGitStatus()
|
||||
if (idx >= 0) root.armAutoCommit(documentManager.pathAt(idx))
|
||||
}
|
||||
function onOpenTabsChanged() { root.loadContent() }
|
||||
function onDirtyFlagChanged() { refreshGitStatus() }
|
||||
function onDocumentSaved(idx) {
|
||||
refreshGitStatus()
|
||||
// Авто-коммит для простого пользователя — каждый сохранённый файл попадает в историю
|
||||
if (fileStore.ready && idx >= 0) {
|
||||
const p = documentManager.pathAt(idx)
|
||||
if (p && p.length>0) {
|
||||
// Небольшая задержка чтобы не спамить на каждый автосейв
|
||||
autoCommitDebounce.filePath = p
|
||||
autoCommitDebounce.restart()
|
||||
}
|
||||
}
|
||||
if (idx >= 0) root.armAutoCommit(documentManager.pathAt(idx))
|
||||
}
|
||||
}
|
||||
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
|
||||
@@ -809,9 +809,9 @@ Item {
|
||||
Timer {
|
||||
id: autoCommitDebounce
|
||||
property string filePath: ""
|
||||
interval: 2000
|
||||
interval: settingsStore.autocommitIdleMs
|
||||
onTriggered: {
|
||||
if (fileStore.ready && filePath.length>0) {
|
||||
if (settingsStore.autocommitEnabled && fileStore.ready && filePath.length>0) {
|
||||
gitManager.autoCommit(fileStore.vault, filePath)
|
||||
refreshGitStatus()
|
||||
}
|
||||
@@ -999,7 +999,16 @@ Item {
|
||||
|
||||
TableEditor { id: tableEditor; onMarkdownReady: (md) => { textArea.insert(textArea.cursorPosition, md); documentManager.updateContent(documentManager.activeIndex, textArea.text) } }
|
||||
|
||||
Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } }
|
||||
Shortcut { sequence: "Ctrl+S"; onActivated: {
|
||||
const dirty = []
|
||||
for (let i = 0; i < documentManager.openTabs.length; ++i)
|
||||
if (documentManager.isDirty(i)) dirty.push(documentManager.pathAt(i))
|
||||
documentManager.saveAll()
|
||||
// В ручном режиме Ctrl+S и сохраняет, и коммитит изменённые файлы
|
||||
if (!settingsStore.autosaveEnabled && fileStore.ready)
|
||||
for (const p of dirty) gitManager.autoCommit(fileStore.vault, p)
|
||||
refreshGitStatus()
|
||||
} }
|
||||
Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
|
||||
Shortcut { sequence: "Ctrl+H"; onActivated: {
|
||||
const p = documentManager.pathAt(documentManager.activeIndex)
|
||||
|
||||
@@ -151,6 +151,7 @@ Item {
|
||||
if (drop.source && drop.source.dragPath && fileStore.ready) {
|
||||
const err = fileStore.moveItem(drop.source.dragPath, fileStore.vault)
|
||||
if (err.length > 0) showStatus(err, false)
|
||||
else vaultIndex.syncIncremental()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,6 +283,7 @@ Item {
|
||||
if (!rowRect.isDir || !drop.source || !drop.source.dragPath) return
|
||||
const err = fileStore.moveItem(drop.source.dragPath, rowRect.path)
|
||||
if (err.length > 0) root.showStatus(err, false)
|
||||
else vaultIndex.syncIncremental()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,9 +441,14 @@ Item {
|
||||
if (pendingCreate.kind==="file") err=fileStore.createFile(createIn,name)
|
||||
else if (pendingCreate.kind==="folder") err=fileStore.createFolder(createIn,name)
|
||||
else if (pendingCreate.kind==="rename") err=fileStore.renameItem(renameTarget.path,name)
|
||||
if (err.length>0) showStatus(err,false); else if (pendingCreate.kind!=="rename") showStatus(qsTr("Создано: %1").arg(name), true)
|
||||
if (err.length>0) {
|
||||
showStatus(err,false)
|
||||
} else {
|
||||
if (pendingCreate.kind!=="rename") showStatus(qsTr("Создано: %1").arg(name), true)
|
||||
vaultIndex.syncIncremental()
|
||||
}
|
||||
pendingCreate.parent=""
|
||||
}
|
||||
}
|
||||
ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else showStatus(qsTr("Удалено"), true) } }
|
||||
ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else { showStatus(qsTr("Удалено"), true); vaultIndex.syncIncremental() } } }
|
||||
}
|
||||
|
||||
@@ -10,19 +10,33 @@ Item {
|
||||
property string fileDir: ""
|
||||
property string emptyText: qsTr("Пустой файл — начните писать в режиме редактирования")
|
||||
// Если задан — markdown считается старой версией и подсвечиваются отличия от него
|
||||
// (блоки del — только в markdown, add — только в compareMarkdown)
|
||||
// (блоки del — только в markdown, add — только в compareMarkdown).
|
||||
// Сравнение считается в рабочем потоке, чтобы UI не висел на больших файлах.
|
||||
property string compareMarkdown: ""
|
||||
property int _diffReq: -1
|
||||
implicitHeight: column.implicitHeight + 24
|
||||
|
||||
signal linkActivated(string href)
|
||||
signal diffApplied()
|
||||
|
||||
// Для обновления
|
||||
// Для обновления. Async-ветка идёт через дебаунс: updateDiff() в диалоге меняет
|
||||
// markdown и compareMarkdown по очереди, без дебаунса уходили бы два запроса
|
||||
// (первый заведомо протухает и отбрасывается по _diffReq).
|
||||
Timer { id: asyncDebounce; interval: 80; onTriggered: root._requestDiff() }
|
||||
function _requestDiff() {
|
||||
if (compareMarkdown.length === 0)
|
||||
return // пока таймер ждал, сравнение уже сняли
|
||||
_diffReq = mdParser.parseBlocksWithDiffAsync(markdown, compareMarkdown, vaultRoot, fileDir)
|
||||
}
|
||||
function update() {
|
||||
if (compareMarkdown.length > 0) {
|
||||
asyncDebounce.restart()
|
||||
return
|
||||
}
|
||||
_diffReq = -1 // гасим возможный async в полёте: его ответ станет чужим
|
||||
// Триггерим пересборку блоков
|
||||
blocksModel.clear()
|
||||
const blocks = compareMarkdown.length > 0
|
||||
? mdParser.parseBlocksWithDiff(markdown, compareMarkdown, vaultRoot, fileDir)
|
||||
: mdParser.parseBlocks(markdown, vaultRoot, fileDir)
|
||||
const blocks = mdParser.parseBlocks(markdown, vaultRoot, fileDir)
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
blocksModel.append(blocks[i])
|
||||
}
|
||||
@@ -30,6 +44,29 @@ 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) {
|
||||
if (reqId !== root._diffReq)
|
||||
return // устарел — уже запрошен более свежий
|
||||
blocksModel.clear()
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
blocksModel.append(blocks[i])
|
||||
}
|
||||
root.diffApplied()
|
||||
}
|
||||
}
|
||||
|
||||
onMarkdownChanged: update()
|
||||
onCompareMarkdownChanged: update()
|
||||
onVaultRootChanged: update()
|
||||
@@ -81,11 +118,8 @@ Item {
|
||||
codeId: blockCodeId
|
||||
highlightedText: blockHighlighted
|
||||
onCopyRequested: (id) => {
|
||||
const txt = mdParser.getCodeBlock(id)
|
||||
if (txt.length > 0) {
|
||||
mdParser.copyToClipboard(txt)
|
||||
if (mdParser.copyCodeBlock(id))
|
||||
root.linkActivated("flora://copycode/" + id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 // можно подсветить выбранный день
|
||||
|
||||
|
||||
@@ -26,16 +26,30 @@ Item {
|
||||
target: fileStore
|
||||
function onVaultChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||
function onReadyChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||
// Внешнее изменение (синхронизация/другая программа): досинхронизировать индекс
|
||||
// по mtime — дёшево, без полного рескана
|
||||
function onExternalChanged() { if (fileStore.ready) vaultIndex.syncIncremental() }
|
||||
}
|
||||
Connections {
|
||||
target: documentManager
|
||||
function onActiveIndexChanged() { root.refreshLinksTags() }
|
||||
function onOpenTabsChanged() { root.refreshLinksTags() }
|
||||
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); rebuildDebounce.restart() } }
|
||||
function onDocumentSaved() { rebuildDebounce.restart() }
|
||||
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); indexDebounce.restart() } }
|
||||
// Сохранение обрабатывает C++ (WindowController): сначала индекс, потом база.
|
||||
// Здесь не дублируем, иначе двойной парс файла.
|
||||
}
|
||||
Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } }
|
||||
Timer { id: rebuildDebounce; interval: 800; onTriggered: if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||
// Правка активного файла: обновить только его (один парс), а не всё хранилище
|
||||
Timer {
|
||||
id: indexDebounce
|
||||
interval: 800
|
||||
onTriggered: {
|
||||
if (fileStore.ready && documentManager.activeIndex >= 0) {
|
||||
const p = documentManager.pathAt(documentManager.activeIndex)
|
||||
if (p && p.length > 0) vaultIndex.updateFile(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Делаем панели ресайзабельными внутри правой колонки — увеличенная зона по всей ширине
|
||||
SplitView {
|
||||
|
||||
@@ -5,17 +5,23 @@
|
||||
#include <QQmlComponent>
|
||||
#include <QQmlContext>
|
||||
#include <QQuickWindow>
|
||||
#include <QTimer>
|
||||
|
||||
#include "core/DocumentManager.h"
|
||||
#include "core/FileStore.h"
|
||||
#include "core/VaultIndex.h"
|
||||
#include "core/VaultSettings.h"
|
||||
#include "db/Database.h"
|
||||
#include "markdown/MdParser.h"
|
||||
|
||||
WindowController::WindowController(QQmlApplicationEngine *engine,
|
||||
const QString &mainWindowUrl,
|
||||
MdParser *mdParser,
|
||||
QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_engine(engine)
|
||||
, m_mainWindowUrl(mainWindowUrl)
|
||||
, m_mdParser(mdParser)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -76,9 +82,37 @@ QObject *WindowController::openWindow(const QString &vaultPath)
|
||||
FileStore *store = new FileStore(ctx);
|
||||
DocumentManager *docMgr = new DocumentManager(ctx);
|
||||
VaultIndex *vIndex = new VaultIndex(ctx);
|
||||
VaultSettings *vSettings = new VaultSettings(ctx);
|
||||
Database *db = new Database(ctx);
|
||||
db->setIndex(vIndex);
|
||||
ctx->setContextProperty("fileStore", store);
|
||||
ctx->setContextProperty("documentManager", docMgr);
|
||||
ctx->setContextProperty("vaultIndex", vIndex);
|
||||
ctx->setContextProperty("vaultSettings", vSettings);
|
||||
ctx->setContextProperty("vaultDatabase", db);
|
||||
|
||||
// Сохранение → сначала индекс, потом база (порядок важен: syncFile читает индекс).
|
||||
// Заменяет QML-обработчик onDocumentSaved (иначе двойной парс).
|
||||
connect(docMgr, &DocumentManager::documentSaved, this,
|
||||
[vIndex, db](int, const QString &path) {
|
||||
if (path.isEmpty())
|
||||
return;
|
||||
vIndex->updateFile(path);
|
||||
db->syncFile(path);
|
||||
});
|
||||
// Настройки хранилища → живьём в базу (флаги читаются при каждом запросе).
|
||||
connect(vSettings, &VaultSettings::changed, this, [vSettings, db, store]() {
|
||||
db->setQueryEnabled(vSettings->sqlEnabled());
|
||||
db->setMaxRows(vSettings->sqlMaxRows());
|
||||
store->setJournalDir(vSettings->journalDir());
|
||||
});
|
||||
// Создание/удаление/переименование/внешние изменения и rebuild:
|
||||
// точечно не знаем что — дебаунс и полная переиндексация базы (редко, не на каждую печать).
|
||||
QTimer *dbTimer = new QTimer(ctx);
|
||||
dbTimer->setSingleShot(true);
|
||||
dbTimer->setInterval(1500);
|
||||
connect(vIndex, &VaultIndex::indexChanged, dbTimer, qOverload<>(&QTimer::start));
|
||||
connect(dbTimer, &QTimer::timeout, db, [db]() { db->reindex(); });
|
||||
|
||||
QObject *obj = m_component->create(ctx);
|
||||
if (!obj) {
|
||||
@@ -98,13 +132,17 @@ QObject *WindowController::openWindow(const QString &vaultPath)
|
||||
}
|
||||
|
||||
const QString cleanVault = vaultPath.isEmpty() ? QString() : QDir::cleanPath(vaultPath);
|
||||
m_windows.append({cleanVault, obj, store, docMgr, vIndex, ctx});
|
||||
connect(obj, &QObject::destroyed, this, [this, obj, ctx]() {
|
||||
m_windows.append({cleanVault, obj, store, docMgr, vIndex, vSettings, db, ctx});
|
||||
MdParser *mdParser = m_mdParser;
|
||||
connect(obj, &QObject::destroyed, this, [this, obj, ctx, mdParser]() {
|
||||
for (int i = 0; i < m_windows.size(); ++i) {
|
||||
if (m_windows[i].object == obj) {
|
||||
const QString vault = m_windows[i].vault;
|
||||
Database *closedDb = m_windows[i].database;
|
||||
QQmlContext *c = m_windows[i].context;
|
||||
m_windows.removeAt(i);
|
||||
if (mdParser && closedDb && !vault.isEmpty())
|
||||
mdParser->removeDatabaseForVault(vault, closedDb);
|
||||
if (c)
|
||||
c->deleteLater();
|
||||
emit windowsChanged();
|
||||
@@ -127,8 +165,19 @@ QObject *WindowController::openWindow(const QString &vaultPath)
|
||||
// Устанавливаем через динамическое свойство если поддерживается
|
||||
if (obj->property("destroyOnWindowClose").isValid())
|
||||
obj->setProperty("destroyOnWindowClose", true);
|
||||
if (!cleanVault.isEmpty() && QDir(cleanVault).exists())
|
||||
if (!cleanVault.isEmpty() && QDir(cleanVault).exists()) {
|
||||
store->openVault(cleanVault);
|
||||
// Настройки → журнал/флаги → база → реестр ~sql.
|
||||
// (QML ensureVaultDb мог уже открыть базу по vaultChanged — guard от повтора.)
|
||||
vSettings->open(cleanVault);
|
||||
store->setJournalDir(vSettings->journalDir());
|
||||
db->setQueryEnabled(vSettings->sqlEnabled());
|
||||
db->setMaxRows(vSettings->sqlMaxRows());
|
||||
if ((!db->isOpen() || db->vault() != cleanVault) && db->open(cleanVault)
|
||||
&& mdParser)
|
||||
mdParser->setDatabaseForVault(cleanVault, db);
|
||||
// Индекс подтянет QML (rebuild), базу — дебаунс по indexChanged
|
||||
}
|
||||
|
||||
emit windowsChanged();
|
||||
if (!vaultPath.isEmpty())
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
class FileStore;
|
||||
class DocumentManager;
|
||||
class VaultIndex;
|
||||
class VaultSettings;
|
||||
class Database;
|
||||
class MdParser;
|
||||
|
||||
// Создание окон приложения (мульти-хранилища: новое хранилище — новое окно).
|
||||
class WindowController : public QObject
|
||||
@@ -19,7 +22,7 @@ class WindowController : public QObject
|
||||
|
||||
public:
|
||||
WindowController(QQmlApplicationEngine *engine, const QString &mainWindowUrl,
|
||||
QObject *parent = nullptr);
|
||||
MdParser *mdParser, QObject *parent = nullptr);
|
||||
|
||||
int windowCount() const;
|
||||
QStringList openVaults() const;
|
||||
@@ -41,11 +44,14 @@ private:
|
||||
FileStore *fileStore = nullptr;
|
||||
DocumentManager *docManager = nullptr;
|
||||
VaultIndex *vaultIndex = nullptr;
|
||||
VaultSettings *vaultSettings = nullptr;
|
||||
Database *database = nullptr;
|
||||
QQmlContext *context = nullptr;
|
||||
};
|
||||
|
||||
QQmlApplicationEngine *m_engine;
|
||||
QString m_mainWindowUrl;
|
||||
MdParser *m_mdParser = nullptr; // общий (реестр ~sql-баз по хранилищам)
|
||||
QList<Window> m_windows;
|
||||
QQmlComponent *m_component = nullptr;
|
||||
};
|
||||
@@ -63,6 +63,21 @@ void DocumentManager::setSideBySide(bool on)
|
||||
emit sideBySideChanged();
|
||||
}
|
||||
|
||||
bool DocumentManager::autosaveEnabled() const { return m_autosaveEnabled; }
|
||||
void DocumentManager::setAutosaveEnabled(bool on)
|
||||
{
|
||||
if (m_autosaveEnabled == on)
|
||||
return;
|
||||
m_autosaveEnabled = on;
|
||||
if (m_autosave) {
|
||||
if (on)
|
||||
m_autosave->start();
|
||||
else
|
||||
m_autosave->stop();
|
||||
}
|
||||
emit autosaveEnabledChanged();
|
||||
}
|
||||
|
||||
int DocumentManager::autosaveIntervalMs() const { return m_autosaveMs; }
|
||||
void DocumentManager::setAutosaveIntervalMs(int ms)
|
||||
{
|
||||
@@ -127,8 +142,15 @@ int DocumentManager::openDocument(const QString &path)
|
||||
|
||||
QString DocumentManager::readFile(const QString &path, bool *ok) const
|
||||
{
|
||||
return readTextFile(path, 10LL * 1024 * 1024, ok);
|
||||
}
|
||||
|
||||
QString DocumentManager::readTextFile(const QString &path, qint64 maxBytes, bool *ok)
|
||||
{
|
||||
if (maxBytes <= 0)
|
||||
maxBytes = 10LL * 1024 * 1024;
|
||||
QFileInfo fi(path);
|
||||
if (fi.exists() && fi.size() > 10 * 1024 * 1024) {
|
||||
if (fi.exists() && fi.size() > maxBytes) {
|
||||
if (ok) *ok = false;
|
||||
return QString();
|
||||
}
|
||||
@@ -137,9 +159,9 @@ QString DocumentManager::readFile(const QString &path, bool *ok) const
|
||||
if (ok) *ok = false;
|
||||
return QString();
|
||||
}
|
||||
// Ограничиваем чтение 10 МБ для защиты от OOM
|
||||
const QByteArray bytes = f.read(10 * 1024 * 1024 + 1);
|
||||
if (bytes.size() > 10 * 1024 * 1024) {
|
||||
// Ограничиваем чтение для защиты от OOM
|
||||
const QByteArray bytes = f.read(maxBytes + 1);
|
||||
if (bytes.size() > maxBytes) {
|
||||
if (ok) *ok = false;
|
||||
return QString();
|
||||
}
|
||||
@@ -229,6 +251,24 @@ void DocumentManager::saveAll()
|
||||
saveDocument(i);
|
||||
}
|
||||
|
||||
QString DocumentManager::reloadDocument(int index)
|
||||
{
|
||||
if (index < 0 || index >= m_docs.size())
|
||||
return QStringLiteral("Нет такой вкладки.");
|
||||
Doc &d = m_docs[index];
|
||||
if (d.dirty)
|
||||
return QStringLiteral("Вкладка изменена — сначала сохраните её.");
|
||||
bool ok = false;
|
||||
const QString text = readFile(d.path, &ok);
|
||||
if (!ok)
|
||||
return QStringLiteral("Не удалось прочитать файл.");
|
||||
if (d.content == text)
|
||||
return QString();
|
||||
d.content = text;
|
||||
emit contentChanged(index);
|
||||
return QString();
|
||||
}
|
||||
|
||||
bool DocumentManager::closeTab(int index)
|
||||
{
|
||||
if (index < 0 || index >= m_docs.size())
|
||||
|
||||
@@ -17,6 +17,8 @@ class DocumentManager : public QObject
|
||||
Q_PROPERTY(bool sideBySide READ sideBySide WRITE setSideBySide NOTIFY sideBySideChanged)
|
||||
Q_PROPERTY(int autosaveIntervalMs READ autosaveIntervalMs WRITE setAutosaveIntervalMs
|
||||
NOTIFY autosaveIntervalChanged)
|
||||
Q_PROPERTY(bool autosaveEnabled READ autosaveEnabled WRITE setAutosaveEnabled
|
||||
NOTIFY autosaveEnabledChanged)
|
||||
Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged)
|
||||
|
||||
public:
|
||||
@@ -31,6 +33,8 @@ public:
|
||||
Q_INVOKABLE void setSideBySide(bool on);
|
||||
int autosaveIntervalMs() const;
|
||||
Q_INVOKABLE void setAutosaveIntervalMs(int ms);
|
||||
bool autosaveEnabled() const;
|
||||
Q_INVOKABLE void setAutosaveEnabled(bool on);
|
||||
QString lastStatus() const;
|
||||
|
||||
// Открывает документ (при необходимости создаётся вкладка). Возвращает индекс.
|
||||
@@ -51,6 +55,10 @@ public:
|
||||
Q_INVOKABLE void saveDocument(int index);
|
||||
Q_INVOKABLE void saveAll();
|
||||
|
||||
// Перечитать файл с диска в чистую вкладку (после правки свойств извне
|
||||
// редактора). Грязную вкладку не трогает. Возвращает "" или текст ошибки.
|
||||
Q_INVOKABLE QString reloadDocument(int index);
|
||||
|
||||
Q_INVOKABLE bool closeTab(int index);
|
||||
Q_INVOKABLE void moveTab(int from, int to);
|
||||
|
||||
@@ -60,6 +68,7 @@ signals:
|
||||
void secondaryIndexChanged();
|
||||
void sideBySideChanged();
|
||||
void autosaveIntervalChanged();
|
||||
void autosaveEnabledChanged();
|
||||
void lastStatusChanged();
|
||||
|
||||
// Уведомляет вкладку о смене признака «изменено».
|
||||
@@ -79,11 +88,17 @@ private:
|
||||
void ensureAutosave();
|
||||
QString readFile(const QString &path, bool *ok) const;
|
||||
|
||||
public:
|
||||
// Общее чтение текстового файла с лимитом (защита от OOM). Используется и GitManager.
|
||||
static QString readTextFile(const QString &path, qint64 maxBytes = 10LL * 1024 * 1024,
|
||||
bool *ok = nullptr);
|
||||
|
||||
QList<Doc> m_docs;
|
||||
int m_active = -1;
|
||||
int m_secondary = -1;
|
||||
bool m_sideBySide = false;
|
||||
int m_autosaveMs = 5000;
|
||||
bool m_autosaveEnabled = true;
|
||||
QString m_lastStatus;
|
||||
QTimer *m_autosave = nullptr;
|
||||
};
|
||||
+26
-2
@@ -12,7 +12,7 @@ FileStore::FileStore(QObject *parent)
|
||||
{
|
||||
m_debounce.setSingleShot(true);
|
||||
m_debounce.setInterval(300);
|
||||
connect(&m_debounce, &QTimer::timeout, this, &FileStore::rebuild);
|
||||
connect(&m_debounce, &QTimer::timeout, this, &FileStore::onExternalTimeout);
|
||||
connect(&m_watcher, &QFileSystemWatcher::directoryChanged,
|
||||
this, &FileStore::scheduleRebuild);
|
||||
}
|
||||
@@ -105,6 +105,24 @@ QString FileStore::openVault(const QString &path)
|
||||
return QString();
|
||||
}
|
||||
|
||||
void FileStore::setJournalDir(const QString &d)
|
||||
{
|
||||
// Та же валидация, что в настройках: один сегмент, без выхода наверх
|
||||
QString t = d.trimmed();
|
||||
t.replace(QLatin1Char('\\'), QLatin1Char('/'));
|
||||
while (t.startsWith(QLatin1Char('/')))
|
||||
t.remove(0, 1);
|
||||
while (t.endsWith(QLatin1Char('/')) && t.size() > 1)
|
||||
t.chop(1);
|
||||
if (t.isEmpty() || t == "." || t == ".." || t.contains(QLatin1String(".."))
|
||||
|| t.contains(QLatin1Char('/')) || t.startsWith(QLatin1Char('.')))
|
||||
t = QStringLiteral("Журнал");
|
||||
if (m_journalDir == t)
|
||||
return;
|
||||
m_journalDir = t;
|
||||
emit journalDirChanged();
|
||||
}
|
||||
|
||||
void FileStore::rebuild()
|
||||
{
|
||||
QVector<Row> rows;
|
||||
@@ -223,6 +241,12 @@ void FileStore::scheduleRebuild()
|
||||
m_debounce.start();
|
||||
}
|
||||
|
||||
void FileStore::onExternalTimeout()
|
||||
{
|
||||
rebuild();
|
||||
emit externalChanged();
|
||||
}
|
||||
|
||||
void FileStore::toggleExpand(int row)
|
||||
{
|
||||
if (row < 0 || row >= m_rows.size() || !m_rows[row].isDir)
|
||||
@@ -351,7 +375,7 @@ QString FileStore::dailyNotePath(const QString &dateStr)
|
||||
static const QRegularExpression dateRe(QStringLiteral("^\\d{4}-\\d{2}-\\d{2}$"));
|
||||
if (!dateRe.match(dateStr).hasMatch())
|
||||
return QString();
|
||||
const QString journalDir = QDir::cleanPath(m_vault + "/Журнал");
|
||||
const QString journalDir = QDir::cleanPath(m_vault + QLatin1Char('/') + m_journalDir);
|
||||
if (!isInsideVault(journalDir))
|
||||
return QString();
|
||||
if (!QDir().mkpath(journalDir))
|
||||
|
||||
@@ -13,6 +13,8 @@ class FileStore : public QAbstractListModel
|
||||
|
||||
Q_PROPERTY(QString vault READ vault NOTIFY vaultChanged)
|
||||
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
|
||||
// Папка заметок дня внутри хранилища (из настроек хранилища, по умолчанию «Журнал»).
|
||||
Q_PROPERTY(QString journalDir READ journalDir WRITE setJournalDir NOTIFY journalDirChanged)
|
||||
|
||||
public:
|
||||
enum Roles {
|
||||
@@ -30,6 +32,8 @@ public:
|
||||
|
||||
QString vault() const { return m_vault; }
|
||||
bool ready() const { return !m_vault.isEmpty(); }
|
||||
QString journalDir() const { return m_journalDir; }
|
||||
void setJournalDir(const QString &d);
|
||||
|
||||
QModelIndex index(int row, int column = 0,
|
||||
const QModelIndex &parent = QModelIndex()) const override;
|
||||
@@ -63,8 +67,12 @@ public:
|
||||
signals:
|
||||
void vaultChanged();
|
||||
void readyChanged();
|
||||
void journalDirChanged();
|
||||
void fileActivated(const QString &path);
|
||||
void statusMessage(const QString &message);
|
||||
// Сработало внешнее изменение (синхронизация/другая программа) — дерево уже
|
||||
// перестроено; собственные операции этот сигнал не вызывают.
|
||||
void externalChanged();
|
||||
|
||||
private:
|
||||
struct Row {
|
||||
@@ -78,6 +86,7 @@ private:
|
||||
};
|
||||
|
||||
void rebuild();
|
||||
void onExternalTimeout();
|
||||
void collectDir(const QString &dir, int depth, QVector<Row> &out) const;
|
||||
bool isIgnored(const QString &path) const;
|
||||
int rowOf(const QString &path) const;
|
||||
@@ -90,6 +99,7 @@ private:
|
||||
QString iconFor(const Row &row) const;
|
||||
|
||||
QString m_vault;
|
||||
QString m_journalDir = QStringLiteral("Журнал");
|
||||
QVector<Row> m_rows;
|
||||
QSet<QString> m_expanded;
|
||||
QFileSystemWatcher m_watcher;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#include "core/Frontmatter.h"
|
||||
|
||||
#include <QRegularExpression>
|
||||
|
||||
namespace {
|
||||
|
||||
bool isFenceLine(const QString &t)
|
||||
{
|
||||
return t == QLatin1String("---") || t == QLatin1String("...");
|
||||
}
|
||||
|
||||
QString unquote(const QString &v)
|
||||
{
|
||||
if (v.size() >= 2) {
|
||||
const QChar f = v.front(), l = v.back();
|
||||
if ((f == '"' && l == '"') || (f == '\'' && l == '\''))
|
||||
return v.mid(1, v.size() - 2);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Frontmatter::isKeyValid(const QString &key)
|
||||
{
|
||||
static const QRegularExpression re(QStringLiteral("^[A-Za-z0-9_\\-а-яА-ЯёЁ]+$"));
|
||||
return !key.isEmpty() && key.size() <= 64 && re.match(key).hasMatch();
|
||||
}
|
||||
|
||||
Frontmatter::Parsed Frontmatter::parse(const QString &text)
|
||||
{
|
||||
Parsed out;
|
||||
// body заполняем всегда: без шапки body = весь текст (иначе serialize
|
||||
// по такому parse сотрёт тело файла).
|
||||
out.body = text;
|
||||
const QStringList lines = text.split(QLatin1Char('\n'));
|
||||
if (lines.isEmpty() || lines.first().trimmed() != QLatin1String("---"))
|
||||
return out; // нет шапки: props пустые, body = весь текст
|
||||
int close = -1;
|
||||
for (int i = 1; i < lines.size(); ++i) {
|
||||
if (isFenceLine(lines[i].trimmed())) {
|
||||
close = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (close < 0)
|
||||
return out; // незакрытая шапка — считаем обычным текстом
|
||||
out.hasFrontmatter = true;
|
||||
for (int i = 1; i < close; ++i) {
|
||||
const QString line = lines[i];
|
||||
const QString t = line.trimmed();
|
||||
if (t.isEmpty() || t.startsWith(QLatin1Char('#'))) {
|
||||
if (!t.isEmpty())
|
||||
out.raw.append(line);
|
||||
continue;
|
||||
}
|
||||
const int colon = line.indexOf(QLatin1Char(':'));
|
||||
if (colon <= 0) {
|
||||
out.raw.append(line);
|
||||
continue;
|
||||
}
|
||||
const QString key = line.left(colon).trimmed();
|
||||
if (!isKeyValid(key)) {
|
||||
out.raw.append(line);
|
||||
continue;
|
||||
}
|
||||
out.props.insert(key, unquote(line.mid(colon + 1).trimmed()));
|
||||
}
|
||||
out.body = lines.mid(close + 1).join(QLatin1Char('\n'));
|
||||
return out;
|
||||
}
|
||||
|
||||
QString Frontmatter::serialize(const QVariantMap &props, const QStringList &raw, const QString &body)
|
||||
{
|
||||
QStringList lines;
|
||||
lines.append(QStringLiteral("---"));
|
||||
QStringList keys = props.keys();
|
||||
keys.sort();
|
||||
for (const QString &k : keys) {
|
||||
if (!isKeyValid(k))
|
||||
continue;
|
||||
lines.append(k + QStringLiteral(": ") + props.value(k).toString());
|
||||
}
|
||||
for (const QString &r : raw)
|
||||
lines.append(r);
|
||||
lines.append(QStringLiteral("---"));
|
||||
QString head = lines.join(QLatin1Char('\n'));
|
||||
if (body.isEmpty())
|
||||
return head + QLatin1Char('\n');
|
||||
if (body.startsWith(QLatin1Char('\n')))
|
||||
return head + body;
|
||||
return head + QLatin1Char('\n') + body;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariantMap>
|
||||
|
||||
// YAML-подобная шапка заметки (Notion-свойства, единый источник правды):
|
||||
// ---
|
||||
// статус: в работе
|
||||
// приоритет: 1
|
||||
// ---
|
||||
//
|
||||
// Тело заметки...
|
||||
//
|
||||
// Правила v1: только скалярные `ключ: значение` (строки); комментарии `#`,
|
||||
// пустые строки и непонятые строки сохраняются как есть и не теряются.
|
||||
class Frontmatter
|
||||
{
|
||||
public:
|
||||
struct Parsed {
|
||||
QVariantMap props; // ключ -> QString
|
||||
QStringList raw; // строки, которые не разобрали (комментарии и пр.)
|
||||
QString body; // текст после шапки
|
||||
bool hasFrontmatter = false;
|
||||
};
|
||||
|
||||
static Parsed parse(const QString &text);
|
||||
static QString serialize(const QVariantMap &props, const QStringList &raw, const QString &body);
|
||||
|
||||
static bool isKeyValid(const QString &key);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ constexpr auto kKeyTheme = "ui/theme";
|
||||
constexpr auto kKeyLeftW = "ui/leftPanelWidth";
|
||||
constexpr auto kKeyRightW = "ui/rightPanelWidth";
|
||||
constexpr auto kKeyViewModes = "ui/viewModes";
|
||||
constexpr auto kKeyAutosavePreset = "autosave/preset";
|
||||
|
||||
QString encodeKey(const QString &s) {
|
||||
// Процент-кодирование чтобы избежать интерпретации "/" как разделителя групп в QSettings Ini
|
||||
@@ -276,4 +277,53 @@ void SettingsStore::setViewModeForFile(const QString &vault, const QString &file
|
||||
root.insert(encKey, cleanMode);
|
||||
s.setValue(kKeyViewModes, root);
|
||||
s.sync();
|
||||
}
|
||||
}
|
||||
QString SettingsStore::autosavePreset() const
|
||||
{
|
||||
QSettings s;
|
||||
const QString p = s.value(kKeyAutosavePreset, QStringLiteral("balanced")).toString();
|
||||
if (p == "safe" || p == "balanced" || p == "eco" || p == "manual")
|
||||
return p;
|
||||
return QStringLiteral("balanced");
|
||||
}
|
||||
|
||||
void SettingsStore::setAutosavePreset(const QString &p)
|
||||
{
|
||||
QString clean = p.trimmed();
|
||||
if (clean != "safe" && clean != "balanced" && clean != "eco" && clean != "manual")
|
||||
clean = QStringLiteral("balanced");
|
||||
QSettings s;
|
||||
if (s.value(kKeyAutosavePreset).toString() == clean)
|
||||
return;
|
||||
s.setValue(kKeyAutosavePreset, clean);
|
||||
s.sync();
|
||||
emit autosavePresetChanged();
|
||||
}
|
||||
|
||||
int SettingsStore::autosaveDiskMs() const
|
||||
{
|
||||
const QString p = autosavePreset();
|
||||
if (p == "safe") return 2000;
|
||||
if (p == "eco") return 10000;
|
||||
if (p == "manual") return 0;
|
||||
return 5000; // balanced
|
||||
}
|
||||
|
||||
int SettingsStore::autocommitIdleMs() const
|
||||
{
|
||||
const QString p = autosavePreset();
|
||||
if (p == "safe") return 5000;
|
||||
if (p == "eco") return 60000;
|
||||
if (p == "manual") return 0;
|
||||
return 15000; // balanced
|
||||
}
|
||||
|
||||
bool SettingsStore::autosaveEnabled() const
|
||||
{
|
||||
return autosavePreset() != "manual";
|
||||
}
|
||||
|
||||
bool SettingsStore::autocommitEnabled() const
|
||||
{
|
||||
return autosavePreset() != "manual";
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ class SettingsStore : public QObject
|
||||
Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged)
|
||||
Q_PROPERTY(int leftPanelWidth READ leftPanelWidth WRITE setLeftPanelWidth NOTIFY leftPanelWidthChanged)
|
||||
Q_PROPERTY(int rightPanelWidth READ rightPanelWidth WRITE setRightPanelWidth NOTIFY rightPanelWidthChanged)
|
||||
// Пресет автосохранения: "safe" | "balanced" | "eco" | "manual".
|
||||
// Производные значения читает UI (таймеры) и тесты.
|
||||
Q_PROPERTY(QString autosavePreset READ autosavePreset WRITE setAutosavePreset
|
||||
NOTIFY autosavePresetChanged)
|
||||
Q_PROPERTY(int autosaveDiskMs READ autosaveDiskMs NOTIFY autosavePresetChanged)
|
||||
Q_PROPERTY(int autocommitIdleMs READ autocommitIdleMs NOTIFY autosavePresetChanged)
|
||||
Q_PROPERTY(bool autosaveEnabled READ autosaveEnabled NOTIFY autosavePresetChanged)
|
||||
Q_PROPERTY(bool autocommitEnabled READ autocommitEnabled NOTIFY autosavePresetChanged)
|
||||
|
||||
public:
|
||||
explicit SettingsStore(QObject *parent = nullptr);
|
||||
@@ -43,6 +51,13 @@ public:
|
||||
int rightPanelWidth() const;
|
||||
void setRightPanelWidth(int w);
|
||||
|
||||
QString autosavePreset() const;
|
||||
void setAutosavePreset(const QString &p);
|
||||
int autosaveDiskMs() const;
|
||||
int autocommitIdleMs() const;
|
||||
bool autosaveEnabled() const;
|
||||
bool autocommitEnabled() const;
|
||||
|
||||
Q_INVOKABLE QString viewModeForFile(const QString &vault, const QString &filePath) const;
|
||||
Q_INVOKABLE void setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode);
|
||||
|
||||
@@ -53,6 +68,7 @@ signals:
|
||||
void themeChanged();
|
||||
void leftPanelWidthChanged();
|
||||
void rightPanelWidthChanged();
|
||||
void autosavePresetChanged();
|
||||
|
||||
private:
|
||||
QVariantList m_providers;
|
||||
|
||||
+264
-45
@@ -140,53 +140,11 @@ QString VaultIndex::resolveLink(const QString &fileDir, const QString &target) c
|
||||
return QString();
|
||||
}
|
||||
|
||||
void VaultIndex::rebuild(const QString &vault)
|
||||
void VaultIndex::deduplicateEdges()
|
||||
{
|
||||
if (vault.isEmpty() || !QDir(vault).exists()) {
|
||||
m_nodes.clear();
|
||||
m_edges.clear();
|
||||
m_nodeById.clear();
|
||||
m_tagsOfFile.clear();
|
||||
m_linksOfFile.clear();
|
||||
m_filesWithTag.clear();
|
||||
m_fileByBase.clear();
|
||||
m_fileCount = 0;
|
||||
emit indexChanged();
|
||||
return;
|
||||
}
|
||||
if (m_busy)
|
||||
return;
|
||||
m_busy = true;
|
||||
emit busyChanged();
|
||||
|
||||
m_nodes.clear();
|
||||
m_edges.clear();
|
||||
m_nodeById.clear();
|
||||
m_tagsOfFile.clear();
|
||||
m_linksOfFile.clear();
|
||||
m_filesWithTag.clear();
|
||||
m_fileByBase.clear();
|
||||
m_fileCount = 0;
|
||||
|
||||
// Проход 1: собрать все md-файлы и карту имён
|
||||
QStringList files;
|
||||
collectFiles(vault, 0, files);
|
||||
m_fileCount = files.size();
|
||||
for (const QString &f : files) {
|
||||
const QString base = QFileInfo(f).completeBaseName();
|
||||
const QString key = base.toLower();
|
||||
if (!base.isEmpty() && !m_fileByBase.contains(key))
|
||||
m_fileByBase.insert(key, f);
|
||||
// также храним оригинал для точного поиска
|
||||
if (!base.isEmpty() && !m_fileByBase.contains(base))
|
||||
m_fileByBase.insert(base, f);
|
||||
}
|
||||
// Проход 2: узлы/теги/ссылки
|
||||
for (const QString &f : files)
|
||||
parseFile(f);
|
||||
// Дедупликация рёбер
|
||||
QSet<QString> seen;
|
||||
QVariantList uniq;
|
||||
uniq.reserve(m_edges.size());
|
||||
for (const auto &e : std::as_const(m_edges)) {
|
||||
const QString k = e.toMap().value("from").toString() + "->" + e.toMap().value("to").toString();
|
||||
if (!seen.contains(k)) {
|
||||
@@ -195,12 +153,258 @@ void VaultIndex::rebuild(const QString &vault)
|
||||
}
|
||||
}
|
||||
m_edges = uniq;
|
||||
}
|
||||
|
||||
bool VaultIndex::shouldIndex(const QString &cleanPath) const
|
||||
{
|
||||
if (m_vault.isEmpty())
|
||||
return false;
|
||||
if (cleanPath != m_vault && !cleanPath.startsWith(m_vault + '/'))
|
||||
return false;
|
||||
const QFileInfo fi(cleanPath);
|
||||
if (!fi.exists() || !fi.isFile())
|
||||
return false;
|
||||
if (kIgnoredNames.contains(fi.fileName()))
|
||||
return false;
|
||||
return fi.suffix().compare(QLatin1String("md"), Qt::CaseInsensitive) == 0;
|
||||
}
|
||||
|
||||
VaultIndex::FileMeta VaultIndex::metaOf(const QString &cleanPath)
|
||||
{
|
||||
const QFileInfo fi(cleanPath);
|
||||
return {fi.lastModified(), fi.size()};
|
||||
}
|
||||
|
||||
void VaultIndex::purgeOutgoing(const QString &cleanPath)
|
||||
{
|
||||
// Теги файла
|
||||
const QStringList oldTags = m_tagsOfFile.take(cleanPath);
|
||||
for (const QString &tag : oldTags) {
|
||||
auto it = m_filesWithTag.find(tag);
|
||||
if (it != m_filesWithTag.end()) {
|
||||
it.value().removeAll(cleanPath);
|
||||
if (it.value().isEmpty())
|
||||
m_filesWithTag.erase(it);
|
||||
}
|
||||
}
|
||||
// Исходящие рёбра (входящие от других файлов не трогаем — они всё ещё валидны)
|
||||
const QString key = nodeKeyFor(cleanPath, true);
|
||||
QVariantList kept;
|
||||
kept.reserve(m_edges.size());
|
||||
for (const auto &e : std::as_const(m_edges)) {
|
||||
if (e.toMap().value("from").toString() != key)
|
||||
kept.append(e);
|
||||
}
|
||||
m_edges = kept;
|
||||
m_linksOfFile.remove(cleanPath);
|
||||
m_unresolvedByFile.remove(cleanPath);
|
||||
}
|
||||
|
||||
void VaultIndex::dropNode(const QString &key)
|
||||
{
|
||||
auto it = m_nodeById.find(key);
|
||||
if (it == m_nodeById.end())
|
||||
return;
|
||||
m_nodes.removeAt(it.value());
|
||||
m_nodeById.clear();
|
||||
for (int i = 0; i < m_nodes.size(); ++i)
|
||||
m_nodeById.insert(m_nodes[i].toMap().value("key").toString(), i);
|
||||
}
|
||||
|
||||
void VaultIndex::dropFile(const QString &cleanPath)
|
||||
{
|
||||
const QString key = nodeKeyFor(cleanPath, true);
|
||||
purgeOutgoing(cleanPath);
|
||||
// Входящие рёбра и ссылки других файлов на удалённый — тоже убрать
|
||||
QVariantList kept;
|
||||
kept.reserve(m_edges.size());
|
||||
for (const auto &e : std::as_const(m_edges)) {
|
||||
if (e.toMap().value("to").toString() != key)
|
||||
kept.append(e);
|
||||
}
|
||||
m_edges = kept;
|
||||
for (auto it = m_linksOfFile.begin(); it != m_linksOfFile.end(); ++it) {
|
||||
QVariantList &links = it.value();
|
||||
for (int i = links.size() - 1; i >= 0; --i) {
|
||||
if (links[i].toMap().value("path").toString() == cleanPath)
|
||||
links.removeAt(i);
|
||||
}
|
||||
}
|
||||
dropNode(key);
|
||||
// bybase-записи, указывавшие на удалённый файл
|
||||
for (auto it = m_fileByBase.begin(); it != m_fileByBase.end();) {
|
||||
if (it.value() == cleanPath)
|
||||
it = m_fileByBase.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
m_fileMeta.remove(cleanPath);
|
||||
}
|
||||
|
||||
bool VaultIndex::relinkForNewFile(const QString &newPath)
|
||||
{
|
||||
Q_UNUSED(newPath)
|
||||
// Новый файл мог починить чьи-то висячие ссылки — перепроверяем только их
|
||||
bool any = false;
|
||||
const QStringList files = m_unresolvedByFile.keys();
|
||||
for (const QString &f : files) {
|
||||
if (!m_fileMeta.contains(f)) {
|
||||
m_unresolvedByFile.remove(f);
|
||||
continue;
|
||||
}
|
||||
const QString dir = QFileInfo(f).absolutePath();
|
||||
bool nowOk = false;
|
||||
for (const QString &t : std::as_const(m_unresolvedByFile)[f]) {
|
||||
if (!resolveLink(dir, t).isEmpty()) {
|
||||
nowOk = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nowOk) {
|
||||
purgeOutgoing(f);
|
||||
parseFile(f);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (any)
|
||||
deduplicateEdges();
|
||||
return any;
|
||||
}
|
||||
|
||||
void VaultIndex::rebuild(const QString &vault)
|
||||
{
|
||||
if (vault.isEmpty() || !QDir(vault).exists()) {
|
||||
m_vault.clear();
|
||||
m_nodes.clear();
|
||||
m_edges.clear();
|
||||
m_nodeById.clear();
|
||||
m_tagsOfFile.clear();
|
||||
m_linksOfFile.clear();
|
||||
m_filesWithTag.clear();
|
||||
m_fileByBase.clear();
|
||||
m_unresolvedByFile.clear();
|
||||
m_fileMeta.clear();
|
||||
emit indexChanged();
|
||||
return;
|
||||
}
|
||||
if (m_busy)
|
||||
return;
|
||||
m_busy = true;
|
||||
emit busyChanged();
|
||||
|
||||
m_vault = QDir::cleanPath(vault);
|
||||
m_nodes.clear();
|
||||
m_edges.clear();
|
||||
m_nodeById.clear();
|
||||
m_tagsOfFile.clear();
|
||||
m_linksOfFile.clear();
|
||||
m_filesWithTag.clear();
|
||||
m_fileByBase.clear();
|
||||
m_unresolvedByFile.clear();
|
||||
m_fileMeta.clear();
|
||||
|
||||
// Проход 1: собрать все md-файлы и карту имён
|
||||
QStringList files;
|
||||
collectFiles(m_vault, 0, files);
|
||||
for (const QString &f : files) {
|
||||
const QString clean = QDir::cleanPath(f);
|
||||
const QString base = QFileInfo(clean).completeBaseName();
|
||||
const QString key = base.toLower();
|
||||
if (!base.isEmpty() && !m_fileByBase.contains(key))
|
||||
m_fileByBase.insert(key, clean);
|
||||
// также храним оригинал для точного поиска
|
||||
if (!base.isEmpty() && !m_fileByBase.contains(base))
|
||||
m_fileByBase.insert(base, clean);
|
||||
m_fileMeta.insert(clean, metaOf(clean));
|
||||
}
|
||||
// Проход 2: узлы/теги/ссылки
|
||||
for (const QString &f : files)
|
||||
parseFile(QDir::cleanPath(f));
|
||||
deduplicateEdges();
|
||||
|
||||
m_busy = false;
|
||||
emit busyChanged();
|
||||
emit indexChanged();
|
||||
}
|
||||
|
||||
void VaultIndex::updateFile(const QString &path)
|
||||
{
|
||||
if (m_vault.isEmpty())
|
||||
return;
|
||||
const QString clean = QDir::cleanPath(path);
|
||||
if (!shouldIndex(clean)) {
|
||||
// Удаление / переименование-из / не-md: просто выкинуть из индекса
|
||||
if (m_fileMeta.contains(clean)) {
|
||||
dropFile(clean);
|
||||
emit indexChanged();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const bool isNew = !m_fileMeta.contains(clean);
|
||||
const QStringList oldTags = m_tagsOfFile.value(clean);
|
||||
const QVariantList oldLinks = m_linksOfFile.value(clean);
|
||||
purgeOutgoing(clean);
|
||||
parseFile(clean);
|
||||
deduplicateEdges();
|
||||
m_fileMeta.insert(clean, metaOf(clean));
|
||||
bool changed = isNew || m_tagsOfFile.value(clean) != oldTags
|
||||
|| m_linksOfFile.value(clean) != oldLinks;
|
||||
if (isNew)
|
||||
changed = relinkForNewFile(clean) || changed;
|
||||
if (changed)
|
||||
emit indexChanged();
|
||||
}
|
||||
|
||||
void VaultIndex::removeFile(const QString &path)
|
||||
{
|
||||
if (m_vault.isEmpty())
|
||||
return;
|
||||
const QString clean = QDir::cleanPath(path);
|
||||
if (!m_fileMeta.contains(clean) && !m_nodeById.contains(nodeKeyFor(clean, true)))
|
||||
return;
|
||||
dropFile(clean);
|
||||
emit indexChanged();
|
||||
}
|
||||
|
||||
void VaultIndex::syncIncremental()
|
||||
{
|
||||
if (m_vault.isEmpty() || !QDir(m_vault).exists() || m_busy)
|
||||
return;
|
||||
// Только метаданные (без чтения содержимого) — дёшево даже на тысячах файлов
|
||||
QStringList files;
|
||||
collectFiles(m_vault, 0, files);
|
||||
QSet<QString> seen;
|
||||
seen.reserve(files.size() * 2);
|
||||
bool changed = false;
|
||||
for (const QString &f : files) {
|
||||
const QString clean = QDir::cleanPath(f);
|
||||
seen.insert(clean);
|
||||
auto it = m_fileMeta.constFind(clean);
|
||||
if (it == m_fileMeta.constEnd() || it.value() != metaOf(clean)) {
|
||||
const QStringList oldTags = m_tagsOfFile.value(clean);
|
||||
const QVariantList oldLinks = m_linksOfFile.value(clean);
|
||||
const bool isNew = (it == m_fileMeta.constEnd());
|
||||
purgeOutgoing(clean);
|
||||
parseFile(clean);
|
||||
m_fileMeta.insert(clean, metaOf(clean));
|
||||
changed = changed || isNew || m_tagsOfFile.value(clean) != oldTags
|
||||
|| m_linksOfFile.value(clean) != oldLinks;
|
||||
if (isNew)
|
||||
changed = relinkForNewFile(clean) || changed;
|
||||
}
|
||||
}
|
||||
for (auto it = m_fileMeta.constBegin(); it != m_fileMeta.constEnd(); ++it) {
|
||||
if (!seen.contains(it.key())) {
|
||||
dropFile(it.key());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
deduplicateEdges();
|
||||
emit indexChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void VaultIndex::collectFiles(const QString &dir, int depth, QStringList &out) const
|
||||
{
|
||||
if (depth > 24)
|
||||
@@ -251,8 +455,10 @@ void VaultIndex::parseFile(const QString &path)
|
||||
m_filesWithTag[tag].append(path);
|
||||
}
|
||||
|
||||
// Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki)
|
||||
// Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki).
|
||||
// Неразрешённые цели запоминаем — вдруг нужный файл создадут позже.
|
||||
QVariantList links;
|
||||
QStringList unresolved;
|
||||
static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]"));
|
||||
auto wi = wikiRe.globalMatch(text);
|
||||
while (wi.hasNext()) {
|
||||
@@ -270,6 +476,8 @@ void VaultIndex::parseFile(const QString &path)
|
||||
addEdge(key, nodeKeyFor(resolved, true));
|
||||
ensureNode(nodeKeyFor(resolved, true),
|
||||
QFileInfo(resolved).fileName(), QStringLiteral("file"));
|
||||
} else if (!unresolved.contains(target)) {
|
||||
unresolved.append(target);
|
||||
}
|
||||
}
|
||||
static const QRegularExpression mdLinkRe(
|
||||
@@ -286,9 +494,15 @@ void VaultIndex::parseFile(const QString &path)
|
||||
addEdge(key, nodeKeyFor(resolved, true));
|
||||
ensureNode(nodeKeyFor(resolved, true),
|
||||
QFileInfo(resolved).fileName(), QStringLiteral("file"));
|
||||
} else if (!unresolved.contains(target)) {
|
||||
unresolved.append(target);
|
||||
}
|
||||
}
|
||||
m_linksOfFile.insert(path, links);
|
||||
if (unresolved.isEmpty())
|
||||
m_unresolvedByFile.remove(path);
|
||||
else
|
||||
m_unresolvedByFile.insert(path, unresolved);
|
||||
}
|
||||
|
||||
QStringList VaultIndex::tagsOfFile(const QString &path) const
|
||||
@@ -304,4 +518,9 @@ QVariantList VaultIndex::linksOfFile(const QString &path) const
|
||||
QStringList VaultIndex::filesWithTag(const QString &tag) const
|
||||
{
|
||||
return m_filesWithTag.value(tag);
|
||||
}
|
||||
|
||||
QStringList VaultIndex::indexedFiles() const
|
||||
{
|
||||
return m_fileMeta.keys();
|
||||
}
|
||||
+37
-3
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QObject>
|
||||
#include <QVariantList>
|
||||
#include <QHash>
|
||||
@@ -21,12 +22,22 @@ public:
|
||||
|
||||
QVariantList nodes() const { return m_nodes; }
|
||||
QVariantList edges() const { return m_edges; }
|
||||
int fileCount() const { return m_fileCount; }
|
||||
int fileCount() const { return m_fileMeta.size(); }
|
||||
bool busy() const { return m_busy; }
|
||||
|
||||
// Полное пересканирование хранилища.
|
||||
// Полное пересканирование хранилища (старт, смена хранилища, вручную, внешние изменения).
|
||||
Q_INVOKABLE void rebuild(const QString &vault);
|
||||
|
||||
// Точечно перечитать один файл (создание/сохранение/переименование-в). Дёшево.
|
||||
Q_INVOKABLE void updateFile(const QString &path);
|
||||
|
||||
// Убрать один файл из индекса (удаление/переименование-из).
|
||||
Q_INVOKABLE void removeFile(const QString &path);
|
||||
|
||||
// Сверить список файлов с диском по mtime/size и дочитать только изменённые.
|
||||
// Для внешних изменений (синхронизация, другие программы).
|
||||
Q_INVOKABLE void syncIncremental();
|
||||
|
||||
// Теги файла / ссылки файла (список {text,path} для открытия).
|
||||
Q_INVOKABLE QStringList tagsOfFile(const QString &path) const;
|
||||
Q_INVOKABLE QVariantList linksOfFile(const QString &path) const;
|
||||
@@ -34,6 +45,9 @@ public:
|
||||
// Все файлы с тегом tag.
|
||||
Q_INVOKABLE QStringList filesWithTag(const QString &tag) const;
|
||||
|
||||
// Все проиндексированные .md (для синхронизации базы данных).
|
||||
Q_INVOKABLE QStringList indexedFiles() const;
|
||||
|
||||
Q_INVOKABLE QString displayName(const QString &nodeKey) const;
|
||||
|
||||
signals:
|
||||
@@ -41,13 +55,32 @@ signals:
|
||||
void busyChanged();
|
||||
|
||||
private:
|
||||
struct FileMeta {
|
||||
QDateTime mtime;
|
||||
qint64 size = -1;
|
||||
bool operator==(const FileMeta &o) const { return mtime == o.mtime && size == o.size; }
|
||||
bool operator!=(const FileMeta &o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
QString nodeKeyFor(const QString &path, bool isFile) const;
|
||||
void collectFiles(const QString &dir, int depth, QStringList &out) const;
|
||||
void parseFile(const QString &path);
|
||||
QString resolveLink(const QString &fileDir, const QString &target) const;
|
||||
int ensureNode(const QString &key, const QString &name, const QString &type);
|
||||
void addEdge(const QString &from, const QString &to);
|
||||
void deduplicateEdges();
|
||||
// Файл попадает в индекс?
|
||||
bool shouldIndex(const QString &cleanPath) const;
|
||||
// Снять собственные данные файла (теги/исходящие рёбра/ссылки), узел и входящие — нет.
|
||||
void purgeOutgoing(const QString &cleanPath);
|
||||
// Полностью выкинуть файл: + входящие рёбра/ссылки, узел, bybase, мета.
|
||||
void dropFile(const QString &cleanPath);
|
||||
void dropNode(const QString &key);
|
||||
// Перепарсить файлы, чьи неразрешённые ссылки могли починиться новым файлом.
|
||||
bool relinkForNewFile(const QString &newPath);
|
||||
static FileMeta metaOf(const QString &cleanPath);
|
||||
|
||||
QString m_vault;
|
||||
QVariantList m_nodes;
|
||||
QVariantList m_edges;
|
||||
QHash<QString, int> m_nodeById;
|
||||
@@ -55,6 +88,7 @@ private:
|
||||
QHash<QString, QVariantList> m_linksOfFile;
|
||||
QHash<QString, QStringList> m_filesWithTag;
|
||||
QHash<QString, QString> m_fileByBase; // имя без расширения → путь
|
||||
int m_fileCount = 0;
|
||||
QHash<QString, QStringList> m_unresolvedByFile; // файл → неразрешённые цели ссылок
|
||||
QHash<QString, FileMeta> m_fileMeta; // проиндексированные .md
|
||||
bool m_busy = false;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
#include "core/VaultSettings.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace {
|
||||
|
||||
QString cleanJournalDir(const QString &d)
|
||||
{
|
||||
QString t = d.trimmed();
|
||||
t.replace(QLatin1Char('\\'), QLatin1Char('/'));
|
||||
// только один сегмент, без выхода наверх и служебных имён
|
||||
while (t.startsWith(QLatin1Char('/')))
|
||||
t.remove(0, 1);
|
||||
while (t.endsWith(QLatin1Char('/')) && t.size() > 1)
|
||||
t.chop(1);
|
||||
if (t.isEmpty() || t == "." || t == ".." || t.contains(QLatin1String(".."))
|
||||
|| t.contains(QLatin1Char('/')) || t.startsWith(QLatin1Char('.')))
|
||||
return QStringLiteral("Журнал");
|
||||
return t;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VaultSettings::VaultSettings(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void VaultSettings::setJournalDir(const QString &d)
|
||||
{
|
||||
const QString clean = cleanJournalDir(d);
|
||||
if (m_journalDir == clean)
|
||||
return;
|
||||
m_journalDir = clean;
|
||||
emit changed();
|
||||
}
|
||||
|
||||
void VaultSettings::setSqlEnabled(bool on)
|
||||
{
|
||||
if (m_sqlEnabled == on)
|
||||
return;
|
||||
m_sqlEnabled = on;
|
||||
emit changed();
|
||||
}
|
||||
|
||||
void VaultSettings::setSqlMaxRows(int n)
|
||||
{
|
||||
if (n < 5)
|
||||
n = 5;
|
||||
if (n > 500)
|
||||
n = 500;
|
||||
if (m_sqlMaxRows == n)
|
||||
return;
|
||||
m_sqlMaxRows = n;
|
||||
emit changed();
|
||||
}
|
||||
|
||||
QString VaultSettings::settingsPath() const
|
||||
{
|
||||
if (m_vault.isEmpty())
|
||||
return QString();
|
||||
return QDir(m_vault).filePath(QStringLiteral(".flora/settings.json"));
|
||||
}
|
||||
|
||||
bool VaultSettings::open(const QString &vaultPath)
|
||||
{
|
||||
const QString clean = QDir::cleanPath(vaultPath.trimmed());
|
||||
if (clean.isEmpty() || !QDir(clean).exists())
|
||||
return false;
|
||||
if (m_vault != clean) {
|
||||
m_vault = clean;
|
||||
emit vaultChanged();
|
||||
}
|
||||
resetDefaults();
|
||||
QFile f(settingsPath());
|
||||
if (!f.exists())
|
||||
return true; // нет файла — дефолты, это нормально
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
QJsonParseError perr{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &perr);
|
||||
if (perr.error != QJsonParseError::NoError || !doc.isObject())
|
||||
return false;
|
||||
const QJsonObject o = doc.object();
|
||||
// Через сеттеры — с валидацией и без лишних сигналов
|
||||
setJournalDir(o.value(QStringLiteral("journalDir")).toString(m_journalDir));
|
||||
if (o.contains(QStringLiteral("sqlEnabled")))
|
||||
setSqlEnabled(o.value(QStringLiteral("sqlEnabled")).toBool(m_sqlEnabled));
|
||||
if (o.contains(QStringLiteral("sqlMaxRows")))
|
||||
setSqlMaxRows(o.value(QStringLiteral("sqlMaxRows")).toInt(m_sqlMaxRows));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VaultSettings::save()
|
||||
{
|
||||
if (m_vault.isEmpty())
|
||||
return false;
|
||||
QDir().mkpath(QDir(m_vault).filePath(QStringLiteral(".flora")));
|
||||
QFile f(settingsPath());
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
return false;
|
||||
QJsonObject o;
|
||||
o.insert(QStringLiteral("journalDir"), m_journalDir);
|
||||
o.insert(QStringLiteral("sqlEnabled"), m_sqlEnabled);
|
||||
o.insert(QStringLiteral("sqlMaxRows"), m_sqlMaxRows);
|
||||
f.write(QJsonDocument(o).toJson(QJsonDocument::Indented));
|
||||
return true;
|
||||
}
|
||||
|
||||
void VaultSettings::resetDefaults()
|
||||
{
|
||||
bool touched = false;
|
||||
if (m_journalDir != QStringLiteral("Журнал")) {
|
||||
m_journalDir = QStringLiteral("Журнал");
|
||||
touched = true;
|
||||
}
|
||||
if (!m_sqlEnabled) {
|
||||
m_sqlEnabled = true;
|
||||
touched = true;
|
||||
}
|
||||
if (m_sqlMaxRows != 50) {
|
||||
m_sqlMaxRows = 50;
|
||||
touched = true;
|
||||
}
|
||||
if (touched)
|
||||
emit changed();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
// Настройки одного хранилища (лежат в <vault>/.flora/settings.json,
|
||||
// в git не попадают — как и сама база). Значения по умолчанию = balanced.
|
||||
class VaultSettings : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
Q_PROPERTY(QString vault READ vault NOTIFY vaultChanged)
|
||||
Q_PROPERTY(QString journalDir READ journalDir WRITE setJournalDir NOTIFY changed)
|
||||
Q_PROPERTY(bool sqlEnabled READ sqlEnabled WRITE setSqlEnabled NOTIFY changed)
|
||||
Q_PROPERTY(int sqlMaxRows READ sqlMaxRows WRITE setSqlMaxRows NOTIFY changed)
|
||||
|
||||
public:
|
||||
explicit VaultSettings(QObject *parent = nullptr);
|
||||
|
||||
QString vault() const { return m_vault; }
|
||||
QString journalDir() const { return m_journalDir; }
|
||||
void setJournalDir(const QString &d);
|
||||
bool sqlEnabled() const { return m_sqlEnabled; }
|
||||
void setSqlEnabled(bool on);
|
||||
int sqlMaxRows() const { return m_sqlMaxRows; }
|
||||
void setSqlMaxRows(int n);
|
||||
|
||||
// Открыть настройки хранилища (нет файла — значения по умолчанию).
|
||||
Q_INVOKABLE bool open(const QString &vaultPath);
|
||||
Q_INVOKABLE bool save();
|
||||
Q_INVOKABLE void resetDefaults();
|
||||
|
||||
signals:
|
||||
void vaultChanged();
|
||||
void changed();
|
||||
|
||||
private:
|
||||
QString settingsPath() const;
|
||||
|
||||
QString m_vault;
|
||||
QString m_journalDir = QStringLiteral("Журнал");
|
||||
bool m_sqlEnabled = true;
|
||||
int m_sqlMaxRows = 50;
|
||||
};
|
||||
@@ -0,0 +1,664 @@
|
||||
#include "db/Database.h"
|
||||
|
||||
#include "core/Frontmatter.h"
|
||||
#include "core/VaultIndex.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QSqlError>
|
||||
#include <QSqlQuery>
|
||||
#include <QSqlRecord>
|
||||
#include <QThread>
|
||||
#include <QVariant>
|
||||
#include <QtDebug>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kSchemaVersion = 1;
|
||||
constexpr qint64 kFrontmatterHeadBytes = 8192;
|
||||
constexpr qint64 kPropsFileLimit = 10LL * 1024 * 1024;
|
||||
|
||||
// Сканер: выкидываем комментарии/строки, оставляем «код» для проверки.
|
||||
QString stripSqlNoise(const QString &sql)
|
||||
{
|
||||
QString code;
|
||||
code.reserve(sql.size());
|
||||
const int n = sql.size();
|
||||
bool inStr = false;
|
||||
QChar strCh;
|
||||
bool inLine = false, inBlock = false;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const QChar c = sql[i];
|
||||
if (inLine) {
|
||||
if (c == QLatin1Char('\n'))
|
||||
inLine = false;
|
||||
continue;
|
||||
}
|
||||
if (inBlock) {
|
||||
if (c == QLatin1Char('*') && i + 1 < n && sql[i + 1] == QLatin1Char('/')) {
|
||||
inBlock = false;
|
||||
++i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inStr) {
|
||||
if (c == strCh) {
|
||||
if (i + 1 < n && sql[i + 1] == strCh) {
|
||||
++i; // удвоенная кавычка — экранирование
|
||||
continue;
|
||||
}
|
||||
inStr = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == QLatin1Char('-') && i + 1 < n && sql[i + 1] == QLatin1Char('-')) {
|
||||
inLine = true;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (c == QLatin1Char('/') && i + 1 < n && sql[i + 1] == QLatin1Char('*')) {
|
||||
inBlock = true;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (c == QLatin1Char('\'') || c == QLatin1Char('"') || c == QLatin1Char('`')) {
|
||||
inStr = true;
|
||||
strCh = c;
|
||||
continue;
|
||||
}
|
||||
code += c;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int Database::s_connSeq = 0;
|
||||
|
||||
Database::Database(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
Database::~Database()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
void Database::setQueryEnabled(bool on)
|
||||
{
|
||||
if (m_queryEnabled == on)
|
||||
return;
|
||||
m_queryEnabled = on;
|
||||
emit queryEnabledChanged();
|
||||
}
|
||||
|
||||
void Database::setMaxRows(int n)
|
||||
{
|
||||
if (n < 1)
|
||||
n = 1;
|
||||
if (n > 2000)
|
||||
n = 2000;
|
||||
if (m_maxRows == n)
|
||||
return;
|
||||
m_maxRows = n;
|
||||
emit maxRowsChanged();
|
||||
}
|
||||
|
||||
void Database::setIndex(VaultIndex *index)
|
||||
{
|
||||
m_index = index;
|
||||
}
|
||||
|
||||
bool Database::open(const QString &vaultPath)
|
||||
{
|
||||
close();
|
||||
const QString clean = QDir::cleanPath(vaultPath.trimmed());
|
||||
if (clean.isEmpty() || !QDir(clean).exists())
|
||||
return false;
|
||||
QDir().mkpath(QDir(clean).filePath(QStringLiteral(".flora")));
|
||||
const QString dbPath = QDir(clean).filePath(QStringLiteral(".flora/flora.db"));
|
||||
|
||||
m_connName = QStringLiteral("flora_%1").arg(++s_connSeq);
|
||||
m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connName);
|
||||
m_db.setDatabaseName(dbPath);
|
||||
m_db.setConnectOptions(QStringLiteral("QSQLITE_BUSY_TIMEOUT=5000"));
|
||||
if (!m_db.open()) {
|
||||
qWarning("Database: не открылась %s: %s", qPrintable(dbPath),
|
||||
qPrintable(m_db.lastError().text()));
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
// PRAGMA возвращает строку — вычитываем сразу, иначе висячий стейтмент
|
||||
// будет мешать SAVEPOINT («SQL statements in progress»).
|
||||
{
|
||||
QSqlQuery w(m_db);
|
||||
w.exec(QStringLiteral("PRAGMA journal_mode=WAL"));
|
||||
while (w.next()) { }
|
||||
}
|
||||
if (!ensureSchema()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
m_vault = clean;
|
||||
emit openChanged();
|
||||
// Индекс могли построить до нас (QML rebuild по vaultChanged раньше open) —
|
||||
// тогда сразу забираем готовые данные, иначе ждём indexChanged (дебаунс в окне).
|
||||
if (!m_index.isNull() && !m_index->indexedFiles().isEmpty())
|
||||
reindex();
|
||||
return true;
|
||||
}
|
||||
|
||||
void Database::close()
|
||||
{
|
||||
// Аккуратно: thread-local ro-соединения потоков пула не трогаем (их мало,
|
||||
// живут до конца потоков); именованные соединения уникальны на каждый open.
|
||||
m_vault.clear();
|
||||
m_connName.clear();
|
||||
if (m_db.isValid()) {
|
||||
const QString name = m_db.connectionName();
|
||||
m_db = QSqlDatabase();
|
||||
QSqlDatabase::removeDatabase(name);
|
||||
}
|
||||
emit openChanged();
|
||||
}
|
||||
|
||||
bool Database::ensureSchema()
|
||||
{
|
||||
static const char *kTables[] = {
|
||||
"CREATE TABLE IF NOT EXISTS files ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" path TEXT NOT NULL UNIQUE,"
|
||||
" title TEXT NOT NULL DEFAULT '',"
|
||||
" mtime INTEGER NOT NULL DEFAULT 0,"
|
||||
" size INTEGER NOT NULL DEFAULT 0)",
|
||||
"CREATE TABLE IF NOT EXISTS tags ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" name TEXT NOT NULL UNIQUE)",
|
||||
"CREATE TABLE IF NOT EXISTS file_tags ("
|
||||
" file_id INTEGER NOT NULL,"
|
||||
" tag_id INTEGER NOT NULL,"
|
||||
" UNIQUE(file_id, tag_id))",
|
||||
"CREATE TABLE IF NOT EXISTS links ("
|
||||
" src_id INTEGER NOT NULL,"
|
||||
" dst_id INTEGER NOT NULL,"
|
||||
" UNIQUE(src_id, dst_id))",
|
||||
"CREATE TABLE IF NOT EXISTS properties ("
|
||||
" file_id INTEGER NOT NULL,"
|
||||
" key TEXT NOT NULL,"
|
||||
" value TEXT NOT NULL DEFAULT '',"
|
||||
" UNIQUE(file_id, key))",
|
||||
"CREATE TABLE IF NOT EXISTS meta ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT NOT NULL DEFAULT '')",
|
||||
nullptr
|
||||
};
|
||||
QSqlQuery q(m_db);
|
||||
for (int i = 0; kTables[i]; ++i) {
|
||||
if (!q.exec(QLatin1String(kTables[i]))) {
|
||||
qWarning("Database schema: %s", qPrintable(q.lastError().text()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
q.prepare(QStringLiteral("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)"));
|
||||
q.addBindValue(kSchemaVersion);
|
||||
return q.exec();
|
||||
}
|
||||
|
||||
int Database::fileId(const QString &path, bool create)
|
||||
{
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral("SELECT id FROM files WHERE path = ?"));
|
||||
q.addBindValue(path);
|
||||
if (q.exec() && q.next())
|
||||
return q.value(0).toInt();
|
||||
if (!create)
|
||||
return -1;
|
||||
const QFileInfo fi(path);
|
||||
q.prepare(QStringLiteral("INSERT INTO files (path, title, mtime, size) VALUES (?, ?, ?, ?)"));
|
||||
q.addBindValue(path);
|
||||
q.addBindValue(fi.completeBaseName());
|
||||
q.addBindValue(fi.exists() ? fi.lastModified().toSecsSinceEpoch() : 0);
|
||||
q.addBindValue(fi.exists() ? fi.size() : 0);
|
||||
if (!q.exec())
|
||||
return -1;
|
||||
return q.lastInsertId().toInt();
|
||||
}
|
||||
|
||||
int Database::tagId(const QString &tag, bool create)
|
||||
{
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral("SELECT id FROM tags WHERE name = ?"));
|
||||
q.addBindValue(tag);
|
||||
if (q.exec() && q.next())
|
||||
return q.value(0).toInt();
|
||||
if (!create)
|
||||
return -1;
|
||||
q.prepare(QStringLiteral("INSERT INTO tags (name) VALUES (?)"));
|
||||
q.addBindValue(tag);
|
||||
if (!q.exec())
|
||||
return -1;
|
||||
return q.lastInsertId().toInt();
|
||||
}
|
||||
|
||||
void Database::clearFileData(int fid)
|
||||
{
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral("DELETE FROM links WHERE src_id = ?"));
|
||||
q.addBindValue(fid);
|
||||
q.exec();
|
||||
q.prepare(QStringLiteral("DELETE FROM file_tags WHERE file_id = ?"));
|
||||
q.addBindValue(fid);
|
||||
q.exec();
|
||||
q.prepare(QStringLiteral("DELETE FROM properties WHERE file_id = ?"));
|
||||
q.addBindValue(fid);
|
||||
q.exec();
|
||||
}
|
||||
|
||||
void Database::deleteFileRows(int fid)
|
||||
{
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral("DELETE FROM links WHERE src_id = ? OR dst_id = ?"));
|
||||
q.addBindValue(fid);
|
||||
q.addBindValue(fid);
|
||||
q.exec();
|
||||
q.prepare(QStringLiteral("DELETE FROM file_tags WHERE file_id = ?"));
|
||||
q.addBindValue(fid);
|
||||
q.exec();
|
||||
q.prepare(QStringLiteral("DELETE FROM properties WHERE file_id = ?"));
|
||||
q.addBindValue(fid);
|
||||
q.exec();
|
||||
q.prepare(QStringLiteral("DELETE FROM files WHERE id = ?"));
|
||||
q.addBindValue(fid);
|
||||
q.exec();
|
||||
}
|
||||
|
||||
void Database::deleteOrphanTags()
|
||||
{
|
||||
QSqlQuery q(m_db);
|
||||
q.exec(QStringLiteral("DELETE FROM tags WHERE id NOT IN (SELECT tag_id FROM file_tags)"));
|
||||
}
|
||||
|
||||
void Database::indexOneFile(const QString &path)
|
||||
{
|
||||
const QString clean = QDir::cleanPath(path);
|
||||
const int fid = fileId(clean, true);
|
||||
if (fid < 0)
|
||||
return;
|
||||
const QFileInfo fi(clean);
|
||||
{
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral("UPDATE files SET title = ?, mtime = ?, size = ? WHERE id = ?"));
|
||||
q.addBindValue(fi.completeBaseName());
|
||||
q.addBindValue(fi.lastModified().toSecsSinceEpoch());
|
||||
q.addBindValue(fi.size());
|
||||
q.addBindValue(fid);
|
||||
q.exec();
|
||||
}
|
||||
// Теги и ссылки — из индекса (парсинг markdown не дублируем)
|
||||
if (!m_index.isNull()) {
|
||||
for (const QString &tag : m_index->tagsOfFile(clean)) {
|
||||
const int tid = tagId(tag, true);
|
||||
if (tid < 0)
|
||||
continue;
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral("INSERT OR IGNORE INTO file_tags (file_id, tag_id) VALUES (?, ?)"));
|
||||
q.addBindValue(fid);
|
||||
q.addBindValue(tid);
|
||||
q.exec();
|
||||
}
|
||||
for (const QVariant &lv : m_index->linksOfFile(clean)) {
|
||||
const QString dst = QDir::cleanPath(lv.toMap().value("path").toString());
|
||||
if (dst.isEmpty())
|
||||
continue;
|
||||
const int did = fileId(dst, true);
|
||||
if (did < 0 || did == fid)
|
||||
continue;
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral("INSERT OR IGNORE INTO links (src_id, dst_id) VALUES (?, ?)"));
|
||||
q.addBindValue(fid);
|
||||
q.addBindValue(did);
|
||||
q.exec();
|
||||
}
|
||||
}
|
||||
// Свойства — из frontmatter файла (шапка всегда в начале, читаем первые байты).
|
||||
// Если закрывающего fence в head нет — шапка длиннее head: пропускаем,
|
||||
// чтобы не индексировать оборванное (parse вернёт hasFrontmatter=false).
|
||||
QFile f(clean);
|
||||
if (f.open(QIODevice::ReadOnly)) {
|
||||
const QString head = QString::fromUtf8(f.read(kFrontmatterHeadBytes));
|
||||
const Frontmatter::Parsed fp = Frontmatter::parse(head);
|
||||
QSqlQuery q(m_db);
|
||||
for (auto it = fp.props.constBegin(); it != fp.props.constEnd(); ++it) {
|
||||
q.prepare(QStringLiteral("INSERT OR REPLACE INTO properties (file_id, key, value) VALUES (?, ?, ?)"));
|
||||
q.addBindValue(fid);
|
||||
q.addBindValue(it.key());
|
||||
q.addBindValue(it.value().toString());
|
||||
q.exec();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Database::reindex()
|
||||
{
|
||||
if (!isOpen() || m_index.isNull())
|
||||
return;
|
||||
QSqlQuery q(m_db);
|
||||
// Без транзакции тоже корректно (просто неатомарно) — например, если кто-то
|
||||
// вызовет reindex, не дочитав свой запрос на этом соединении.
|
||||
bool txn = q.exec(QStringLiteral("SAVEPOINT flora_reindex"));
|
||||
if (!txn)
|
||||
qWarning("Database: reindex без транзакции: %s", qPrintable(q.lastError().text()));
|
||||
bool ok = true;
|
||||
ok = ok && q.exec(QStringLiteral("DELETE FROM links"));
|
||||
ok = ok && q.exec(QStringLiteral("DELETE FROM file_tags"));
|
||||
ok = ok && q.exec(QStringLiteral("DELETE FROM properties"));
|
||||
ok = ok && q.exec(QStringLiteral("DELETE FROM tags"));
|
||||
ok = ok && q.exec(QStringLiteral("DELETE FROM files"));
|
||||
if (ok) {
|
||||
for (const QString &f : m_index->indexedFiles())
|
||||
indexOneFile(QDir::cleanPath(f));
|
||||
}
|
||||
if (txn) {
|
||||
if (ok)
|
||||
q.exec(QStringLiteral("RELEASE flora_reindex"));
|
||||
else
|
||||
q.exec(QStringLiteral("ROLLBACK TO flora_reindex"));
|
||||
}
|
||||
emit changed();
|
||||
}
|
||||
|
||||
void Database::syncFile(const QString &path)
|
||||
{
|
||||
if (!isOpen() || m_index.isNull())
|
||||
return;
|
||||
const QString clean = QDir::cleanPath(path.trimmed());
|
||||
if (clean.isEmpty())
|
||||
return;
|
||||
if (!QFileInfo::exists(clean)) {
|
||||
syncRemove(clean);
|
||||
return;
|
||||
}
|
||||
if (!m_index->indexedFiles().contains(clean)) {
|
||||
reindex(); // индекс ещё не знает файл (порядок вызовов) — проще целиком
|
||||
return;
|
||||
}
|
||||
const int fid = fileId(clean, false);
|
||||
if (fid >= 0)
|
||||
clearFileData(fid);
|
||||
// строки нет (например, после ручной чистки) — создастся в indexOneFile
|
||||
indexOneFile(clean);
|
||||
deleteOrphanTags();
|
||||
emit changed();
|
||||
}
|
||||
|
||||
void Database::syncRemove(const QString &path)
|
||||
{
|
||||
if (!isOpen())
|
||||
return;
|
||||
const QString clean = QDir::cleanPath(path.trimmed());
|
||||
if (clean.isEmpty())
|
||||
return;
|
||||
const int fid = fileId(clean, false);
|
||||
if (fid < 0)
|
||||
return;
|
||||
deleteFileRows(fid);
|
||||
deleteOrphanTags();
|
||||
emit changed();
|
||||
}
|
||||
|
||||
bool Database::isReadOnlySql(const QString &sql, QString *error)
|
||||
{
|
||||
const QString code = stripSqlNoise(sql);
|
||||
static const QRegularExpression firstWord(QStringLiteral("^\\s*(\\w+)"));
|
||||
const auto m = firstWord.match(code);
|
||||
if (!m.hasMatch()) {
|
||||
if (error)
|
||||
*error = QStringLiteral("Пустой запрос");
|
||||
return false;
|
||||
}
|
||||
const QString first = m.captured(1).toLower();
|
||||
if (first != QLatin1String("select") && first != QLatin1String("with")
|
||||
&& first != QLatin1String("values")) {
|
||||
if (error)
|
||||
*error = QStringLiteral("Разрешены только читающие запросы (SELECT / WITH). "
|
||||
"Изменение базы из заметок запрещено.");
|
||||
return false;
|
||||
}
|
||||
static const QRegularExpression banned(
|
||||
QStringLiteral("\\b(insert|update|delete|drop|alter|create|attach|detach|replace|"
|
||||
"vacuum|pragma|reindex|savepoint|transaction|analyze|copy)\\b"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
if (banned.match(code).hasMatch()) {
|
||||
if (error)
|
||||
*error = QStringLiteral("Изменяющие запросы запрещены — только чтение.");
|
||||
return false;
|
||||
}
|
||||
QString body = code.trimmed();
|
||||
if (body.endsWith(QLatin1Char(';')))
|
||||
body.chop(1);
|
||||
if (body.contains(QLatin1Char(';'))) {
|
||||
if (error)
|
||||
*error = QStringLiteral("Один блок — один запрос (без «;» внутри).");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QSqlDatabase Database::threadRoDb() const
|
||||
{
|
||||
// Соединения QSqlDatabase привязаны к потоку: у каждого потока своё
|
||||
// read-only соединение (diff считается в пуле потоков).
|
||||
thread_local QSqlDatabase ro;
|
||||
thread_local QString roPath;
|
||||
if (ro.isValid() && ro.isOpen() && roPath == m_vault && !m_vault.isEmpty())
|
||||
return ro;
|
||||
// Новое хранилище/поток — своё соединение с уникальным именем
|
||||
thread_local int seq = 0;
|
||||
const QString name = QStringLiteral("flora_ro_%1_%2_%3")
|
||||
.arg(QString::number(qHash(m_vault), 16))
|
||||
.arg((quintptr)QThread::currentThreadId())
|
||||
.arg(++seq);
|
||||
ro = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name);
|
||||
roPath.clear();
|
||||
if (m_vault.isEmpty())
|
||||
return ro;
|
||||
// Read-only силами самого SQLite (mode=ro в URI), а не опцией драйвера —
|
||||
// работает во всех сборках Qt.
|
||||
const QString dbPath = QDir(m_vault).filePath(QStringLiteral(".flora/flora.db"));
|
||||
ro.setDatabaseName(QStringLiteral("file:%1?mode=ro").arg(dbPath));
|
||||
ro.setConnectOptions(QStringLiteral("QSQLITE_OPEN_URI;QSQLITE_BUSY_TIMEOUT=5000"));
|
||||
if (!ro.open())
|
||||
return ro;
|
||||
roPath = m_vault;
|
||||
// Пояс поверх mode=ro: запрещаем запись на этом соединении вообще.
|
||||
{
|
||||
QSqlQuery q(ro);
|
||||
q.exec(QStringLiteral("PRAGMA query_only = ON"));
|
||||
while (q.next()) { }
|
||||
}
|
||||
// Зонд: пробная запись обязана упасть. (BEGIN IMMEDIATE для зонда не годится —
|
||||
// на read-only соединении он может успешно проходить.)
|
||||
{
|
||||
QSqlQuery probe(ro);
|
||||
if (probe.exec(QStringLiteral("CREATE TABLE __flora_ro_probe (x)"))) {
|
||||
probe.exec(QStringLiteral("DROP TABLE __flora_ro_probe"));
|
||||
qWarning("Database: read-only соединение пишет! Пишущие запросы всё равно режутся проверкой текста.");
|
||||
}
|
||||
}
|
||||
return ro;
|
||||
}
|
||||
|
||||
QVariantMap Database::queryTable(const QString &sql)
|
||||
{
|
||||
QVariantMap out;
|
||||
out.insert("columns", QStringList());
|
||||
out.insert("rows", QVariantList());
|
||||
out.insert("error", QString());
|
||||
out.insert("truncated", false);
|
||||
if (!m_queryEnabled) {
|
||||
out["error"] = tr("SQL-блоки выключены в настройках хранилища.");
|
||||
return out;
|
||||
}
|
||||
if (!isOpen()) {
|
||||
out["error"] = tr("База данных закрыта.");
|
||||
return out;
|
||||
}
|
||||
QString guardErr;
|
||||
if (!isReadOnlySql(sql, &guardErr)) {
|
||||
out["error"] = guardErr;
|
||||
return out;
|
||||
}
|
||||
QSqlDatabase ro = threadRoDb();
|
||||
if (!ro.isOpen()) {
|
||||
out["error"] = tr("Не удалось открыть базу для чтения: %1").arg(ro.lastError().text());
|
||||
return out;
|
||||
}
|
||||
QSqlQuery q(ro);
|
||||
q.setForwardOnly(true);
|
||||
if (!q.exec(sql)) {
|
||||
out["error"] = tr("Ошибка запроса: %1").arg(q.lastError().text());
|
||||
return out;
|
||||
}
|
||||
const QSqlRecord rec = q.record();
|
||||
if (rec.count() == 0) {
|
||||
out["error"] = tr("Запрос ничего не вернул.");
|
||||
return out;
|
||||
}
|
||||
const int ncols = qMin(rec.count(), 64);
|
||||
QStringList cols;
|
||||
for (int c = 0; c < ncols; ++c) {
|
||||
const QString n = rec.fieldName(c);
|
||||
cols.append(n.isEmpty() ? QStringLiteral("col%1").arg(c + 1) : n);
|
||||
}
|
||||
QVariantList rows;
|
||||
int n = 0;
|
||||
bool truncated = false;
|
||||
while (q.next()) {
|
||||
if (++n > m_maxRows) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
QVariantList row;
|
||||
for (int c = 0; c < ncols; ++c)
|
||||
row.append(q.isNull(c) ? QStringLiteral("NULL") : q.value(c).toString());
|
||||
// QVariant(...) обязателен: иначе вызывается QList::append(const QList&)
|
||||
// и строка сплющивается в плоский список ячеек.
|
||||
rows.append(QVariant(row));
|
||||
}
|
||||
out["columns"] = cols;
|
||||
out["rows"] = rows;
|
||||
out["truncated"] = truncated;
|
||||
return out;
|
||||
}
|
||||
|
||||
QVariantMap Database::stats()
|
||||
{
|
||||
QVariantMap out;
|
||||
auto count = [&](const char *table) {
|
||||
QSqlQuery q(m_db);
|
||||
if (q.exec(QString::fromLatin1("SELECT COUNT(*) FROM ") + QLatin1String(table)) && q.next())
|
||||
return q.value(0).toInt();
|
||||
return 0;
|
||||
};
|
||||
if (isOpen()) {
|
||||
out.insert("files", count("files"));
|
||||
out.insert("tags", count("tags"));
|
||||
out.insert("links", count("links"));
|
||||
out.insert("properties", count("properties"));
|
||||
out.insert("dbSize", QFileInfo(QDir(m_vault).filePath(QStringLiteral(".flora/flora.db"))).size());
|
||||
} else {
|
||||
out.insert("files", 0);
|
||||
out.insert("tags", 0);
|
||||
out.insert("links", 0);
|
||||
out.insert("properties", 0);
|
||||
out.insert("dbSize", 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
QString Database::filePathForTitle(const QString &title)
|
||||
{
|
||||
const QString t = title.trimmed();
|
||||
if (!isOpen() || t.isEmpty())
|
||||
return QString();
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral("SELECT path FROM files WHERE title = ? LIMIT 2"));
|
||||
q.addBindValue(t);
|
||||
if (!q.exec())
|
||||
return QString();
|
||||
QString found;
|
||||
int n = 0;
|
||||
while (q.next()) {
|
||||
found = q.value(0).toString();
|
||||
if (++n > 1)
|
||||
return QString(); // неоднозначно
|
||||
}
|
||||
return n == 1 ? found : QString();
|
||||
}
|
||||
|
||||
QVariantMap Database::fileProperties(const QString &path)
|
||||
{
|
||||
QVariantMap out;
|
||||
if (!isOpen())
|
||||
return out;
|
||||
const int fid = fileId(QDir::cleanPath(path), false);
|
||||
if (fid < 0)
|
||||
return out;
|
||||
QSqlQuery q(m_db);
|
||||
q.prepare(QStringLiteral("SELECT key, value FROM properties WHERE file_id = ? ORDER BY key"));
|
||||
q.addBindValue(fid);
|
||||
if (q.exec()) {
|
||||
while (q.next())
|
||||
out.insert(q.value(0).toString(), q.value(1).toString());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
QString Database::saveFileProperties(const QString &path, const QVariantMap &props)
|
||||
{
|
||||
const QString clean = QDir::cleanPath(path.trimmed());
|
||||
if (clean.isEmpty())
|
||||
return tr("Пустой путь.");
|
||||
QFileInfo fi(clean);
|
||||
if (!fi.exists() || !fi.isFile())
|
||||
return tr("Файл не найден.");
|
||||
if (fi.size() > kPropsFileLimit)
|
||||
return tr("Файл слишком большой для свойств.");
|
||||
// Проверяем ключи до записи — мусор в шапку не пишем
|
||||
for (auto it = props.constBegin(); it != props.constEnd(); ++it) {
|
||||
if (!Frontmatter::isKeyValid(it.key()))
|
||||
return tr("Плохое имя свойства: %1 (буквы, цифры, _, -)").arg(it.key());
|
||||
}
|
||||
QFile f(clean);
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return tr("Не удалось прочитать файл.");
|
||||
const QString text = QString::fromUtf8(f.readAll());
|
||||
f.close();
|
||||
const Frontmatter::Parsed fp = Frontmatter::parse(text);
|
||||
const QString newText = Frontmatter::serialize(props, fp.raw, fp.body);
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
return tr("Не удалось записать файл.");
|
||||
f.write(newText.toUtf8());
|
||||
return QString(); // ок — индекс и базу обновит вызывающий (updateFile/syncFile)
|
||||
}
|
||||
|
||||
QString Database::schemaHelp()
|
||||
{
|
||||
return QStringLiteral(
|
||||
"Таблицы базы хранилища (только чтение, SELECT / WITH):\n"
|
||||
"• files(path, title, mtime, size) — заметки\n"
|
||||
"• tags(name) — теги; file_tags(file_id, tag_id) — чьи\n"
|
||||
"• links(src_id, dst_id) — id файлов из files\n"
|
||||
"• properties(file_id, key, value) — свойства из шапки --- ... ---\n"
|
||||
"\n"
|
||||
"Примеры:\n"
|
||||
"SELECT title, path FROM files ORDER BY title;\n"
|
||||
"SELECT t.name, COUNT(*) AS n FROM tags t\n"
|
||||
" JOIN file_tags ft ON ft.tag_id = t.id GROUP BY t.name;\n"
|
||||
"SELECT p.key, p.value FROM properties p\n"
|
||||
" JOIN files f ON f.id = p.file_id WHERE f.path LIKE '%inbox%';");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariantList>
|
||||
#include <QVariantMap>
|
||||
#include <QSqlDatabase>
|
||||
|
||||
class VaultIndex;
|
||||
|
||||
// Notion-подобная база хранилища (.flora/flora.db, SQLite):
|
||||
// файлы, теги, связи, свойства из frontmatter. Источник правды для
|
||||
// свойств — frontmatter в самих .md (переносимо, видно в git);
|
||||
// база — быстрый индекс + движок для ~sql-блоков.
|
||||
class Database : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
Q_PROPERTY(bool isOpen READ isOpen NOTIFY openChanged)
|
||||
Q_PROPERTY(QString vault READ vault NOTIFY openChanged)
|
||||
Q_PROPERTY(bool queryEnabled READ queryEnabled WRITE setQueryEnabled NOTIFY queryEnabledChanged)
|
||||
Q_PROPERTY(int maxRows READ maxRows WRITE setMaxRows NOTIFY maxRowsChanged)
|
||||
|
||||
public:
|
||||
explicit Database(QObject *parent = nullptr);
|
||||
~Database() override;
|
||||
|
||||
bool isOpen() const { return m_db.isValid() && m_db.isOpen(); }
|
||||
QString vault() const { return m_vault; }
|
||||
bool queryEnabled() const { return m_queryEnabled; }
|
||||
void setQueryEnabled(bool on);
|
||||
int maxRows() const { return m_maxRows; }
|
||||
void setMaxRows(int n);
|
||||
|
||||
// Индекс для подтягивания тегов/ссылок (парсинг markdown не дублируем).
|
||||
void setIndex(VaultIndex *index);
|
||||
|
||||
Q_INVOKABLE bool open(const QString &vaultPath);
|
||||
Q_INVOKABLE void close();
|
||||
|
||||
// Полная переиндексация из VaultIndex. Вызывать после rebuild индекса.
|
||||
Q_INVOKABLE void reindex();
|
||||
// Точечно: файл создан/сохранён/переименован-в (нет файла — как удаление).
|
||||
Q_INVOKABLE void syncFile(const QString &path);
|
||||
// Файл удалён/переименован-из.
|
||||
Q_INVOKABLE void syncRemove(const QString &path);
|
||||
|
||||
// Только SELECT/WITH, на read-only соединении, с лимитом строк.
|
||||
// Возвращает {columns:[...], rows:[[cell...]], error:"", truncated:bool}.
|
||||
// Ячейки — строки, NULL как "NULL".
|
||||
Q_INVOKABLE QVariantMap queryTable(const QString &sql);
|
||||
|
||||
Q_INVOKABLE QVariantMap stats();
|
||||
// Путь файла по названию (title = имя без расширения). Пусто если нет
|
||||
// или несколько (неоднозначно — ссылку не строим).
|
||||
Q_INVOKABLE QString filePathForTitle(const QString &title);
|
||||
Q_INVOKABLE QVariantMap fileProperties(const QString &path);
|
||||
Q_INVOKABLE QString saveFileProperties(const QString &path, const QVariantMap &props);
|
||||
|
||||
// Схема для подсказок в UI (какие таблицы/колонки можно спрашивать).
|
||||
static QString schemaHelp();
|
||||
|
||||
signals:
|
||||
void openChanged();
|
||||
void queryEnabledChanged();
|
||||
void maxRowsChanged();
|
||||
void changed(); // данные изменились (для обновления статистики и т.п.)
|
||||
|
||||
private:
|
||||
bool ensureSchema();
|
||||
int fileId(const QString &path, bool create);
|
||||
int tagId(const QString &tag, bool create);
|
||||
void deleteFileRows(int fileId);
|
||||
// Снести только собственные данные файла (исходящие ссылки, теги, свойства).
|
||||
// Строка files и входящие ссылки целы — id сохраняется, граф не рвётся.
|
||||
void clearFileData(int fileId);
|
||||
void deleteOrphanTags();
|
||||
void indexOneFile(const QString &path);
|
||||
static bool isReadOnlySql(const QString &sql, QString *error);
|
||||
QSqlDatabase threadRoDb() const; // read-only соединение текущего потока
|
||||
|
||||
QString m_vault;
|
||||
QSqlDatabase m_db;
|
||||
QPointer<VaultIndex> m_index;
|
||||
bool m_queryEnabled = true;
|
||||
int m_maxRows = 50;
|
||||
QString m_connName;
|
||||
static int s_connSeq;
|
||||
};
|
||||
+178
-190
@@ -1,5 +1,7 @@
|
||||
#include "git/GitManager.h"
|
||||
|
||||
#include "core/DocumentManager.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
@@ -41,23 +43,6 @@ QString timeAgoString(const QDateTime &dt)
|
||||
return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm"));
|
||||
}
|
||||
|
||||
QString escHtml(const QString &s)
|
||||
{
|
||||
QString out;
|
||||
out.reserve(s.size());
|
||||
for (const QChar &c : s) {
|
||||
switch (c.unicode()) {
|
||||
case '&': out += "&"; break;
|
||||
case '<': out += "<"; break;
|
||||
case '>': out += ">"; break;
|
||||
case '"': out += """; break;
|
||||
case '\'': out += "'"; break;
|
||||
default: out += c; break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool GitManager::s_gitInitialized = false;
|
||||
@@ -205,29 +190,13 @@ QString GitManager::fileGitStatus(const QString &vaultPath, const QString &fileP
|
||||
if (vaultPath.isEmpty() || filePath.isEmpty())
|
||||
return QStringLiteral("—");
|
||||
const QString vault = QDir::cleanPath(vaultPath);
|
||||
const QString file = QDir::cleanPath(filePath);
|
||||
if (!file.startsWith(vault + '/') && file != vault)
|
||||
const QString rel = relativePath(vaultPath, filePath);
|
||||
if (rel.isEmpty())
|
||||
return QStringLiteral("—");
|
||||
git_repository *repo = nullptr;
|
||||
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
|
||||
return QStringLiteral("—");
|
||||
|
||||
// относительный путь внутри репозитория
|
||||
QString rel = file;
|
||||
if (rel == vault) {
|
||||
git_repository_free(repo);
|
||||
return QStringLiteral("—");
|
||||
}
|
||||
if (rel.startsWith(vault)) {
|
||||
rel = rel.mid(vault.length());
|
||||
while (rel.startsWith("/") || rel.startsWith("\\"))
|
||||
rel.remove(0, 1);
|
||||
}
|
||||
if (rel.isEmpty()) {
|
||||
git_repository_free(repo);
|
||||
return QStringLiteral("—");
|
||||
}
|
||||
|
||||
unsigned int status = 0;
|
||||
const int rc = git_status_file(&status, repo, rel.toUtf8().constData());
|
||||
git_repository_free(repo);
|
||||
@@ -269,6 +238,73 @@ QString GitManager::fileGitStatusShort(const QString &vaultPath, const QString &
|
||||
return s;
|
||||
}
|
||||
|
||||
bool GitManager::commitFromIndex(git_repository *repo, git_index *index, const QString &message)
|
||||
{
|
||||
// Не проверяем entrycount==0 отдельно — пустой индекс может означать удаление всех файлов,
|
||||
// что тоже нужно коммитить. Проверяем равенство дерева ниже.
|
||||
|
||||
// Создаём дерево
|
||||
git_oid treeId{};
|
||||
git_tree *tree = nullptr;
|
||||
if (git_index_write_tree(&treeId, index) < 0
|
||||
|| git_tree_lookup(&tree, repo, &treeId) < 0) {
|
||||
setLastError(QStringLiteral("Не удалось создать дерево: %1").arg(gitErr()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Родитель: head, если коммиты уже есть
|
||||
git_reference *headRef = nullptr;
|
||||
git_commit *parentCommit = nullptr;
|
||||
int nParents = 0;
|
||||
if (git_repository_head(&headRef, repo) == 0) {
|
||||
git_object *obj = nullptr;
|
||||
if (git_reference_peel(&obj, headRef, GIT_OBJECT_COMMIT) == 0) {
|
||||
parentCommit = reinterpret_cast<git_commit *>(obj);
|
||||
nParents = 1;
|
||||
}
|
||||
if (headRef) git_reference_free(headRef);
|
||||
}
|
||||
|
||||
// Если дерево не изменилось относительно родителя — коммитить нечего.
|
||||
if (parentCommit) {
|
||||
const git_oid *prevTree = git_commit_tree_id(parentCommit);
|
||||
if (prevTree && git_oid_equal(prevTree, &treeId)) {
|
||||
git_commit_free(parentCommit);
|
||||
git_tree_free(tree);
|
||||
setLastError(QStringLiteral("Нет изменений для коммита"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const git_commit *parents[1] = {parentCommit};
|
||||
|
||||
// Подпись из конфига репозитория
|
||||
git_signature *author = nullptr;
|
||||
if (git_signature_default(&author, repo) < 0) {
|
||||
git_commit_free(parentCommit);
|
||||
git_tree_free(tree);
|
||||
setLastError(QStringLiteral("Не настроена git-личность: %1").arg(gitErr()));
|
||||
return false;
|
||||
}
|
||||
|
||||
git_oid commitId{};
|
||||
const int crc = git_commit_create(&commitId, repo, "HEAD",
|
||||
author, author, "UTF-8",
|
||||
message.toUtf8().constData(),
|
||||
tree, nParents, parents);
|
||||
|
||||
git_signature_free(author);
|
||||
if (parentCommit) git_commit_free(parentCommit);
|
||||
git_tree_free(tree);
|
||||
|
||||
if (crc < 0) {
|
||||
setLastError(QStringLiteral("Не удалось создать коммит: %1").arg(gitErr()));
|
||||
return false;
|
||||
}
|
||||
|
||||
setLastError(QString());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GitManager::commitAll(const QString &path, const QString &message)
|
||||
{
|
||||
const QString cleanPath = QDir::cleanPath(path);
|
||||
@@ -285,78 +321,69 @@ bool GitManager::commitAll(const QString &path, const QString &message)
|
||||
|
||||
// Добавляем все файлы, включая новые и удаления (с учётом .gitignore)
|
||||
git_index_add_all(info.index, nullptr, GIT_INDEX_ADD_DEFAULT, nullptr, nullptr);
|
||||
const int wrc = git_index_write(info.index);
|
||||
if (wrc < 0) {
|
||||
if (git_index_write(info.index) < 0) {
|
||||
setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Не проверяем entrycount==0 отдельно — пустой индекс может означать удаление всех файлов,
|
||||
// что тоже нужно коммитить. Проверяем равенство дерева ниже.
|
||||
|
||||
// Создаём дерево
|
||||
git_tree *tree = nullptr;
|
||||
if (git_index_write_tree(&info.treeId, info.index) < 0
|
||||
|| git_tree_lookup(&tree, info.repo, &info.treeId) < 0) {
|
||||
setLastError(QStringLiteral("Не удалось создать дерево: %1").arg(gitErr()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Родитель: head, если коммиты уже есть
|
||||
git_reference *headRef = nullptr;
|
||||
git_commit *parentCommit = nullptr;
|
||||
int nParents = 0;
|
||||
if (git_repository_head(&headRef, info.repo) == 0) {
|
||||
git_object *obj = nullptr;
|
||||
if (git_reference_peel(&obj, headRef, GIT_OBJECT_COMMIT) == 0) {
|
||||
parentCommit = reinterpret_cast<git_commit *>(obj);
|
||||
nParents = 1;
|
||||
}
|
||||
if (headRef) git_reference_free(headRef);
|
||||
}
|
||||
|
||||
// Если дерево не изменилось относительно родителя — коммитить нечего.
|
||||
if (parentCommit) {
|
||||
const git_oid *prevTree = git_commit_tree_id(parentCommit);
|
||||
if (prevTree && git_oid_equal(prevTree, &info.treeId)) {
|
||||
git_commit_free(parentCommit);
|
||||
git_tree_free(tree);
|
||||
setLastError(QStringLiteral("Нет изменений для коммита"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const git_commit *parents[1] = {parentCommit};
|
||||
|
||||
// Подпись из конфига репозитория
|
||||
git_signature *author = nullptr;
|
||||
if (git_signature_default(&author, info.repo) < 0) {
|
||||
git_commit_free(parentCommit);
|
||||
git_tree_free(tree);
|
||||
setLastError(QStringLiteral("Не настроена git-личность: %1").arg(gitErr()));
|
||||
return false;
|
||||
}
|
||||
info.author = author;
|
||||
|
||||
const int crc = git_commit_create(&info.commitId, info.repo, "HEAD",
|
||||
author, author, "UTF-8",
|
||||
message.toUtf8().constData(),
|
||||
tree, nParents, parents);
|
||||
|
||||
if (parentCommit) git_commit_free(parentCommit);
|
||||
git_tree_free(tree);
|
||||
|
||||
if (crc < 0) {
|
||||
setLastError(QStringLiteral("Не удалось создать коммит: %1").arg(gitErr()));
|
||||
return false;
|
||||
}
|
||||
|
||||
setLastError(QString());
|
||||
return true;
|
||||
return commitFromIndex(info.repo, info.index, message);
|
||||
}
|
||||
|
||||
QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &filePath) const
|
||||
bool GitManager::commitFile(const QString &vaultPath, const QString &filePath, const QString &message)
|
||||
{
|
||||
const QString vault = QDir::cleanPath(vaultPath);
|
||||
const QString rel = relativePath(vaultPath, filePath);
|
||||
if (vault.isEmpty() || rel.isEmpty()) {
|
||||
setLastError(QStringLiteral("Неверные пути"));
|
||||
return false;
|
||||
}
|
||||
CommitInfo info;
|
||||
if (git_repository_open(&info.repo, vault.toUtf8().constData()) < 0) {
|
||||
setLastError(QStringLiteral("Хранилище не является git-репозиторием"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (git_repository_index(&info.index, info.repo) < 0) {
|
||||
setLastError(QStringLiteral("Не удалось открыть индекс: %1").arg(gitErr()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Точечно: существующий файл — в индекс, удалённый — из индекса
|
||||
const QByteArray relBytes = rel.toUtf8();
|
||||
int rc;
|
||||
if (QFile::exists(QDir(vault).filePath(rel)))
|
||||
rc = git_index_add_bypath(info.index, relBytes.constData());
|
||||
else
|
||||
rc = git_index_remove_bypath(info.index, relBytes.constData());
|
||||
if (rc < 0) {
|
||||
setLastError(QStringLiteral("Не удалось добавить файл в индекс: %1").arg(gitErr()));
|
||||
return false;
|
||||
}
|
||||
if (git_index_write(info.index) < 0) {
|
||||
setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr()));
|
||||
return false;
|
||||
}
|
||||
|
||||
return commitFromIndex(info.repo, info.index, message);
|
||||
}
|
||||
|
||||
QString GitManager::headOid(git_repository *repo) const
|
||||
{
|
||||
git_oid oid;
|
||||
if (git_reference_name_to_id(&oid, repo, "HEAD") < 0)
|
||||
return QString();
|
||||
char buf[41] = {0};
|
||||
git_oid_tostr(buf, sizeof(buf), &oid);
|
||||
return QString::fromUtf8(buf);
|
||||
}
|
||||
|
||||
QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &filePath,
|
||||
int limit) const
|
||||
{
|
||||
QVariantList out;
|
||||
if (limit <= 0)
|
||||
limit = 500;
|
||||
limit = qMin(limit, 2000);
|
||||
const QString vault = QDir::cleanPath(vaultPath);
|
||||
const QString rel = relativePath(vaultPath, filePath);
|
||||
if (vault.isEmpty() || rel.isEmpty())
|
||||
@@ -368,19 +395,31 @@ QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &fi
|
||||
git_repository_free(repo);
|
||||
return out;
|
||||
}
|
||||
// История меняется только с HEAD — при том же HEAD отдаём кэш
|
||||
const QString head = headOid(repo);
|
||||
const QString key = vault + QLatin1Char('|') + rel + QLatin1Char('|') + head;
|
||||
if (!head.isEmpty() && key == m_histKey && m_histCache.size() >= limit) {
|
||||
git_repository_free(repo);
|
||||
return m_histCache.mid(0, limit);
|
||||
}
|
||||
git_revwalk *walk = nullptr;
|
||||
if (git_revwalk_new(&walk, repo) < 0) {
|
||||
git_repository_free(repo);
|
||||
return out;
|
||||
}
|
||||
git_revwalk_sorting(walk, GIT_SORT_TIME);
|
||||
// TIME|TOPOLOGICAL: время + гарантия «потомки раньше родителей».
|
||||
// Один TIME при коммитах в одну секунду тасует порядок (баг: «новой» могла
|
||||
// оказаться старая версия); история хранилища линейная, топология даёт точный порядок.
|
||||
git_revwalk_sorting(walk, GIT_SORT_TIME | GIT_SORT_TOPOLOGICAL);
|
||||
git_revwalk_push_head(walk);
|
||||
|
||||
// Собираем до limit КАСАНИЙ файла (а не коммитов): иначе на активной истории
|
||||
// «Показать ещё» врало бы при разреженных касаниях. Перебор коммитов ограничен.
|
||||
git_oid oid;
|
||||
int iter = 0;
|
||||
const int maxIter = 500; // ограничение для скорости
|
||||
while (git_revwalk_next(&oid, walk) == 0 && iter < maxIter) {
|
||||
++iter;
|
||||
int scanned = 0;
|
||||
const int scanCap = 2000;
|
||||
while (git_revwalk_next(&oid, walk) == 0 && (int)out.size() < limit && scanned < scanCap) {
|
||||
++scanned;
|
||||
git_commit *commit = nullptr;
|
||||
if (git_commit_lookup(&commit, repo, &oid) < 0)
|
||||
continue;
|
||||
@@ -461,6 +500,10 @@ QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &fi
|
||||
}
|
||||
git_revwalk_free(walk);
|
||||
git_repository_free(repo);
|
||||
if (!head.isEmpty()) {
|
||||
m_histKey = key;
|
||||
m_histCache = out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -505,88 +548,35 @@ QString GitManager::fileContentAt(const QString &vaultPath, const QString &fileP
|
||||
return result;
|
||||
}
|
||||
|
||||
QString GitManager::fileDiffText(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
|
||||
bool GitManager::fileExistedAt(const QString &vaultPath, const QString &filePath,
|
||||
const QString &commitHash) const
|
||||
{
|
||||
const QString oldContent = fileContentAt(vaultPath, filePath, commitHash);
|
||||
QFile f(filePath);
|
||||
QString newContent;
|
||||
if (f.open(QIODevice::ReadOnly))
|
||||
newContent = QString::fromUtf8(f.readAll());
|
||||
else
|
||||
newContent = QString();
|
||||
|
||||
if (oldContent == newContent)
|
||||
return QStringLiteral("Нет изменений");
|
||||
|
||||
// Используем git_patch_from_buffers для диффа
|
||||
git_patch *patch = nullptr;
|
||||
const QByteArray oldBytes = oldContent.toUtf8();
|
||||
const QByteArray newBytes = newContent.toUtf8();
|
||||
const QString vault = QDir::cleanPath(vaultPath);
|
||||
const QString rel = relativePath(vaultPath, filePath);
|
||||
const QByteArray relBytes = rel.toUtf8();
|
||||
git_patch_from_buffers(&patch,
|
||||
oldBytes.constData(), (size_t)oldBytes.size(), relBytes.constData(),
|
||||
newBytes.constData(), (size_t)newBytes.size(), relBytes.constData(),
|
||||
nullptr);
|
||||
if (!patch)
|
||||
return QStringLiteral("Не удалось построить diff");
|
||||
|
||||
QString out;
|
||||
// Заголовок
|
||||
out += QStringLiteral("--- a/%1\n").arg(rel);
|
||||
out += QStringLiteral("+++ b/%1\n").arg(rel);
|
||||
|
||||
size_t hunks = git_patch_num_hunks(patch);
|
||||
for (size_t h = 0; h < hunks; ++h) {
|
||||
const git_diff_hunk *hk = nullptr;
|
||||
size_t hunkLines = 0;
|
||||
if (git_patch_get_hunk(&hk, &hunkLines, patch, h) < 0) continue;
|
||||
out += QString::fromUtf8(hk->header, (int)hk->header_len);
|
||||
if (!out.endsWith('\n')) out += '\n';
|
||||
for (size_t l = 0; l < hunkLines; ++l) {
|
||||
const git_diff_line *line = nullptr;
|
||||
if (git_patch_get_line_in_hunk(&line, patch, h, l) < 0) continue;
|
||||
QChar origin = QChar(line->origin);
|
||||
QString text = QString::fromUtf8(line->content, (int)line->content_len);
|
||||
// line->content уже содержит \n в конце
|
||||
out += origin + text;
|
||||
if (!text.endsWith('\n') && line->content_len > 0) out += '\n';
|
||||
if (vault.isEmpty() || rel.isEmpty() || commitHash.isEmpty())
|
||||
return false;
|
||||
git_repository *repo = nullptr;
|
||||
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
|
||||
return false;
|
||||
git_oid oid;
|
||||
bool existed = false;
|
||||
if (git_oid_fromstr(&oid, commitHash.toUtf8().constData()) == 0) {
|
||||
git_commit *commit = nullptr;
|
||||
if (git_commit_lookup(&commit, repo, &oid) == 0) {
|
||||
git_tree *tree = nullptr;
|
||||
if (git_commit_tree(&tree, commit) == 0) {
|
||||
git_tree_entry *entry = nullptr;
|
||||
if (git_tree_entry_bypath(&entry, tree, rel.toUtf8().constData()) == 0) {
|
||||
existed = true;
|
||||
git_tree_entry_free(entry);
|
||||
}
|
||||
git_tree_free(tree);
|
||||
}
|
||||
git_commit_free(commit);
|
||||
}
|
||||
}
|
||||
git_patch_free(patch);
|
||||
if (out.trimmed().isEmpty())
|
||||
return QStringLiteral("Нет изменений");
|
||||
return out;
|
||||
}
|
||||
|
||||
QString GitManager::fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
|
||||
{
|
||||
const QString txt = fileDiffText(vaultPath, filePath, commitHash);
|
||||
if (txt == QStringLiteral("Нет изменений") || txt.startsWith(QStringLiteral("Не удалось")))
|
||||
return QStringLiteral("<p style='color:#888'>%1</p>").arg(escHtml(txt));
|
||||
|
||||
QString html;
|
||||
html.reserve(txt.size()*2);
|
||||
html += "<pre style='font-family:Menlo,monospace;font-size:12px;white-space:pre-wrap;word-wrap:break-word;margin:0'>";
|
||||
const QStringList lines = txt.split('\n');
|
||||
for (const QString &rawLine : lines) {
|
||||
if (rawLine.isEmpty()) { html += "\n"; continue; }
|
||||
QChar first = rawLine.at(0);
|
||||
QString esc = escHtml(rawLine);
|
||||
if (first == '+') {
|
||||
html += QStringLiteral("<div style='background:#b7f5c6;color:#0d5c2c'>%1</div>").arg(esc);
|
||||
} else if (first == '-') {
|
||||
html += QStringLiteral("<div style='background:#ffd6dd;color:#b3192e'>%1</div>").arg(esc);
|
||||
} else if (first == '@') {
|
||||
html += QStringLiteral("<div style='background:#ddf4ff;color:#0969da'>%1</div>").arg(esc);
|
||||
} else if (first == ' ') {
|
||||
html += QStringLiteral("<div style='color:#24292f'>%1</div>").arg(esc);
|
||||
} else {
|
||||
html += QStringLiteral("<div style='color:#656d76'>%1</div>").arg(esc);
|
||||
}
|
||||
}
|
||||
html += "</pre>";
|
||||
return html;
|
||||
git_repository_free(repo);
|
||||
return existed;
|
||||
}
|
||||
|
||||
QString GitManager::restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash)
|
||||
@@ -601,7 +591,7 @@ QString GitManager::restoreFile(const QString &vaultPath, const QString &filePat
|
||||
const QString absPath = QDir(vault).filePath(rel);
|
||||
if (content.isNull() && content.isEmpty()) {
|
||||
// Проверяем был ли файл в коммите вообще
|
||||
QVariantList hist = fileHistory(vaultPath, filePath);
|
||||
QVariantList hist = fileHistory(vaultPath, filePath, 500);
|
||||
bool found = false;
|
||||
for (auto v : hist) if (v.toMap().value("hash").toString() == commitHash) { found = true; break; }
|
||||
if (!found) return QStringLiteral("Коммит не найден");
|
||||
@@ -629,15 +619,12 @@ QString GitManager::restoreFile(const QString &vaultPath, const QString &filePat
|
||||
|
||||
QString GitManager::workdirContent(const QString &filePath) const
|
||||
{
|
||||
QFile f(filePath);
|
||||
if (!f.open(QIODevice::ReadOnly) || f.size() > 2 * 1024 * 1024)
|
||||
return QString();
|
||||
return QString::fromUtf8(f.readAll());
|
||||
return DocumentManager::readTextFile(filePath, 2LL * 1024 * 1024);
|
||||
}
|
||||
|
||||
QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const
|
||||
{
|
||||
const QVariantList hist = fileHistory(vaultPath, filePath);
|
||||
const QVariantList hist = fileHistory(vaultPath, filePath, 500);
|
||||
if (!hist.isEmpty()) {
|
||||
QDateTime dt = hist.first().toMap().value("time").toDateTime();
|
||||
if (dt.isValid()) return dt;
|
||||
@@ -669,8 +656,9 @@ bool GitManager::autoCommit(const QString &vaultPath, const QString &filePath)
|
||||
const QString msg = fileName.isEmpty()
|
||||
? QStringLiteral("Автосохранение")
|
||||
: QStringLiteral("Автосохранение: %1").arg(fileName);
|
||||
// commitAll вернёт false если нет изменений — это нормально
|
||||
bool ok = commitAll(vaultPath, msg);
|
||||
// Точечный коммит одного файла (а не всего хранилища) — сообщение честное.
|
||||
// commitFile вернёт false если нет изменений — это нормально
|
||||
bool ok = commitFile(vaultPath, filePath, msg);
|
||||
if (!ok && lastError() == QStringLiteral("Нет изменений для коммита")) {
|
||||
setLastError(QString());
|
||||
return true; // считаем успехом
|
||||
|
||||
+21
-7
@@ -22,6 +22,10 @@ public:
|
||||
// Коммитит все изменения в хранилище (рабочее дерево). Возвращает true/false.
|
||||
Q_INVOKABLE bool commitAll(const QString &path, const QString &message);
|
||||
|
||||
// Коммитит только один файл (добавление/изменение/удаление). Возвращает true/false.
|
||||
Q_INVOKABLE bool commitFile(const QString &vaultPath, const QString &filePath,
|
||||
const QString &message);
|
||||
|
||||
// Есть ли в хранилище хотя бы один коммит.
|
||||
Q_INVOKABLE bool hasCommits(const QString &path) const;
|
||||
|
||||
@@ -32,20 +36,22 @@ public:
|
||||
Q_INVOKABLE QString fileGitStatusShort(const QString &vaultPath, const QString &filePath) const;
|
||||
|
||||
// История файла: список коммитов где файл менялся. Сортировка новые сверху.
|
||||
// Каждый элемент: {hash, shortHash, message, author, time (QDateTime), timeStr, timeAgo}
|
||||
Q_INVOKABLE QVariantList fileHistory(const QString &vaultPath, const QString &filePath) const;
|
||||
// Каждый элемент: {hash, shortHash, message, author, time (QDateTime), timeStr, timeAgo}.
|
||||
// Не больше limit КАСАНИЙ (limit<=0 — до 500); перебор коммитов ограничен изнутри.
|
||||
// Для кнопки «Показать ещё»: запросить limit+1, hasMore = (размер > limit).
|
||||
Q_INVOKABLE QVariantList fileHistory(const QString &vaultPath, const QString &filePath,
|
||||
int limit) const;
|
||||
|
||||
// Содержимое файла в конкретном коммите (hash). Пусто если файл не существовал.
|
||||
Q_INVOKABLE QString fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
|
||||
|
||||
// Существовал ли файл в коммите (отличает «пустой файл» от «файла не было»).
|
||||
Q_INVOKABLE bool fileExistedAt(const QString &vaultPath, const QString &filePath,
|
||||
const QString &commitHash) const;
|
||||
|
||||
// Текущее содержимое файла на диске (для сравнения с версией). Пусто при ошибке/лимите 2 МБ.
|
||||
Q_INVOKABLE QString workdirContent(const QString &filePath) const;
|
||||
|
||||
// Diff между версией в коммите и текущим файлом (рабочее дерево). Возвращает HTML с цветами.
|
||||
Q_INVOKABLE QString fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
|
||||
// Текстовый diff (unified)
|
||||
Q_INVOKABLE QString fileDiffText(const QString &vaultPath, const QString &filePath, const QString &commitHash) const;
|
||||
|
||||
// Восстановить файл из коммита (перезаписать и закоммитить). Возвращает "" при успехе или текст ошибки.
|
||||
Q_INVOKABLE QString restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash);
|
||||
|
||||
@@ -71,7 +77,15 @@ private:
|
||||
void setLastError(const QString &msg);
|
||||
QString relativePath(const QString &vaultPath, const QString &filePath) const;
|
||||
void ensureGitignore(const QString &vaultPath);
|
||||
// OID текущего HEAD ("" если нет). Для инвалидации кэша истории.
|
||||
QString headOid(git_repository *repo) const;
|
||||
// Создаёт коммит из уже записанного индекса (общее для commitAll/commitFile).
|
||||
bool commitFromIndex(git_repository *repo, git_index *index, const QString &message);
|
||||
|
||||
QString m_lastError;
|
||||
static bool s_gitInitialized;
|
||||
|
||||
// Кэш истории: ключ vault|rel|headOid. История меняется только с HEAD.
|
||||
mutable QString m_histKey;
|
||||
mutable QVariantList m_histCache;
|
||||
};
|
||||
+1
-1
@@ -67,7 +67,7 @@ int main(int argc, char *argv[])
|
||||
engine.rootContext()->setContextProperty("vaultIndex", &vaultIndex);
|
||||
|
||||
WindowController windowController(
|
||||
&engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml"));
|
||||
&engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml"), &mdParser);
|
||||
engine.rootContext()->setContextProperty("windowController", &windowController);
|
||||
|
||||
// При открытии любого окна — запоминаем его как последнее выбранное (для следующего запуска)
|
||||
|
||||
+397
-146
@@ -1,5 +1,8 @@
|
||||
#include "markdown/MdParser.h"
|
||||
|
||||
#include "db/Database.h"
|
||||
#include "core/Frontmatter.h"
|
||||
|
||||
#include <cmark-gfm.h>
|
||||
#include <cmark-gfm-extension_api.h>
|
||||
#include <cmark-gfm-core-extensions.h>
|
||||
@@ -20,12 +23,14 @@
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QFutureWatcher>
|
||||
#include <QGuiApplication>
|
||||
#include <QImageReader>
|
||||
#include <QRegularExpression>
|
||||
#include <QSettings>
|
||||
#include <QTime>
|
||||
#include <QUrl>
|
||||
#include <QtConcurrentRun>
|
||||
|
||||
using KSyntaxHighlighting::AbstractHighlighter;
|
||||
using KSyntaxHighlighting::Definition;
|
||||
@@ -208,12 +213,18 @@ QVector<DiffOp> diffMdBlocks(const QStringList &a, const QStringList &b)
|
||||
return ops;
|
||||
}
|
||||
|
||||
// Цветная обёртка изменённого блока: насыщенные розовый/зелёный, текст читается
|
||||
// Цветная обёртка изменённого блока: единая насыщенная палитра везде
|
||||
// (полутона читаются и на светлом, и на тёмном фоне, и в синем пузыре кода)
|
||||
QString tintDiffHtml(const QString &inner, bool isDel)
|
||||
{
|
||||
if (isDel)
|
||||
return QStringLiteral("<div style=\"background-color:#ffd6dd;border-left:3px solid #e5484d;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
|
||||
return QStringLiteral("<div style=\"background-color:#b7f5c6;border-left:3px solid #1a7f37;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
|
||||
return QStringLiteral("<div style=\"background-color:rgba(244,63,94,0.45);border-left:3px solid #e5484d;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
|
||||
return QStringLiteral("<div style=\"background-color:rgba(34,197,94,0.45);border-left:3px solid #1a7f37;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
|
||||
}
|
||||
|
||||
bool isSqlFence(const QString &lang)
|
||||
{
|
||||
return lang.trimmed().compare(QLatin1String("~sql"), Qt::CaseInsensitive) == 0;
|
||||
}
|
||||
|
||||
QString fileHrefFor(const QString &path)
|
||||
@@ -416,12 +427,150 @@ struct MdParser::Impl
|
||||
Repository *m_repo = nullptr;
|
||||
QHash<QString, QString> codeBlocks;
|
||||
int codeCounter = 0;
|
||||
// Базы открытых хранилищ (для ~sql). QPointer — окно могли закрыть.
|
||||
QHash<QString, QPointer<Database>> databases;
|
||||
};
|
||||
|
||||
MdParser::MdParser(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m(new Impl)
|
||||
{
|
||||
m_diffCache.setMaxCost(8);
|
||||
}
|
||||
|
||||
// Ячейка — путь к файлу хранилища? Возвращает абсолютный путь или "".
|
||||
QString vaultFileForCell(Database *db, const QString &cell)
|
||||
{
|
||||
const QString t = cell.trimmed();
|
||||
if (t.isEmpty() || !db)
|
||||
return QString();
|
||||
const QString vault = db->vault();
|
||||
if (vault.isEmpty())
|
||||
return QString();
|
||||
if (QDir::isAbsolutePath(t)) {
|
||||
const QString clean = QDir::cleanPath(t);
|
||||
if (clean.startsWith(vault + QLatin1Char('/')) && QFileInfo::exists(clean))
|
||||
return clean;
|
||||
return QString();
|
||||
}
|
||||
const QString rel = QDir::cleanPath(vault + QLatin1Char('/') + t);
|
||||
if (QFileInfo::exists(rel))
|
||||
return rel;
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Путь для показа: вставляем zero-width space после '/' — движок rich text
|
||||
// умеет переносить только по пробелам, длинные пути иначе рвут пузырь.
|
||||
// На href/копирование не влияет (только отображаемый текст).
|
||||
QString breakablePath(const QString &cell)
|
||||
{
|
||||
QString d = esc(cell);
|
||||
d.replace(QLatin1Char('/'), QStringLiteral("/\u200b"));
|
||||
return d;
|
||||
}
|
||||
|
||||
// ~sql-блок → результат в пузыре кодоблока (как в Telegram).
|
||||
// Имена файлов — ссылки flora://open (новая вкладка), пути — flora://copycode
|
||||
// (клик копирует путь). codeId — кнопка «скопировать запрос».
|
||||
// Пусто = fallback на обычный код.
|
||||
QString renderSqlBlock(Database *db, MdParser::Impl *impl, const QString &sql, const QString &codeId)
|
||||
{
|
||||
if (!db || !db->isOpen() || !db->queryEnabled())
|
||||
return QString();
|
||||
const QVariantMap res = db->queryTable(sql);
|
||||
const QString err = res.value("error").toString();
|
||||
|
||||
const QString bubbleBg = "#2e4d6a";
|
||||
const QString copyLink = codeId.isEmpty() ? QString()
|
||||
: QStringLiteral("<a href=\"flora://copycode/%1\" class=\"copybtn\" style=\"display:inline-block;background:rgba(255,255,255,0.12);color:#e0e0e0;text-decoration:none;font-size:12px;padding:4px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.18);font-family:-apple-system,sans-serif;\" title=\"Копировать\">📋 SQL</a>").arg(codeId);
|
||||
|
||||
QString inner;
|
||||
QString foot;
|
||||
if (!err.isEmpty()) {
|
||||
inner = QStringLiteral(
|
||||
"<div style=\"background:rgba(244,63,94,0.30);border:1px solid #e5484d;border-radius:9px;padding:10px 12px\">"
|
||||
"<div style=\"color:#ffc4c4;font-weight:bold;font-size:12px\">SQL: %1</div>"
|
||||
"<div style=\"color:#d6dee7;font-size:11px;margin-top:6px\">Таблицы: files(path,title,mtime,size), tags(name), file_tags, links, properties. Только SELECT / WITH.</div>"
|
||||
"</div>")
|
||||
.arg(esc(err));
|
||||
} else {
|
||||
const QStringList cols = res.value("columns").toStringList();
|
||||
const QVariantList rows = res.value("rows").toList();
|
||||
const bool truncated = res.value("truncated").toBool();
|
||||
const int colCount = cols.size();
|
||||
// th не используем: Qt рисует заголовкам свою чёрную плашку поверх стилей.
|
||||
// width=100% тоже нельзя: такая таблица в Qt игнорирует padding'и
|
||||
// родительских div'ов и вылезает за панель.
|
||||
inner = QStringLiteral("<table cellspacing=\"0\" cellpadding=\"0\" style=\"font-family:-apple-system,sans-serif;font-size:13px\">");
|
||||
inner += QStringLiteral("<tr>");
|
||||
for (int c = 0; c < colCount; ++c) {
|
||||
inner += QStringLiteral("<td style=\"padding:8px 12px;text-align:left;color:#9fb3c8;font-weight:600;font-size:12px;border-bottom:1px solid rgba(255,255,255,0.25)\">%1</td>")
|
||||
.arg(esc(cols[c]));
|
||||
}
|
||||
inner += QStringLiteral("</tr>");
|
||||
for (const QVariant &rv : rows) {
|
||||
const QVariantList row = rv.toList();
|
||||
// Путь строки: ячейка-путь, иначе нечем открывать title
|
||||
QString rowPath;
|
||||
for (int c = 0; c < colCount; ++c) {
|
||||
const QString probe = c < row.size() ? row[c].toString() : QString();
|
||||
rowPath = vaultFileForCell(db, probe);
|
||||
if (!rowPath.isEmpty())
|
||||
break;
|
||||
}
|
||||
inner += QStringLiteral("<tr>");
|
||||
for (int c = 0; c < colCount; ++c) {
|
||||
const QString cell = c < row.size() ? row[c].toString() : QString();
|
||||
const QString cs = QStringLiteral("padding:8px 12px;text-align:left;color:#ffffff;border-top:1px solid rgba(255,255,255,0.12)");
|
||||
QString body;
|
||||
if (cell == QLatin1String("NULL")) {
|
||||
body = QStringLiteral("<i style=\"color:#8ea3b8\">NULL</i>");
|
||||
} else if (!vaultFileForCell(db, cell).isEmpty()) {
|
||||
// Путь: клик копирует его в буфер
|
||||
const QString abs = vaultFileForCell(db, cell);
|
||||
QString pid;
|
||||
if (impl) {
|
||||
pid = QStringLiteral("spath_%1").arg(impl->codeCounter++);
|
||||
impl->codeBlocks.insert(pid, abs);
|
||||
}
|
||||
body = pid.isEmpty()
|
||||
? breakablePath(cell)
|
||||
: QStringLiteral("<a href=\"flora://copycode/%1\" style=\"color:#c9d6e2;text-decoration:underline\" title=\"Нажмите, чтобы скопировать путь\">%2</a>")
|
||||
.arg(pid, breakablePath(cell));
|
||||
} else if (c < cols.size()
|
||||
&& cols[c].trimmed().compare(QLatin1String("title"), Qt::CaseInsensitive) == 0) {
|
||||
// Имя: клик открывает файл в соседней вкладке
|
||||
QString tp = rowPath;
|
||||
if (tp.isEmpty())
|
||||
tp = db->filePathForTitle(cell);
|
||||
body = tp.isEmpty()
|
||||
? esc(cell)
|
||||
: QStringLiteral("<a href=\"%1\" style=\"color:#8fc3ff;text-decoration:underline\" title=\"Открыть в соседней вкладке\">%2</a>")
|
||||
.arg(fileHrefFor(tp), esc(cell));
|
||||
} else {
|
||||
body = esc(cell);
|
||||
}
|
||||
inner += QStringLiteral("<td style=\"%1\">%2</td>").arg(cs, body);
|
||||
}
|
||||
inner += QStringLiteral("</tr>");
|
||||
}
|
||||
inner += QStringLiteral("</table>");
|
||||
foot = rows.size() == 1 ? QStringLiteral("1 строка") : QStringLiteral("%1 строк").arg(rows.size());
|
||||
if (truncated)
|
||||
foot += QStringLiteral(" (показаны первые)");
|
||||
}
|
||||
|
||||
QString out = QStringLiteral(
|
||||
"<style>.copybtn:hover{background:rgba(255,255,255,0.22) !important;border-color:rgba(255,255,255,0.28) !important;color:#ffffff !important;}"
|
||||
".copybtn:active{background:rgba(255,255,255,0.30) !important;}</style>"
|
||||
"<div style=\"background:%1;border-radius:14px;padding:12px 14px 12px 14px;margin:12px 0\">"
|
||||
"<div style=\"text-align:right;margin-bottom:8px\">%2</div>"
|
||||
"<div style=\"background:rgba(0,0,0,0.22);border-radius:9px;padding:12px 14px\">%3</div>")
|
||||
.arg(bubbleBg, copyLink, inner);
|
||||
if (!foot.isEmpty())
|
||||
out += QStringLiteral("<div style=\"color:#9fb3c8;font-size:11px;margin-top:8px;font-family:-apple-system,sans-serif\">%1</div>").arg(esc(foot));
|
||||
out += QStringLiteral("</div>");
|
||||
return out;
|
||||
}
|
||||
|
||||
MdParser::~MdParser()
|
||||
@@ -442,6 +591,39 @@ void MdParser::copyToClipboard(const QString &text) const
|
||||
clip->setText(text);
|
||||
}
|
||||
|
||||
bool MdParser::copyCodeBlock(const QString &id) const
|
||||
{
|
||||
const QString code = getCodeBlock(id);
|
||||
if (code.isEmpty())
|
||||
return false;
|
||||
copyToClipboard(code);
|
||||
return true;
|
||||
}
|
||||
|
||||
void MdParser::setDatabaseForVault(const QString &vaultRoot, Database *db)
|
||||
{
|
||||
if (!m || vaultRoot.trimmed().isEmpty() || !db)
|
||||
return;
|
||||
m->databases.insert(QDir::cleanPath(vaultRoot), db);
|
||||
}
|
||||
|
||||
void MdParser::removeDatabaseForVault(const QString &vaultRoot, Database *db)
|
||||
{
|
||||
if (!m)
|
||||
return;
|
||||
const QString key = QDir::cleanPath(vaultRoot);
|
||||
auto it = m->databases.find(key);
|
||||
if (it != m->databases.end() && it.value() == db)
|
||||
m->databases.erase(it);
|
||||
}
|
||||
|
||||
Database *MdParser::databaseFor(const QString &vaultRoot) const
|
||||
{
|
||||
if (!m || vaultRoot.trimmed().isEmpty())
|
||||
return nullptr;
|
||||
return m->databases.value(QDir::cleanPath(vaultRoot), nullptr);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
class Renderer
|
||||
@@ -606,7 +788,8 @@ private:
|
||||
QString code_block(cmark_node *n)
|
||||
{
|
||||
const char *info = cmark_node_get_fence_info(n);
|
||||
const QString lang = normalizeLang(QString::fromUtf8(info ? info : ""));
|
||||
const QString rawLang = QString::fromUtf8(info ? info : "");
|
||||
const QString lang = normalizeLang(rawLang);
|
||||
const QString code = QString::fromUtf8(cmark_node_get_literal(n));
|
||||
// Сохраняем оригинал для копирования
|
||||
QString codeId;
|
||||
@@ -614,6 +797,15 @@ private:
|
||||
codeId = QStringLiteral("code_%1").arg(m_impl->codeCounter++);
|
||||
m_impl->codeBlocks.insert(codeId, code);
|
||||
}
|
||||
// ~sql: результат запроса таблицей; без базы — исходник как код
|
||||
if (isSqlFence(rawLang)) {
|
||||
Database *db = m_impl
|
||||
? m_impl->databases.value(QDir::cleanPath(m_vaultRoot), nullptr)
|
||||
: nullptr;
|
||||
const QString table = renderSqlBlock(db, m_impl, code, codeId);
|
||||
if (!table.isEmpty())
|
||||
return table;
|
||||
}
|
||||
// Подсветка — тёмная тема для контраста на синем фоне (как в Telegram)
|
||||
CodeHighlighter hl(m_repo);
|
||||
const QString highlighted = hl.highlight(code, lang, true);
|
||||
@@ -629,7 +821,7 @@ private:
|
||||
return QStringLiteral(
|
||||
"<style>.copybtn:hover{background:rgba(255,255,255,0.22) !important;border-color:rgba(255,255,255,0.28) !important;color:#ffffff !important;}"
|
||||
".copybtn:active{background:rgba(255,255,255,0.30) !important;}</style>"
|
||||
"<div style=\"background:%1;border-radius:14px;padding:12px 14px 12px 14px;margin:12px 0;max-width:88%%;box-shadow:0 1px 2px rgba(0,0,0,0.18)\">"
|
||||
"<div style=\"background:%1;border-radius:14px;padding:12px 14px 12px 14px;margin:12px 0\">"
|
||||
"<div style=\"text-align:right;margin-bottom:8px\">%2</div>"
|
||||
"<div style=\"background:rgba(0,0,0,0.22);border-radius:9px;padding:12px 14px\">"
|
||||
"<pre style=\"margin:0;font-family:'JetBrains Mono','Fira Code','Menlo',Consolas,monospace;font-size:13px;line-height:1.5;tab-size:4;white-space:pre-wrap;word-wrap:break-word;color:#ffffff\">"
|
||||
@@ -844,6 +1036,22 @@ private:
|
||||
|
||||
QString MdParser::render(const QString &markdown, const QString &vaultRoot,
|
||||
const QString &fileDir)
|
||||
{
|
||||
// Публичный вызов начинает нумерацию кодовых блоков заново (code_0, code_1, ...)
|
||||
if (m) {
|
||||
m->codeBlocks.clear();
|
||||
m->codeCounter = 0;
|
||||
}
|
||||
// Шапка свойств — метаданные, не контент: в просмотре её не показываем
|
||||
// (в истории версий текст идёт как есть, там не режем).
|
||||
const Frontmatter::Parsed fp = Frontmatter::parse(markdown);
|
||||
return renderInner(fp.hasFrontmatter ? fp.body : markdown, vaultRoot, fileDir);
|
||||
}
|
||||
|
||||
// Файлово-статический рендер с явным Impl: безопасен для рабочего потока
|
||||
// (свой парсер cmark на вызов, никакой разделяемой мутации кроме переданного impl).
|
||||
static QString renderWithImpl(MdParser::Impl *impl, const QString &markdown,
|
||||
const QString &vaultRoot, const QString &fileDir)
|
||||
{
|
||||
cmark_gfm_core_extensions_ensure_registered();
|
||||
cmark_parser *parser = cmark_parser_new(CMARK_OPT_DEFAULT);
|
||||
@@ -856,11 +1064,7 @@ QString MdParser::render(const QString &markdown, const QString &vaultRoot,
|
||||
cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size()));
|
||||
cmark_node *doc = cmark_parser_finish(parser);
|
||||
|
||||
if (m) {
|
||||
m->codeBlocks.clear();
|
||||
m->codeCounter = 0;
|
||||
}
|
||||
Renderer r(m, vaultRoot, fileDir);
|
||||
Renderer r(impl, vaultRoot, fileDir);
|
||||
QString html = r.renderTree(doc);
|
||||
|
||||
cmark_node_free(doc);
|
||||
@@ -868,100 +1072,34 @@ QString MdParser::render(const QString &markdown, const QString &vaultRoot,
|
||||
return html;
|
||||
}
|
||||
|
||||
QString MdParser::renderInner(const QString &markdown, const QString &vaultRoot,
|
||||
const QString &fileDir)
|
||||
{
|
||||
return renderWithImpl(m, markdown, vaultRoot, fileDir);
|
||||
}
|
||||
|
||||
QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vaultRoot, const QString &fileDir)
|
||||
{
|
||||
QVariantList blocks;
|
||||
if (markdown.isEmpty()) return blocks;
|
||||
|
||||
// Шапка свойств — метаданные: в блочном просмотре её нет (как в render).
|
||||
QString src = markdown;
|
||||
{
|
||||
const Frontmatter::Parsed fp = Frontmatter::parse(src);
|
||||
if (fp.hasFrontmatter)
|
||||
src = fp.body;
|
||||
}
|
||||
|
||||
// Сбрасываем счётчик для нового парса
|
||||
if (m) {
|
||||
m->codeBlocks.clear();
|
||||
m->codeCounter = 0;
|
||||
}
|
||||
|
||||
// Ищем fenced code blocks ```lang\ncode\n```
|
||||
static const QRegularExpression re(QStringLiteral("```([^`\\n]*?)\\n(.*?)\\n```"), QRegularExpression::DotMatchesEverythingOption);
|
||||
QRegularExpressionMatchIterator it = re.globalMatch(markdown);
|
||||
int lastPos = 0;
|
||||
while (it.hasNext()) {
|
||||
QRegularExpressionMatch match = it.next();
|
||||
int start = match.capturedStart();
|
||||
int end = match.capturedEnd();
|
||||
// Текст до блока
|
||||
if (start > lastPos) {
|
||||
QString htmlPart = markdown.mid(lastPos, start - lastPos);
|
||||
if (!htmlPart.trimmed().isEmpty()) {
|
||||
// Сохраняем codeBlocks, т.к. render() их очищает
|
||||
QHash<QString, QString> savedBlocks;
|
||||
int savedCounter = 0;
|
||||
if (m) { savedBlocks = m->codeBlocks; savedCounter = m->codeCounter; }
|
||||
QString html = render(htmlPart, vaultRoot, fileDir);
|
||||
if (m) { m->codeBlocks = savedBlocks; m->codeCounter = savedCounter; }
|
||||
if (!html.trimmed().isEmpty()) {
|
||||
QVariantMap block;
|
||||
block["type"] = "html";
|
||||
block["html"] = html;
|
||||
block["codeText"] = "";
|
||||
block["language"] = "";
|
||||
block["codeId"] = "";
|
||||
block["highlightedText"] = "";
|
||||
blocks.append(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
QString langRaw = match.captured(1).trimmed();
|
||||
QString code = match.captured(2);
|
||||
QString lang = langRaw; // нормализация внутри highlight
|
||||
QString codeId;
|
||||
if (m) {
|
||||
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
|
||||
m->codeBlocks.insert(codeId, code);
|
||||
}
|
||||
CodeHighlighter hl(m ? m->m_repo : nullptr);
|
||||
QString highlighted;
|
||||
if (m && m->m_repo) {
|
||||
highlighted = hl.highlight(code, lang, true);
|
||||
} else {
|
||||
highlighted = plainToRich(code);
|
||||
}
|
||||
QVariantMap block;
|
||||
block["type"] = "code";
|
||||
block["codeText"] = code;
|
||||
block["language"] = lang.isEmpty() ? "text" : lang;
|
||||
block["codeId"] = codeId;
|
||||
block["highlightedText"] = highlighted;
|
||||
block["html"] = "";
|
||||
blocks.append(block);
|
||||
lastPos = end;
|
||||
}
|
||||
// Остаток после последнего блока
|
||||
if (lastPos < markdown.length()) {
|
||||
QString htmlPart = markdown.mid(lastPos);
|
||||
if (!htmlPart.trimmed().isEmpty()) {
|
||||
QHash<QString, QString> savedBlocks;
|
||||
int savedCounter = 0;
|
||||
if (m) { savedBlocks = m->codeBlocks; savedCounter = m->codeCounter; }
|
||||
QString html = render(htmlPart, vaultRoot, fileDir);
|
||||
if (m) { m->codeBlocks = savedBlocks; m->codeCounter = savedCounter; }
|
||||
if (!html.trimmed().isEmpty()) {
|
||||
QVariantMap block;
|
||||
block["type"] = "html";
|
||||
block["html"] = html;
|
||||
block["codeText"] = "";
|
||||
block["language"] = "";
|
||||
block["codeId"] = "";
|
||||
block["highlightedText"] = "";
|
||||
blocks.append(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Если вообще не нашли блоки, но markdown не пустой — вернём как html
|
||||
if (blocks.isEmpty() && !markdown.trimmed().isEmpty()) {
|
||||
QHash<QString, QString> savedBlocks;
|
||||
int savedCounter = 0;
|
||||
if (m) { savedBlocks = m->codeBlocks; savedCounter = m->codeCounter; }
|
||||
QString html = render(markdown, vaultRoot, fileDir);
|
||||
if (m) { m->codeBlocks = savedBlocks; m->codeCounter = savedCounter; }
|
||||
// Единая разбивка с diff-пайплайном (splitMdBlocks): заодно чинит ~~~-блоки,
|
||||
// которые старый regex ``` не видел. renderInner состояние не трогает.
|
||||
auto appendHtml = [&](const QString &html) {
|
||||
QVariantMap block;
|
||||
block["type"] = "html";
|
||||
block["html"] = html;
|
||||
@@ -970,39 +1108,76 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
||||
block["codeId"] = "";
|
||||
block["highlightedText"] = "";
|
||||
blocks.append(block);
|
||||
};
|
||||
|
||||
bool any = false;
|
||||
for (const QString &part : splitMdBlocks(src)) {
|
||||
QString lang, code;
|
||||
if (isFenceBlock(part) && parseFenceBlock(part, &lang, &code)) {
|
||||
QString codeId;
|
||||
if (m) {
|
||||
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
|
||||
m->codeBlocks.insert(codeId, code);
|
||||
}
|
||||
// ~sql: запрос к базе хранилища, результат — таблицей (как в Просмотре)
|
||||
if (isSqlFence(lang)) {
|
||||
const QString table = renderSqlBlock(databaseFor(vaultRoot), m, code, codeId);
|
||||
if (!table.isEmpty()) {
|
||||
QVariantMap block;
|
||||
block["type"] = "html";
|
||||
block["html"] = table;
|
||||
block["codeText"] = "";
|
||||
block["language"] = "";
|
||||
block["codeId"] = "";
|
||||
block["highlightedText"] = "";
|
||||
blocks.append(block);
|
||||
any = true;
|
||||
continue;
|
||||
}
|
||||
// без базы/выключено — показываем исходник запроса как код
|
||||
}
|
||||
CodeHighlighter hl(m ? m->m_repo : nullptr);
|
||||
QString highlighted;
|
||||
if (m && m->m_repo) {
|
||||
highlighted = hl.highlight(code, lang, true);
|
||||
} else {
|
||||
highlighted = plainToRich(code);
|
||||
}
|
||||
QVariantMap block;
|
||||
block["type"] = "code";
|
||||
block["codeText"] = code;
|
||||
block["language"] = lang.isEmpty() ? "text" : lang;
|
||||
block["codeId"] = codeId;
|
||||
block["highlightedText"] = highlighted;
|
||||
block["html"] = "";
|
||||
blocks.append(block);
|
||||
any = true;
|
||||
} else {
|
||||
const QString html = renderInner(part, vaultRoot, fileDir);
|
||||
if (!html.trimmed().isEmpty()) {
|
||||
appendHtml(html);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Если вообще не нашли блоки, но markdown не пустой — вернём как html
|
||||
if (!any && !src.trimmed().isEmpty())
|
||||
appendHtml(renderInner(src, vaultRoot, fileDir));
|
||||
return blocks;
|
||||
}
|
||||
|
||||
QString MdParser::highlightedCode(const QString &code, const QString &language) const
|
||||
{
|
||||
QString lang = language.trimmed();
|
||||
if (!m || !m->m_repo) return plainToRich(code);
|
||||
CodeHighlighter hl(m->m_repo);
|
||||
return hl.highlight(code, lang, true);
|
||||
}
|
||||
|
||||
QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &newMd,
|
||||
const QString &vaultRoot, const QString &fileDir)
|
||||
// Потокобезопасное ядро diff-блоков: работает с явно переданным Impl
|
||||
// (в главном потоке — m, в рабочем — локальный), состояние только в нём.
|
||||
static QVariantList buildDiffBlocks(MdParser::Impl *impl, const QString &oldMd,
|
||||
const QString &newMd, const QString &vaultRoot,
|
||||
const QString &fileDir)
|
||||
{
|
||||
// Чистая функция сборки: codeId остаются пустыми, регистрирует вызыватель
|
||||
// (нужно и для кэша, и для рабочего потока).
|
||||
QVariantList blocks;
|
||||
if (m) {
|
||||
m->codeBlocks.clear();
|
||||
m->codeCounter = 0;
|
||||
}
|
||||
if (oldMd.isEmpty() && newMd.isEmpty())
|
||||
return blocks;
|
||||
|
||||
// render() внутри очищает codeBlocks — сохраняем/восстанавливаем вокруг вызовов
|
||||
auto renderKeep = [&](const QString &part) -> QString {
|
||||
QHash<QString, QString> savedBlocks;
|
||||
int savedCounter = 0;
|
||||
if (m) { savedBlocks = m->codeBlocks; savedCounter = m->codeCounter; }
|
||||
QString html = render(part, vaultRoot, fileDir);
|
||||
if (m) { m->codeBlocks = savedBlocks; m->codeCounter = savedCounter; }
|
||||
return html;
|
||||
};
|
||||
|
||||
auto appendHtmlBlock = [&](const QString &html, const char *change) {
|
||||
QVariantMap block;
|
||||
block["type"] = "html";
|
||||
@@ -1015,25 +1190,15 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
||||
blocks.append(block);
|
||||
};
|
||||
|
||||
// Насыщенная полупрозрачная подложка для кода: пузырь CodeBlock всегда тёмно-синий,
|
||||
// светлый текст на ней читается, в отличие от пастели
|
||||
auto tintCodeHtml = [&](const QString &inner, bool isDel) -> QString {
|
||||
const QString bg = isDel ? QStringLiteral("rgba(244,63,94,0.45)")
|
||||
: QStringLiteral("rgba(34,197,94,0.45)");
|
||||
return QStringLiteral("<div style=\"background-color:%1;border-radius:6px;padding:6px 8px;margin:4px 0\">%2</div>").arg(bg, inner);
|
||||
};
|
||||
|
||||
auto appendCodeBlock = [&](const QString &lang, const QString &code, const char *change, bool tint) {
|
||||
QString codeId;
|
||||
if (m) {
|
||||
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
|
||||
m->codeBlocks.insert(codeId, code);
|
||||
}
|
||||
CodeHighlighter hl(m ? m->m_repo : nullptr);
|
||||
QString highlighted = (m && m->m_repo) ? hl.highlight(code, lang, true)
|
||||
: plainToRich(code);
|
||||
// codeId intentionally empty here: registers the caller (main thread) via
|
||||
// registerCodeBlocks, so the result is cacheable and thread-safe.
|
||||
const QString codeId;
|
||||
CodeHighlighter hl(impl ? impl->m_repo : nullptr);
|
||||
QString highlighted = (impl && impl->m_repo) ? hl.highlight(code, lang, true)
|
||||
: plainToRich(code);
|
||||
if (tint)
|
||||
highlighted = tintCodeHtml(highlighted, change[0] == 'd');
|
||||
highlighted = tintDiffHtml(highlighted, change[0] == 'd');
|
||||
QVariantMap block;
|
||||
block["type"] = "code";
|
||||
block["codeText"] = code;
|
||||
@@ -1049,11 +1214,11 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
||||
// подсветкой внутри — изменения видны там, где сделаны. Копируется новое.
|
||||
auto appendMergedCode = [&](const QString &oldLang, const QString &oldCode,
|
||||
const QString &newLang, const QString &newCode) -> bool {
|
||||
CodeHighlighter hl(m ? m->m_repo : nullptr);
|
||||
const QString oldHl = (m && m->m_repo) ? hl.highlight(oldCode, oldLang, true)
|
||||
: plainToRich(oldCode);
|
||||
const QString newHl = (m && m->m_repo) ? hl.highlight(newCode, newLang, true)
|
||||
: plainToRich(newCode);
|
||||
CodeHighlighter hl(impl ? impl->m_repo : nullptr);
|
||||
const QString oldHl = (impl && impl->m_repo) ? hl.highlight(oldCode, oldLang, true)
|
||||
: plainToRich(oldCode);
|
||||
const QString newHl = (impl && impl->m_repo) ? hl.highlight(newCode, newLang, true)
|
||||
: plainToRich(newCode);
|
||||
const QStringList oldLines = oldCode.split(QLatin1Char('\n'));
|
||||
const QStringList newLines = newCode.split(QLatin1Char('\n'));
|
||||
const QStringList oldFrag = oldHl.split(QStringLiteral("<br/>"));
|
||||
@@ -1090,11 +1255,7 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
||||
}
|
||||
flushPending();
|
||||
const QString lang = !newLang.isEmpty() ? newLang : oldLang;
|
||||
QString codeId;
|
||||
if (m) {
|
||||
codeId = QStringLiteral("code_%1").arg(m->codeCounter++);
|
||||
m->codeBlocks.insert(codeId, newCode);
|
||||
}
|
||||
const QString codeId; // registers the caller, see above
|
||||
QVariantMap block;
|
||||
block["type"] = "code";
|
||||
block["codeText"] = newCode;
|
||||
@@ -1115,6 +1276,8 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
||||
const char *change = op.kind == '=' ? "same" : (isDel ? "del" : "add");
|
||||
QString lang, code;
|
||||
const bool isCode = isFenceBlock(op.text) && parseFenceBlock(op.text, &lang, &code);
|
||||
// Изменённый код — один блок с построчным diff внутри; обычный текст —
|
||||
// отдельными tinted-блоками (del, затем add), как стандартный diff
|
||||
if (isCode && isDel && k + 1 < ops.size() && ops[k + 1].kind == '+') {
|
||||
QString nlang, ncode;
|
||||
if (isFenceBlock(ops[k + 1].text) && parseFenceBlock(ops[k + 1].text, &nlang, &ncode)
|
||||
@@ -1126,7 +1289,7 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
||||
if (isCode) {
|
||||
appendCodeBlock(lang, code, change, changed);
|
||||
} else {
|
||||
QString html = renderKeep(op.text);
|
||||
QString html = renderWithImpl(impl, op.text, vaultRoot, fileDir);
|
||||
if (html.trimmed().isEmpty())
|
||||
continue;
|
||||
if (changed)
|
||||
@@ -1138,9 +1301,97 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &
|
||||
// Пустой результат при непустом вводе — отдать как один html-блок
|
||||
if (blocks.isEmpty() && (!oldMd.trimmed().isEmpty() || !newMd.trimmed().isEmpty())) {
|
||||
const QString src = !oldMd.trimmed().isEmpty() ? oldMd : newMd;
|
||||
const QString html = renderKeep(src);
|
||||
const QString html = renderWithImpl(impl, src, vaultRoot, fileDir);
|
||||
if (!html.trimmed().isEmpty())
|
||||
appendHtmlBlock(html, "same");
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// Ключ кэша diff: размеры + хеши содержимого + пути (коллизии qHash гасим размерами)
|
||||
static QString diffCacheKey(const QString &oldMd, const QString &newMd,
|
||||
const QString &vaultRoot, const QString &fileDir)
|
||||
{
|
||||
return QString::number(oldMd.size()) + QLatin1Char(':') + QString::number(newMd.size())
|
||||
+ QLatin1Char(':') + QString::number(qHash(oldMd)) + QLatin1Char(':')
|
||||
+ QString::number(qHash(newMd)) + QLatin1Char('|') + vaultRoot
|
||||
+ QLatin1Char('|') + fileDir;
|
||||
}
|
||||
|
||||
// Регистрация кодовых блоков свежими id в главном потоке (блоки из кэша/потока
|
||||
// приходят с пустыми codeId)
|
||||
static void registerCodeBlocks(QVariantList &blocks, QHash<QString, QString> &codes, int &counter)
|
||||
{
|
||||
for (auto &v : blocks) {
|
||||
QVariantMap bm = v.toMap();
|
||||
if (bm.value("type").toString() == QLatin1String("code")) {
|
||||
const QString newId = QStringLiteral("code_%1").arg(counter++);
|
||||
codes.insert(newId, bm.value("codeText").toString());
|
||||
bm["codeId"] = newId;
|
||||
v = bm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &newMd,
|
||||
const QString &vaultRoot, const QString &fileDir)
|
||||
{
|
||||
const QString key = diffCacheKey(oldMd, newMd, vaultRoot, fileDir);
|
||||
if (QVariantList *cached = m_diffCache.object(key)) {
|
||||
QVariantList blocks = *cached;
|
||||
if (m)
|
||||
registerCodeBlocks(blocks, m->codeBlocks, m->codeCounter);
|
||||
return blocks;
|
||||
}
|
||||
QVariantList blocks = buildDiffBlocks(m, oldMd, newMd, vaultRoot, fileDir);
|
||||
if (oldMd.size() + newMd.size() <= 1024 * 1024)
|
||||
m_diffCache.insert(key, new QVariantList(blocks)); // без id — регистрирует получатель
|
||||
if (m)
|
||||
registerCodeBlocks(blocks, m->codeBlocks, m->codeCounter);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
int MdParser::parseBlocksWithDiffAsync(const QString &oldMd, const QString &newMd,
|
||||
const QString &vaultRoot, const QString &fileDir)
|
||||
{
|
||||
const int id = ++m_asyncId;
|
||||
// Быстрый путь: уже считали — отдать следующим тиком (контракт остаётся асинхронным,
|
||||
// чтобы QML не зависел от того, откуда пришёл ответ)
|
||||
if (oldMd.size() + newMd.size() <= 1024 * 1024) {
|
||||
if (QVariantList *cached = m_diffCache.object(diffCacheKey(oldMd, newMd, vaultRoot, fileDir))) {
|
||||
QVariantList blocks = *cached;
|
||||
QMetaObject::invokeMethod(this, [this, id, blocks]() mutable {
|
||||
if (id != m_asyncId || !m)
|
||||
return;
|
||||
registerCodeBlocks(blocks, m->codeBlocks, m->codeCounter);
|
||||
emit blocksReady(id, blocks);
|
||||
}, Qt::QueuedConnection);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
QFutureWatcher<QVariantList> *watcher = new QFutureWatcher<QVariantList>(this);
|
||||
// Лямбде нужны копии параметров для кэширования по завершении
|
||||
const QString cOld = oldMd, cNew = newMd, cVault = vaultRoot, cDir = fileDir;
|
||||
connect(watcher, &QFutureWatcher<QVariantList>::finished, this, [this, watcher, id, cOld, cNew, cVault, cDir]() {
|
||||
QVariantList blocks = watcher->result();
|
||||
watcher->deleteLater();
|
||||
if (id != m_asyncId || !m)
|
||||
return; // устарел — пользователь уже смотрит другую версию
|
||||
if (cOld.size() + cNew.size() <= 1024 * 1024)
|
||||
m_diffCache.insert(diffCacheKey(cOld, cNew, cVault, cDir), new QVariantList(blocks));
|
||||
if (m->codeBlocks.size() > 2000) {
|
||||
m->codeBlocks.clear(); // защита от бесконечного роста между полными перерисовками
|
||||
m->codeCounter = 0;
|
||||
}
|
||||
// Коды регистрируем здесь, в главном потоке, свежими id
|
||||
registerCodeBlocks(blocks, m->codeBlocks, m->codeCounter);
|
||||
emit blocksReady(id, blocks);
|
||||
});
|
||||
watcher->setFuture(QtConcurrent::run([oldMd, newMd, vaultRoot, fileDir]() {
|
||||
MdParser::Impl local; // свой Repository подсветки — главный поток не трогаем
|
||||
if (!local.m_repo)
|
||||
return QVariantList();
|
||||
return buildDiffBlocks(&local, oldMd, newMd, vaultRoot, fileDir);
|
||||
}));
|
||||
return id;
|
||||
}
|
||||
+36
-1
@@ -1,9 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <QCache>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
|
||||
class Database;
|
||||
|
||||
// Рендер Markdown (CommonMark + GFM) в HTML для режима просмотра.
|
||||
// Задачи/ссылки/переходы кодируются ссылками schemes:
|
||||
// flora://task/N — переключить задачу на строке N (1-based)
|
||||
@@ -36,11 +40,42 @@ public:
|
||||
const QString &vaultRoot,
|
||||
const QString &fileDir);
|
||||
|
||||
// То же, но в рабочем потоке (UI не виснет на больших файлах).
|
||||
// Возвращает requestId; результат приходит сигналом blocksReady (устаревшие id игнорить).
|
||||
// Регистрация кодов для копирования выполняется в главном потоке при приёме.
|
||||
Q_INVOKABLE int parseBlocksWithDiffAsync(const QString &oldMd,
|
||||
const QString &newMd,
|
||||
const QString &vaultRoot,
|
||||
const QString &fileDir);
|
||||
|
||||
Q_INVOKABLE QString getCodeBlock(const QString &id) const;
|
||||
Q_INVOKABLE void copyToClipboard(const QString &text) const;
|
||||
Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const;
|
||||
// Скопировать кодовый блок по id (get + copy одним вызовом). false — нечего копировать.
|
||||
Q_INVOKABLE bool copyCodeBlock(const QString &id) const;
|
||||
|
||||
// База хранилища для ~sql-блоков (по одному на хранилище; окна не мешают друг другу).
|
||||
// Регистрирует WindowController; из QML — только fallback для пути визарда.
|
||||
Q_INVOKABLE void setDatabaseForVault(const QString &vaultRoot, Database *db);
|
||||
Q_INVOKABLE void removeDatabaseForVault(const QString &vaultRoot, Database *db);
|
||||
|
||||
private:
|
||||
Database *databaseFor(const QString &vaultRoot) const;
|
||||
|
||||
signals:
|
||||
void blocksReady(int requestId, const QVariantList &blocks);
|
||||
|
||||
public:
|
||||
struct Impl;
|
||||
private:
|
||||
// Внутренний рендер без сброса codeBlocks/codeCounter (для parseBlocks*,
|
||||
// которые сами управляют состоянием и вызывают его многократно)
|
||||
QString renderInner(const QString &markdown,
|
||||
const QString &vaultRoot,
|
||||
const QString &fileDir);
|
||||
|
||||
Impl *m = nullptr;
|
||||
int m_asyncId = 0;
|
||||
// Кэш diff-блоков на время открытого диалога (ключ — хеши содержимого+пути).
|
||||
// Хранятся БЕЗ регистрации кодов; регистрация — при каждой выдаче.
|
||||
QCache<QString, QVariantList> m_diffCache;
|
||||
};
|
||||
+46
-2
@@ -13,6 +13,8 @@ add_test(NAME flora_smoke COMMAND flora_smoke)
|
||||
add_executable(flora_git_test git_test.cpp
|
||||
../src/git/GitManager.cpp
|
||||
../src/git/GitManager.h
|
||||
../src/core/DocumentManager.cpp
|
||||
../src/core/DocumentManager.h
|
||||
)
|
||||
target_include_directories(flora_git_test PRIVATE ../src)
|
||||
target_compile_definitions(flora_git_test PRIVATE CMARK_GFM_STATIC_DEFINE)
|
||||
@@ -27,6 +29,8 @@ add_executable(flora_vault_test vault_test.cpp
|
||||
../src/app/VaultManager.h
|
||||
../src/core/SettingsStore.cpp
|
||||
../src/core/SettingsStore.h
|
||||
../src/core/DocumentManager.cpp
|
||||
../src/core/DocumentManager.h
|
||||
../src/git/GitManager.cpp
|
||||
../src/git/GitManager.h
|
||||
)
|
||||
@@ -60,6 +64,12 @@ add_test(NAME flora_docmgr_test COMMAND flora_docmgr_test)
|
||||
add_executable(flora_mdparser_test mdparser_test.cpp
|
||||
../src/markdown/MdParser.cpp
|
||||
../src/markdown/MdParser.h
|
||||
../src/db/Database.cpp
|
||||
../src/db/Database.h
|
||||
../src/core/Frontmatter.cpp
|
||||
../src/core/Frontmatter.h
|
||||
../src/core/VaultIndex.cpp
|
||||
../src/core/VaultIndex.h
|
||||
)
|
||||
target_include_directories(flora_mdparser_test PRIVATE ../src)
|
||||
target_compile_definitions(flora_mdparser_test PRIVATE
|
||||
@@ -68,6 +78,8 @@ target_compile_definitions(flora_mdparser_test PRIVATE
|
||||
target_link_libraries(flora_mdparser_test PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Sql
|
||||
Qt6::Concurrent
|
||||
cmark::cmark
|
||||
cmark::cmark_extensions
|
||||
)
|
||||
@@ -112,6 +124,10 @@ add_executable(flora_extended_test extended_test.cpp
|
||||
../src/core/DocumentManager.h
|
||||
../src/core/VaultIndex.cpp
|
||||
../src/core/VaultIndex.h
|
||||
../src/core/Frontmatter.cpp
|
||||
../src/core/Frontmatter.h
|
||||
../src/db/Database.cpp
|
||||
../src/db/Database.h
|
||||
../src/markdown/MdParser.cpp
|
||||
../src/markdown/MdParser.h
|
||||
../src/git/GitManager.cpp
|
||||
@@ -119,7 +135,7 @@ add_executable(flora_extended_test extended_test.cpp
|
||||
)
|
||||
target_include_directories(flora_extended_test PRIVATE ../src)
|
||||
target_compile_definitions(flora_extended_test PRIVATE CMARK_GFM_STATIC_DEFINE CMARK_GFM_EXTENSIONS_STATIC_DEFINE)
|
||||
target_link_libraries(flora_extended_test PRIVATE Qt6::Core Qt6::Gui cmark::cmark cmark::cmark_extensions Flora_libgit2_static)
|
||||
target_link_libraries(flora_extended_test PRIVATE Qt6::Core Qt6::Gui Qt6::Sql Qt6::Concurrent cmark::cmark cmark::cmark_extensions Flora_libgit2_static)
|
||||
if(TARGET KF6SyntaxHighlighting)
|
||||
target_link_libraries(flora_extended_test PRIVATE KF6SyntaxHighlighting)
|
||||
endif()
|
||||
@@ -140,6 +156,10 @@ add_executable(flora_integration_test integration_test.cpp
|
||||
../src/core/DocumentManager.h
|
||||
../src/core/VaultIndex.cpp
|
||||
../src/core/VaultIndex.h
|
||||
../src/core/Frontmatter.cpp
|
||||
../src/core/Frontmatter.h
|
||||
../src/db/Database.cpp
|
||||
../src/db/Database.h
|
||||
../src/markdown/MdParser.cpp
|
||||
../src/markdown/MdParser.h
|
||||
../src/git/GitManager.cpp
|
||||
@@ -147,7 +167,7 @@ add_executable(flora_integration_test integration_test.cpp
|
||||
)
|
||||
target_include_directories(flora_integration_test PRIVATE ../src)
|
||||
target_compile_definitions(flora_integration_test PRIVATE CMARK_GFM_STATIC_DEFINE CMARK_GFM_EXTENSIONS_STATIC_DEFINE)
|
||||
target_link_libraries(flora_integration_test PRIVATE Qt6::Core Qt6::Gui cmark::cmark cmark::cmark_extensions Flora_libgit2_static)
|
||||
target_link_libraries(flora_integration_test PRIVATE Qt6::Core Qt6::Gui Qt6::Sql Qt6::Concurrent cmark::cmark cmark::cmark_extensions Flora_libgit2_static)
|
||||
if(TARGET KF6SyntaxHighlighting)
|
||||
target_link_libraries(flora_integration_test PRIVATE KF6SyntaxHighlighting)
|
||||
endif()
|
||||
@@ -157,15 +177,39 @@ add_test(NAME flora_integration_test COMMAND flora_integration_test)
|
||||
add_executable(flora_git_history_test git_history_test.cpp
|
||||
../src/git/GitManager.cpp
|
||||
../src/git/GitManager.h
|
||||
../src/core/DocumentManager.cpp
|
||||
../src/core/DocumentManager.h
|
||||
)
|
||||
target_include_directories(flora_git_history_test PRIVATE ../src)
|
||||
target_link_libraries(flora_git_history_test PRIVATE Qt6::Core Flora_libgit2_static)
|
||||
add_test(NAME flora_git_history_test COMMAND flora_git_history_test)
|
||||
|
||||
# Тест SQLite-слоя (Frontmatter, VaultSettings, Database, ~sql-блоки)
|
||||
add_executable(flora_db_test db_test.cpp
|
||||
../src/core/Frontmatter.cpp
|
||||
../src/core/Frontmatter.h
|
||||
../src/core/VaultSettings.cpp
|
||||
../src/core/VaultSettings.h
|
||||
../src/core/VaultIndex.cpp
|
||||
../src/core/VaultIndex.h
|
||||
../src/db/Database.cpp
|
||||
../src/db/Database.h
|
||||
../src/markdown/MdParser.cpp
|
||||
../src/markdown/MdParser.h
|
||||
)
|
||||
target_include_directories(flora_db_test PRIVATE ../src)
|
||||
target_compile_definitions(flora_db_test PRIVATE CMARK_GFM_STATIC_DEFINE CMARK_GFM_EXTENSIONS_STATIC_DEFINE)
|
||||
target_link_libraries(flora_db_test PRIVATE Qt6::Core Qt6::Gui Qt6::Sql Qt6::Concurrent cmark::cmark cmark::cmark_extensions)
|
||||
if(TARGET KF6SyntaxHighlighting)
|
||||
target_link_libraries(flora_db_test PRIVATE KF6SyntaxHighlighting)
|
||||
endif()
|
||||
add_test(NAME flora_db_test COMMAND flora_db_test)
|
||||
|
||||
# Тест выбора хранилища (последнее выбранное, а не добавленное)
|
||||
add_executable(flora_vault_select_test vault_select_test.cpp
|
||||
../src/app/VaultManager.cpp
|
||||
../src/core/SettingsStore.cpp
|
||||
../src/core/DocumentManager.cpp
|
||||
../src/git/GitManager.cpp
|
||||
)
|
||||
target_include_directories(flora_vault_select_test PRIVATE ../src)
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
// Тест SQLite-слоя: Frontmatter, VaultSettings, Database (индекс, запросы,
|
||||
// свойства) и ~sql-блоки в MdParser (таблица / fallback / ошибка).
|
||||
#include <QCoreApplication>
|
||||
#include <QTemporaryDir>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QUrl>
|
||||
#include <cstdio>
|
||||
|
||||
#include "core/Frontmatter.h"
|
||||
#include "core/VaultSettings.h"
|
||||
#include "core/VaultIndex.h"
|
||||
#include "db/Database.h"
|
||||
#include "markdown/MdParser.h"
|
||||
|
||||
static int fail(const char *msg, const QString &detail = QString())
|
||||
{
|
||||
std::fprintf(stderr, "FAIL: %s | %s\n", msg, qPrintable(detail));
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void writeFile(const QString &path, const QString &text)
|
||||
{
|
||||
QFile f(path);
|
||||
f.open(QIODevice::WriteOnly | QIODevice::Truncate);
|
||||
f.write(text.toUtf8());
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
QCoreApplication::setApplicationName("FloraTest");
|
||||
QCoreApplication::setOrganizationName("FloraTest");
|
||||
|
||||
// --- Frontmatter: парс ---
|
||||
{
|
||||
Frontmatter::Parsed fp = Frontmatter::parse(
|
||||
"---\nстатус: в работе\nприоритет: 1\n# коммент\nмусор без двоеточия\n---\n\nТело\n");
|
||||
if (!fp.hasFrontmatter)
|
||||
return fail("fm has");
|
||||
if (fp.props.value("статус").toString() != "в работе")
|
||||
return fail("fm статус", fp.props.value("статус").toString());
|
||||
if (fp.props.value("приоритет").toString() != "1")
|
||||
return fail("fm приоритет");
|
||||
if (fp.raw.size() != 2)
|
||||
return fail("fm raw", QString::number(fp.raw.size()));
|
||||
if (!fp.body.contains("Тело"))
|
||||
return fail("fm body", fp.body);
|
||||
}
|
||||
// --- Frontmatter: нет шапки / незакрытая (body при этом целый!) ---
|
||||
{
|
||||
Frontmatter::Parsed a = Frontmatter::parse("Просто текст\n");
|
||||
if (a.hasFrontmatter || !a.props.isEmpty())
|
||||
return fail("fm no fence");
|
||||
if (a.body != "Просто текст\n")
|
||||
return fail("fm body kept", a.body);
|
||||
Frontmatter::Parsed b = Frontmatter::parse("---\nключ: 1\nбез закрытия\n");
|
||||
if (b.hasFrontmatter)
|
||||
return fail("fm unclosed = текст");
|
||||
}
|
||||
// --- Frontmatter: roundtrip ---
|
||||
{
|
||||
QVariantMap props;
|
||||
props.insert("b", "2");
|
||||
props.insert("a", "1");
|
||||
const QString s = Frontmatter::serialize(props, QStringList() << "# raw", "Тело");
|
||||
Frontmatter::Parsed fp = Frontmatter::parse(s);
|
||||
if (fp.props.value("a").toString() != "1" || fp.props.value("b").toString() != "2")
|
||||
return fail("fm roundtrip props", s);
|
||||
if (fp.raw.size() != 1 || fp.body.trimmed() != "Тело")
|
||||
return fail("fm roundtrip raw/body", s);
|
||||
}
|
||||
|
||||
// --- VaultSettings: дефолты + persist ---
|
||||
QTemporaryDir dir;
|
||||
if (!dir.isValid())
|
||||
return fail("temp dir");
|
||||
const QString vault = dir.path();
|
||||
{
|
||||
VaultSettings vs;
|
||||
if (!vs.open(vault))
|
||||
return fail("vs open");
|
||||
if (vs.journalDir() != "Журнал" || !vs.sqlEnabled() || vs.sqlMaxRows() != 50)
|
||||
return fail("vs defaults");
|
||||
vs.setJournalDir("../../etc"); // мусор → дефолт
|
||||
if (vs.journalDir() != "Журнал")
|
||||
return fail("vs sanitize", vs.journalDir());
|
||||
vs.setJournalDir("Дневник");
|
||||
vs.setSqlMaxRows(20);
|
||||
if (!vs.save())
|
||||
return fail("vs save");
|
||||
VaultSettings vs2;
|
||||
vs2.open(vault);
|
||||
if (vs2.journalDir() != "Дневник" || vs2.sqlMaxRows() != 20)
|
||||
return fail("vs roundtrip");
|
||||
}
|
||||
|
||||
// --- Хранилище: два файла со свойствами, тегами и ссылкой ---
|
||||
writeFile(vault + "/a.md",
|
||||
"---\nстатус: в работе\n---\n\nТекст #тег1 [[b]]\n");
|
||||
writeFile(vault + "/b.md", "# Бэ\n\nТекст #тег1 #тег2\n");
|
||||
VaultIndex index;
|
||||
index.rebuild(vault);
|
||||
if (index.fileCount() != 2)
|
||||
return fail("index count", QString::number(index.fileCount()));
|
||||
|
||||
Database db;
|
||||
db.setIndex(&index);
|
||||
if (!db.open(vault))
|
||||
return fail("db open");
|
||||
// open сам подхватил готовый индекс
|
||||
QVariantMap st = db.stats();
|
||||
if (st.value("files").toInt() != 2)
|
||||
return fail("db files", QString::number(st.value("files").toInt()));
|
||||
if (st.value("tags").toInt() != 2)
|
||||
return fail("db tags", QString::number(st.value("tags").toInt()));
|
||||
if (st.value("links").toInt() != 1)
|
||||
return fail("db links", QString::number(st.value("links").toInt()));
|
||||
if (st.value("properties").toInt() != 1)
|
||||
return fail("db props", QString::number(st.value("properties").toInt()));
|
||||
|
||||
// --- SELECT работает (включая многоколоночные: строки не сплющиваются) ---
|
||||
{
|
||||
QVariantMap r = db.queryTable("SELECT title FROM files ORDER BY title");
|
||||
if (!r.value("error").toString().isEmpty())
|
||||
return fail("select err", r.value("error").toString());
|
||||
if (r.value("columns").toStringList() != QStringList() << "title")
|
||||
return fail("select cols", r.value("columns").toStringList().join(","));
|
||||
const QVariantList rows = r.value("rows").toList();
|
||||
if (rows.size() != 2)
|
||||
return fail("select rows");
|
||||
if (rows[0].toList().size() != 1 || rows[0].toList()[0].toString() != "a")
|
||||
return fail("select cell");
|
||||
}
|
||||
{
|
||||
QVariantMap r = db.queryTable("SELECT path, title FROM files ORDER BY title");
|
||||
const QVariantList rows = r.value("rows").toList();
|
||||
if (rows.size() != 2 || rows[0].toList().size() != 2)
|
||||
return fail("select shape");
|
||||
if (!rows[0].toList()[0].toString().endsWith("/a.md")
|
||||
|| rows[0].toList()[1].toString() != "a")
|
||||
return fail("select cells", rows[0].toList()[0].toString());
|
||||
}
|
||||
// --- Пишущие запросы режутся ---
|
||||
{
|
||||
QVariantMap r = db.queryTable("INSERT INTO tags (name) VALUES ('x')");
|
||||
if (r.value("error").toString().isEmpty())
|
||||
return fail("insert не зарезан");
|
||||
QVariantMap r2 = db.queryTable("SELECT * FROM tags; DELETE FROM tags");
|
||||
if (r2.value("error").toString().isEmpty())
|
||||
return fail("multi не зарезан");
|
||||
// SELECT с запрещённым словом внутри строки — можно (фильтр по коду, не по строке)
|
||||
QVariantMap r3 = db.queryTable("SELECT 'update' AS u");
|
||||
if (!r3.value("error").toString().isEmpty())
|
||||
return fail("строка update зря зарезана", r3.value("error").toString());
|
||||
}
|
||||
// --- Свойства: чтение + запись ---
|
||||
{
|
||||
QVariantMap p = db.fileProperties(vault + "/a.md");
|
||||
if (p.value("статус").toString() != "в работе")
|
||||
return fail("props read");
|
||||
QVariantMap np;
|
||||
np.insert("статус", "готово");
|
||||
np.insert("оценка", "5");
|
||||
const QString err = db.saveFileProperties(vault + "/a.md", np);
|
||||
if (!err.isEmpty())
|
||||
return fail("props save", err);
|
||||
index.updateFile(vault + "/a.md");
|
||||
db.syncFile(vault + "/a.md");
|
||||
QVariantMap p2 = db.fileProperties(vault + "/a.md");
|
||||
if (p2.value("статус").toString() != "готово" || p2.value("оценка").toString() != "5")
|
||||
return fail("props roundtrip");
|
||||
// тело и ссылка не пострадали
|
||||
QFile f(vault + "/a.md");
|
||||
f.open(QIODevice::ReadOnly);
|
||||
const QString t = QString::fromUtf8(f.readAll());
|
||||
if (!t.contains("[[b]]") || !t.contains("#тег1"))
|
||||
return fail("props body", t);
|
||||
// плохое имя свойства
|
||||
QVariantMap bad;
|
||||
bad.insert("плохо имя!", "x");
|
||||
if (db.saveFileProperties(vault + "/a.md", bad).isEmpty())
|
||||
return fail("props bad key");
|
||||
}
|
||||
|
||||
// --- MdParser: ~sql с базой → таблица ---
|
||||
{
|
||||
MdParser p;
|
||||
p.setDatabaseForVault(vault, &db);
|
||||
const QVariantList blocks = p.parseBlocks(
|
||||
"Текст\n\n```~sql\nSELECT title FROM files ORDER BY title\n```\n", vault, vault);
|
||||
if (blocks.size() != 2)
|
||||
return fail("sql blocks", QString::number(blocks.size()));
|
||||
if (blocks[1].toMap().value("type").toString() != "html")
|
||||
return fail("sql type", blocks[1].toMap().value("type").toString());
|
||||
const QString html = blocks[1].toMap().value("html").toString();
|
||||
// title = имя файла без расширения; пузырь кодоблока; имена — ссылки
|
||||
if (!html.contains("<table") || !html.contains(">a</a>") || !html.contains(">b</a>"))
|
||||
return fail("sql table", html.left(300));
|
||||
if (!html.contains("#2e4d6a"))
|
||||
return fail("sql bubble", html.left(200));
|
||||
// plain render тоже таблицей
|
||||
const QString h = p.render("```~sql\nSELECT title FROM files\n```\n", vault, vault);
|
||||
if (!h.contains("<table"))
|
||||
return fail("sql render", h.left(300));
|
||||
// ошибка запроса → красный бокс, не падение
|
||||
const QVariantList be = p.parseBlocks("```~sql\nSELECT * FROM нет_таблицы\n```\n", vault, vault);
|
||||
if (!be[0].toMap().value("html").toString().contains("SQL:"))
|
||||
return fail("sql errbox");
|
||||
}
|
||||
// --- MdParser: ~sql без базы → исходник как код ---
|
||||
{
|
||||
MdParser p;
|
||||
const QVariantList blocks = p.parseBlocks("```~sql\nSELECT 1\n```\n", vault, vault);
|
||||
if (blocks.size() != 1 || blocks[0].toMap().value("type").toString() != "code")
|
||||
return fail("sql fallback");
|
||||
}
|
||||
// --- MdParser: шапка не светится в просмотре ---
|
||||
{
|
||||
MdParser p;
|
||||
const QString h = p.render("---\nстатус: секрет\n---\n\n# Заголовок\n", vault, vault);
|
||||
if (h.contains("секрет") || !h.contains("Заголовок"))
|
||||
return fail("fm hidden", h.left(200));
|
||||
}
|
||||
// --- Свойства в файл без шапки: тело сохраняется ---
|
||||
{
|
||||
writeFile(vault + "/plain.md", "# Обычная\n\nТекст без шапки\n");
|
||||
index.rebuild(vault);
|
||||
db.reindex();
|
||||
QVariantMap np;
|
||||
np.insert("k", "v");
|
||||
if (!db.saveFileProperties(vault + "/plain.md", np).isEmpty())
|
||||
return fail("plain props save");
|
||||
QFile f(vault + "/plain.md");
|
||||
f.open(QIODevice::ReadOnly);
|
||||
const QString t = QString::fromUtf8(f.readAll());
|
||||
if (!t.contains("Текст без шапки") || !t.contains("k: v"))
|
||||
return fail("plain body kept", t.left(200));
|
||||
}
|
||||
|
||||
// --- Связность: вызовы из одного файла в другие через базу ---
|
||||
// Граф: index -> a, index -> b, a -> b, c — изолят.
|
||||
QTemporaryDir dir2;
|
||||
if (!dir2.isValid())
|
||||
return fail("temp dir2");
|
||||
const QString v2 = dir2.path();
|
||||
writeFile(v2 + "/index.md",
|
||||
"---\nтип: хаб\n---\n\n# Хаб\n\n[[a]] [[b]]\n\n"
|
||||
"```~sql\n"
|
||||
"SELECT f2.title AS связанные FROM files f1\n"
|
||||
" JOIN links l ON l.src_id = f1.id\n"
|
||||
" JOIN files f2 ON f2.id = l.dst_id\n"
|
||||
"WHERE f1.path LIKE '%index.md' ORDER BY f2.title;\n"
|
||||
"```\n");
|
||||
writeFile(v2 + "/a.md", "# A #общее\n\n[[b]]\n");
|
||||
writeFile(v2 + "/b.md", "# B #общее\n");
|
||||
writeFile(v2 + "/c.md", "# C\n\nИзолят\n");
|
||||
VaultIndex index2;
|
||||
index2.rebuild(v2);
|
||||
if (index2.fileCount() != 4)
|
||||
return fail("link index count", QString::number(index2.fileCount()));
|
||||
Database db2;
|
||||
db2.setIndex(&index2);
|
||||
if (!db2.open(v2))
|
||||
return fail("link db open");
|
||||
QVariantMap st2 = db2.stats();
|
||||
if (st2.value("files").toInt() != 4)
|
||||
return fail("link files");
|
||||
if (st2.value("links").toInt() != 3) // index->a, index->b, a->b
|
||||
return fail("link edges", QString::number(st2.value("links").toInt()));
|
||||
if (st2.value("tags").toInt() != 1)
|
||||
return fail("link tags");
|
||||
|
||||
auto titles = [&](const QVariantMap &r) {
|
||||
QStringList out;
|
||||
for (const QVariant &row : r.value("rows").toList())
|
||||
out.append(row.toList().value(0).toString());
|
||||
return out;
|
||||
};
|
||||
// Прямые соседи index.md
|
||||
{
|
||||
QVariantMap r = db2.queryTable(
|
||||
"SELECT f2.title FROM files f1 JOIN links l ON l.src_id = f1.id "
|
||||
"JOIN files f2 ON f2.id = l.dst_id "
|
||||
"WHERE f1.path LIKE '%index.md' ORDER BY f2.title");
|
||||
if (!r.value("error").toString().isEmpty())
|
||||
return fail("fwd err", r.value("error").toString());
|
||||
if (titles(r) != QStringList({"a", "b"}))
|
||||
return fail("fwd index", titles(r).join(","));
|
||||
}
|
||||
// Обратные ссылки на b.md (кто ссылается)
|
||||
{
|
||||
QVariantMap r = db2.queryTable(
|
||||
"SELECT f.title FROM files f JOIN links l ON l.src_id = f.id "
|
||||
"JOIN files d ON d.id = l.dst_id "
|
||||
"WHERE d.path LIKE '%b.md' ORDER BY f.title");
|
||||
if (titles(r) != QStringList({"a", "index"}))
|
||||
return fail("backlinks b", titles(r).join(","));
|
||||
}
|
||||
// Достижимость из index.md за любое число шагов (рекурсия) — c.md вне
|
||||
{
|
||||
QVariantMap r = db2.queryTable(
|
||||
"WITH RECURSIVE reach(id) AS ("
|
||||
" SELECT id FROM files WHERE path LIKE '%index.md'"
|
||||
" UNION"
|
||||
" SELECT l.dst_id FROM links l JOIN reach r ON l.src_id = r.id)"
|
||||
" SELECT f.title FROM files f JOIN reach r ON r.id = f.id"
|
||||
" WHERE f.path NOT LIKE '%index.md' ORDER BY f.title");
|
||||
if (!r.value("error").toString().isEmpty())
|
||||
return fail("reach err", r.value("error").toString());
|
||||
if (titles(r) != QStringList({"a", "b"}))
|
||||
return fail("reach index", titles(r).join(","));
|
||||
}
|
||||
// Изолят: у c.md нет рёбер ни в одну сторону
|
||||
{
|
||||
QVariantMap r = db2.queryTable(
|
||||
"SELECT COUNT(*) FROM links l JOIN files f ON f.id IN (l.src_id, l.dst_id)"
|
||||
" WHERE f.path LIKE '%c.md'");
|
||||
if (r.value("rows").toList().value(0).toList().value(0).toString() != "0")
|
||||
return fail("isolate c");
|
||||
}
|
||||
// filePathForTitle: точное имя → путь, нет/несколько → пусто
|
||||
{
|
||||
if (db2.filePathForTitle("a") != v2 + "/a.md")
|
||||
return fail("title lookup", db2.filePathForTitle("a"));
|
||||
if (!db2.filePathForTitle("нет-такого").isEmpty())
|
||||
return fail("title missing");
|
||||
}
|
||||
// ~sql внутри index.md видит другие файлы (и не видит изолят).
|
||||
// Имена — ссылки flora://open, пути — flora://copycode (клик = в буфер).
|
||||
{
|
||||
MdParser p;
|
||||
p.setDatabaseForVault(v2, &db2);
|
||||
const QVariantList blocks = p.parseBlocks(
|
||||
"```~sql\nSELECT title, path FROM files WHERE title IN ('a','b','c') ORDER BY title\n```\n",
|
||||
v2, v2);
|
||||
if (blocks.size() != 1 || blocks[0].toMap().value("type").toString() != "html")
|
||||
return fail("xfile type");
|
||||
const QString h = blocks[0].toMap().value("html").toString();
|
||||
if (!h.contains("#2e4d6a"))
|
||||
return fail("xfile bubble", h.left(200));
|
||||
// имя a открывает файл
|
||||
const QString openA = "flora://open/" + QString::fromLatin1(QUrl::toPercentEncoding(v2 + "/a.md"));
|
||||
if (!h.contains(openA))
|
||||
return fail("xfile open link", h.left(500));
|
||||
// путь a копируется (spath_* зарегистрирован)
|
||||
if (!h.contains("flora://copycode/spath_"))
|
||||
return fail("xfile copy link", h.left(500));
|
||||
if (p.getCodeBlock("spath_0") != v2 + "/a.md"
|
||||
&& p.getCodeBlock("spath_1") != v2 + "/a.md"
|
||||
&& p.getCodeBlock("spath_2") != v2 + "/a.md")
|
||||
return fail("xfile copy target");
|
||||
// изолят c тоже в выдаче (запрос его выбрал), но имя так же кликабельно
|
||||
if (!h.contains(">c</a>"))
|
||||
return fail("xfile c link", h.left(500));
|
||||
// title без path в строке резолвится через базу
|
||||
const QVariantList b2 = p.parseBlocks("```~sql\nSELECT title FROM files WHERE title='b'\n```\n", v2, v2);
|
||||
const QString h2 = b2[0].toMap().value("html").toString();
|
||||
const QString openB = "flora://open/" + QString::fromLatin1(QUrl::toPercentEncoding(v2 + "/b.md"));
|
||||
if (!h2.contains(openB))
|
||||
return fail("xfile title lookup", h2.left(500));
|
||||
}
|
||||
// Рендер index.md целиком: таблица есть, изолята нет, шапки нет
|
||||
{
|
||||
MdParser p;
|
||||
p.setDatabaseForVault(v2, &db2);
|
||||
QFile f(v2 + "/index.md");
|
||||
f.open(QIODevice::ReadOnly);
|
||||
const QString h = p.render(QString::fromUtf8(f.readAll()), v2, v2);
|
||||
if (!h.contains(">a</a>") || !h.contains(">b</a>"))
|
||||
return fail("xfile table", h.left(300));
|
||||
if (h.contains(">c</td>") || h.contains(">c</a>"))
|
||||
return fail("xfile leak isolate");
|
||||
if (h.contains("тип: хаб"))
|
||||
return fail("xfile fm leak");
|
||||
}
|
||||
// Межфайловые свойства: статусы соседей index.md
|
||||
{
|
||||
QVariantMap pa;
|
||||
pa.insert("статус", "в работе");
|
||||
if (!db2.saveFileProperties(v2 + "/a.md", pa).isEmpty())
|
||||
return fail("xfile props save");
|
||||
index2.updateFile(v2 + "/a.md");
|
||||
db2.syncFile(v2 + "/a.md");
|
||||
QVariantMap r = db2.queryTable(
|
||||
"SELECT f2.title, p.value FROM files f1 JOIN links l ON l.src_id = f1.id "
|
||||
"JOIN files f2 ON f2.id = l.dst_id "
|
||||
"JOIN properties p ON p.file_id = f2.id "
|
||||
"WHERE f1.path LIKE '%index.md' AND p.key = 'статус'");
|
||||
const QVariantList rows = r.value("rows").toList();
|
||||
if (rows.size() != 1 || rows[0].toList().value(0).toString() != "a"
|
||||
|| rows[0].toList().value(1).toString() != "в работе")
|
||||
return fail("xfile props query", r.value("error").toString());
|
||||
}
|
||||
// Динамика: a.md ссылается на c.md — изолят подключается
|
||||
{
|
||||
writeFile(v2 + "/a.md", "# A #общее\n\n[[b]] [[c]]\n");
|
||||
index2.updateFile(v2 + "/a.md");
|
||||
db2.syncFile(v2 + "/a.md");
|
||||
if (db2.stats().value("links").toInt() != 4)
|
||||
return fail("dyn link+");
|
||||
QVariantMap r = db2.queryTable(
|
||||
"SELECT f.title FROM files f JOIN links l ON l.src_id = f.id "
|
||||
"JOIN files d ON d.id = l.dst_id "
|
||||
"WHERE d.path LIKE '%c.md'");
|
||||
if (titles(r) != QStringList({"a"}))
|
||||
return fail("dyn backlinks c", titles(r).join(","));
|
||||
}
|
||||
// Динамика: удаление c.md чистит рёбра
|
||||
{
|
||||
QFile::remove(v2 + "/c.md");
|
||||
index2.removeFile(v2 + "/c.md");
|
||||
db2.syncRemove(v2 + "/c.md");
|
||||
QVariantMap s = db2.stats();
|
||||
if (s.value("files").toInt() != 3 || s.value("links").toInt() != 3)
|
||||
return fail("dyn remove",
|
||||
QString("files=%1 links=%2").arg(s.value("files").toInt())
|
||||
.arg(s.value("links").toInt()));
|
||||
}
|
||||
|
||||
std::printf("db_test OK\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -129,6 +129,26 @@ int main(int argc, char *argv[])
|
||||
|| dm.isTextForEditor("/x/a.pdf"))
|
||||
return fail("isTextForEditor");
|
||||
|
||||
// --- autosaveEnabled (ручной режим настроек) ---
|
||||
if (!dm.autosaveEnabled())
|
||||
return fail("autosave enabled by default");
|
||||
{
|
||||
QTimer *t = dm.findChild<QTimer*>();
|
||||
if (!t || !t->isActive())
|
||||
return fail("autosave timer active");
|
||||
}
|
||||
dm.setAutosaveEnabled(false);
|
||||
if (dm.autosaveEnabled())
|
||||
return fail("autosave disabled");
|
||||
{
|
||||
QTimer *t = dm.findChild<QTimer*>();
|
||||
if (!t || t->isActive())
|
||||
return fail("autosave timer stopped");
|
||||
}
|
||||
dm.setAutosaveEnabled(true);
|
||||
if (!dm.autosaveEnabled())
|
||||
return fail("autosave re-enabled");
|
||||
|
||||
std::printf("DOCUMENT MANAGER TEST OK\n");
|
||||
return 0;
|
||||
}
|
||||
+78
-29
@@ -49,7 +49,7 @@ int main(int argc, char *argv[])
|
||||
if(!git.hasCommits(vault)) return fail("hasCommits");
|
||||
|
||||
// История после v1: 1 элемент
|
||||
QVariantList hist = git.fileHistory(vault, file);
|
||||
QVariantList hist = git.fileHistory(vault, file, 500);
|
||||
if(hist.size()!=1) return fail("hist size 1", QString::number(hist.size()));
|
||||
QString hash1 = hist.first().toMap().value("hash").toString();
|
||||
QString short1 = hist.first().toMap().value("shortHash").toString();
|
||||
@@ -66,20 +66,14 @@ int main(int argc, char *argv[])
|
||||
// content at
|
||||
QString c1 = git.fileContentAt(vault, file, hash1);
|
||||
if(!c1.contains("Версия 1")) return fail("content v1", c1);
|
||||
// diff с текущим (одинаково) должен быть "Нет изменений" или пусто?
|
||||
QString diffSame = git.fileDiffText(vault, file, hash1);
|
||||
if(diffSame != "Нет изменений" && !diffSame.isEmpty()) {
|
||||
// если файл не менялся, diff должен быть "Нет изменений"
|
||||
// Но мы ещё не меняли, так что должно быть нет изменений
|
||||
if(!diffSame.contains("Нет изменений"))
|
||||
return fail("diff same should be нет изменений", diffSame);
|
||||
}
|
||||
// workdirContent читает текущее с диска
|
||||
if(git.workdirContent(file) != "Версия 1\nСтрока A\n") return fail("workdirContent", git.workdirContent(file));
|
||||
|
||||
// Изменяем файл → v2
|
||||
QThread::msleep(1100); // чтобы время коммита отличалось
|
||||
QFile f2(file); f2.open(QIODevice::WriteOnly | QIODevice::Truncate); f2.write("Версия 2\nСтрока A\nСтрока B\n"); f2.close();
|
||||
if(!git.commitAll(vault, "v2 - добавили строку B")) return fail("commit v2", git.lastError());
|
||||
QVariantList hist2 = git.fileHistory(vault, file);
|
||||
QVariantList hist2 = git.fileHistory(vault, file, 500);
|
||||
if(hist2.size()!=2) return fail("hist size 2", QString::number(hist2.size()));
|
||||
// Новый сверху
|
||||
QString hash2 = hist2.first().toMap().value("hash").toString();
|
||||
@@ -95,18 +89,12 @@ int main(int argc, char *argv[])
|
||||
// content at hash1 всё ещё v1
|
||||
QString c1again = git.fileContentAt(vault, file, hash1);
|
||||
if(!c1again.contains("Версия 1") || c1again.contains("Строка B")) return fail("c1again", c1again);
|
||||
// diff hash1 vs current (v2): должен показать + строка B и -Версия1 +Версия2
|
||||
QString diff = git.fileDiffText(vault, file, hash1);
|
||||
if(!diff.contains("+") || !diff.contains("-")) return fail("diff should have + -", diff);
|
||||
if(!diff.contains("Версия 1") || !diff.contains("Версия 2")) return fail("diff contains versions", diff);
|
||||
QString diffHtml = git.fileDiffHtml(vault, file, hash1);
|
||||
if(!diffHtml.contains("background:#b7f5c6") || !diffHtml.contains("background:#ffd6dd")) return fail("diffHtml colors", diffHtml.left(500));
|
||||
|
||||
// forward/backward: добавляем ещё файл other.md, коммитим, история note.md не должна вырасти
|
||||
QString other = vault+"/other.md";
|
||||
QFile of(other); of.open(QIODevice::WriteOnly); of.write("other\n"); of.close();
|
||||
if(!git.commitAll(vault, "add other")) return fail("commit other");
|
||||
QVariantList histAfterOther = git.fileHistory(vault, file);
|
||||
QVariantList histAfterOther = git.fileHistory(vault, file, 500);
|
||||
if(histAfterOther.size()!=2) return fail("hist after other should still 2", QString::number(histAfterOther.size()));
|
||||
|
||||
// Восстановление назад к v1
|
||||
@@ -115,7 +103,7 @@ int main(int argc, char *argv[])
|
||||
QFile check(file); check.open(QIODevice::ReadOnly); QString cur = QString::fromUtf8(check.readAll());
|
||||
if(!cur.contains("Версия 1") || cur.contains("Строка B")) return fail("after restore v1 content", cur);
|
||||
// История после восстановления должна иметь 3 элемента (v1, v2, восстановление)
|
||||
QVariantList hist3 = git.fileHistory(vault, file);
|
||||
QVariantList hist3 = git.fileHistory(vault, file, 500);
|
||||
if(hist3.size()!=3) return fail("hist after restore size 3", QString::number(hist3.size()));
|
||||
if(!hist3.first().toMap().value("message").toString().contains("Восстановление")) return fail("restore commit message", hist3.first().toMap().value("message").toString());
|
||||
// lastModified должно быть сейчас (только что)
|
||||
@@ -129,29 +117,21 @@ int main(int argc, char *argv[])
|
||||
if(!err.isEmpty()) return fail("restore forward to v2", err);
|
||||
QFile check2(file); check2.open(QIODevice::ReadOnly); QString cur2 = QString::fromUtf8(check2.readAll());
|
||||
if(!cur2.contains("Версия 2")) return fail("after restore forward v2", cur2);
|
||||
QVariantList hist4 = git.fileHistory(vault, file);
|
||||
QVariantList hist4 = git.fileHistory(vault, file, 500);
|
||||
if(hist4.size()!=4) return fail("hist after forward restore 4", QString::number(hist4.size()));
|
||||
|
||||
// Ещё одно изменение вручную вперёд
|
||||
QThread::msleep(1100);
|
||||
QFile f3(file); f3.open(QIODevice::WriteOnly | QIODevice::Truncate); f3.write("Версия 3\nНовая строка\n"); f3.close();
|
||||
if(!git.commitAll(vault, "v3")) return fail("commit v3");
|
||||
QVariantList hist5 = git.fileHistory(vault, file);
|
||||
QVariantList hist5 = git.fileHistory(vault, file, 500);
|
||||
if(hist5.size()!=5) return fail("hist 5", QString::number(hist5.size()));
|
||||
if(hist5.first().toMap().value("message").toString()!="v3") return fail("top v3");
|
||||
|
||||
// Проверка что diff между v1 и текущим (v3) показывает изменения
|
||||
QString diffV1Now = git.fileDiffText(vault, file, hash1);
|
||||
if(!diffV1Now.contains("Версия 1") || !diffV1Now.contains("Версия 3")) return fail("diff v1->v3", diffV1Now);
|
||||
|
||||
// Проверка несуществующего коммита
|
||||
QString badContent = git.fileContentAt(vault, file, "0000000000000000000000000000000000000000");
|
||||
if(!badContent.isEmpty()) return fail("bad hash should return empty");
|
||||
|
||||
QString badDiff = git.fileDiffText(vault, file, "0000000000000000000000000000000000000000");
|
||||
// должен либо вернуть ошибку diff, либо содержать old empty
|
||||
// не проверяем строго
|
||||
|
||||
QString badRestore = git.restoreFile(vault, file, "0000000000000000000000000000000000000000");
|
||||
if(badRestore.isEmpty()) return fail("bad restore should fail");
|
||||
|
||||
@@ -166,7 +146,7 @@ int main(int argc, char *argv[])
|
||||
QFile f4(file); f4.open(QIODevice::WriteOnly | QIODevice::Append); f4.write("\nauto\n"); f4.close();
|
||||
bool autoOk = git.autoCommit(vault, file);
|
||||
if(!autoOk) return fail("autoCommit", git.lastError());
|
||||
QVariantList histAuto = git.fileHistory(vault, file);
|
||||
QVariantList histAuto = git.fileHistory(vault, file, 500);
|
||||
if(histAuto.size()!=6) return fail("hist after auto 6", QString::number(histAuto.size()));
|
||||
if(!histAuto.first().toMap().value("message").toString().contains("Автосохранение")) return fail("auto message", histAuto.first().toMap().value("message").toString());
|
||||
|
||||
@@ -174,6 +154,75 @@ int main(int argc, char *argv[])
|
||||
bool autoAgain = git.autoCommit(vault, file);
|
||||
if(!autoAgain) return fail("auto again should be true even if no changes");
|
||||
|
||||
// Пагинация: limit режет сверху, порядок новые сверху, кэш консистентен
|
||||
{
|
||||
QVariantList full = git.fileHistory(vault, file, 500);
|
||||
QVariantList part = git.fileHistory(vault, file, 2);
|
||||
if(part.size()!=2) return fail("paged size", QString::number(part.size()));
|
||||
if(part.first().toMap().value("hash") != full.first().toMap().value("hash"))
|
||||
return fail("paged order");
|
||||
// повторный запрос с тем же лимитом — из кэша, тот же результат
|
||||
QVariantList part2 = git.fileHistory(vault, file, 2);
|
||||
if(part2.size()!=2 || part2.first().toMap().value("hash") != part.first().toMap().value("hash"))
|
||||
return fail("paged cache");
|
||||
// limit<=0 — как раньше (всё)
|
||||
if(git.fileHistory(vault, file, 0).size() != full.size()) return fail("limit 0");
|
||||
// разреженная история: 30 чужих коммитов — касания файла всё равно находятся,
|
||||
// hasMore-логика (limit+1) при этом не врёт
|
||||
for (int i = 0; i < 30; ++i) {
|
||||
QFile fn(vault+"/noise.md");
|
||||
if(!fn.open(QIODevice::WriteOnly | QIODevice::Append)) return fail("noise open");
|
||||
fn.write(QString("n%1\n").arg(i).toUtf8());
|
||||
fn.close();
|
||||
if(!git.commitAll(vault, QString("noise %1").arg(i))) return fail("noise commit", git.lastError());
|
||||
}
|
||||
QVariantList sparse = git.fileHistory(vault, file, 3);
|
||||
if(sparse.size() != 3) return fail("sparse size", QString::number(sparse.size()));
|
||||
if(sparse.first().toMap().value("hash") != full.first().toMap().value("hash"))
|
||||
return fail("sparse order");
|
||||
}
|
||||
|
||||
// fileExistedAt: отличает «пустой файл» от «файла не было»
|
||||
// (коммит удаления в историю файла не попадает — файла нет в дереве,
|
||||
// поэтому false проверяем коммитом, предшествующим созданию файла)
|
||||
{
|
||||
if(!git.fileExistedAt(vault, file, hash1)) return fail("existed v1");
|
||||
const QString late = vault+"/late.md";
|
||||
QFile lf(late);
|
||||
if(!lf.open(QIODevice::WriteOnly)) return fail("late create");
|
||||
lf.write("late\n");
|
||||
lf.close();
|
||||
if(!git.commitAll(vault, "late file")) return fail("commit late", git.lastError());
|
||||
QString lateHash = git.fileHistory(vault, late, 500).first().toMap().value("hash").toString();
|
||||
if(!git.fileExistedAt(vault, late, lateHash)) return fail("late existed");
|
||||
if(git.fileExistedAt(vault, late, hash1)) return fail("late should not exist at v1");
|
||||
if(git.fileExistedAt(vault, late, "0000000000000000000000000000000000000000"))
|
||||
return fail("bad hash existed");
|
||||
}
|
||||
|
||||
// commitFile: точечный коммит одного файла (второй файл не трогаем)
|
||||
{
|
||||
QFile re(file);
|
||||
if(!re.open(QIODevice::WriteOnly)) return fail("recreate");
|
||||
re.write("v-final\n");
|
||||
re.close();
|
||||
if(!git.commitFile(vault, file, "final A")) return fail("commitFile A", git.lastError());
|
||||
const int histA = git.fileHistory(vault, file, 500).size();
|
||||
// правим оба файла
|
||||
QFile fa(file);
|
||||
fa.open(QIODevice::WriteOnly | QIODevice::Append); fa.write("more A\n"); fa.close();
|
||||
QFile fb(vault+"/other.md");
|
||||
fb.open(QIODevice::WriteOnly | QIODevice::Append); fb.write("more B\n"); fb.close();
|
||||
const int histBBefore = git.fileHistory(vault, vault+"/other.md", 500).size();
|
||||
if(!git.commitFile(vault, file, "final B")) return fail("commitFile B", git.lastError());
|
||||
if(git.fileHistory(vault, file, 500).size() != histA + 1) return fail("commitFile history A");
|
||||
if(git.fileHistory(vault, vault+"/other.md", 500).size() != histBBefore) return fail("commitFile затронул B");
|
||||
if(!git.fileHistory(vault, file, 500).first().toMap().value("message").toString().contains("final B"))
|
||||
return fail("commitFile message");
|
||||
// commitFile без изменений — false с понятной ошибкой, как commitAll
|
||||
if(git.commitFile(vault, file, "noop")) return fail("commitFile noop should fail");
|
||||
}
|
||||
|
||||
std::printf("GIT HISTORY TEST OK (forward/backward, diff, restore, time)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
+68
-8
@@ -5,6 +5,8 @@
|
||||
#include <QTemporaryDir>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QEventLoop>
|
||||
#include <QTimer>
|
||||
#include <cstdio>
|
||||
|
||||
#include "markdown/MdParser.h"
|
||||
@@ -136,11 +138,11 @@ int main(int argc, char *argv[])
|
||||
|
||||
// Подсветка: переносы строк — явные <br/>, иначе RichText склеит в одну строку
|
||||
{
|
||||
QString hl = p.highlightedCode("int a = 1;\nint b = 2;\n", "cpp");
|
||||
auto b = p.parseBlocks("```cpp\nint a = 1;\nint b = 2;\n```\n", vault, fileDir);
|
||||
if (b.size() != 1) return fail("br блок");
|
||||
const QString hl = b[0].toMap().value("highlightedText").toString();
|
||||
if (!hl.contains("<br/>")) return fail("highlighted без <br/>", hl.left(200));
|
||||
if (hl.contains("\n")) return fail("highlighted с сырым \\n", hl.left(200));
|
||||
QString plain = p.highlightedCode("a\nb", "неизвестный-язык-xyz");
|
||||
if (!plain.contains("<br/>")) return fail("plain highlight без <br/>", plain);
|
||||
}
|
||||
|
||||
// parseBlocksWithDiff: версия old vs текущее new
|
||||
@@ -149,19 +151,20 @@ int main(int argc, char *argv[])
|
||||
const QString newMd = "# Заголовок\n\nНовая строка\n\n```cpp\nint x = 2;\n```\n";
|
||||
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
||||
if (d.isEmpty()) return fail("diff пуст");
|
||||
// порядок как у стандартного diff: сначала все del изменённого куска, потом все add
|
||||
// порядок как у стандартного diff: сначала все del изменённого куска, потом все add,
|
||||
// поэтому пары del/add здесь не соседствуют и merge не срабатывает — отдельные блоки
|
||||
auto changeOf = [&](int i) { return d[i].toMap().value("change").toString(); };
|
||||
if (d.size() != 5) return fail("diff размер", QString::number(d.size()));
|
||||
if (changeOf(0) != "same") return fail("diff[0] должен быть same", changeOf(0));
|
||||
if (d[0].toMap().value("type").toString() != "html") return fail("diff[0] тип");
|
||||
if (changeOf(1) != "del") return fail("diff[1] должен быть del", changeOf(1));
|
||||
if (!d[1].toMap().value("html").toString().contains("ffd6dd")) return fail("diff[1] без розовой подсветки");
|
||||
if (!d[1].toMap().value("html").toString().contains("244,63,94")) return fail("diff[1] без розовой подсветки");
|
||||
if (changeOf(2) != "del" || d[2].toMap().value("type").toString() != "code")
|
||||
return fail("diff[2] код del", changeOf(2));
|
||||
if (!d[2].toMap().value("highlightedText").toString().contains("244,63,94"))
|
||||
return fail("diff[2] код без розовой подсветки");
|
||||
if (changeOf(3) != "add") return fail("diff[3] должен быть add", changeOf(3));
|
||||
if (!d[3].toMap().value("html").toString().contains("b7f5c6")) return fail("diff[3] без зелёной подсветки");
|
||||
if (!d[3].toMap().value("html").toString().contains("34,197,94")) return fail("diff[3] без зелёной подсветки");
|
||||
if (changeOf(4) != "add" || d[4].toMap().value("type").toString() != "code")
|
||||
return fail("diff[4] код add", changeOf(4));
|
||||
// кодовые блоки сохранили id для копирования
|
||||
@@ -177,12 +180,69 @@ int main(int argc, char *argv[])
|
||||
for (const auto &v : d) {
|
||||
if (v.toMap().value("change").toString() != "same")
|
||||
return fail("identical должен быть same");
|
||||
if (v.toMap().value("html").toString().contains("ffd6dd")
|
||||
|| v.toMap().value("html").toString().contains("b7f5c6"))
|
||||
if (v.toMap().value("html").toString().contains("244,63,94")
|
||||
|| v.toMap().value("html").toString().contains("34,197,94"))
|
||||
return fail("identical с подсветкой");
|
||||
}
|
||||
}
|
||||
|
||||
// ~~~-заборы тоже разбираются на code-блоки (единая разбивка splitMdBlocks)
|
||||
{
|
||||
auto b = p.parseBlocks("~~~\ncode\n~~~\n", vault, fileDir);
|
||||
if (b.size() != 1 || b[0].toMap().value("type").toString() != "code")
|
||||
return fail("tilde fence");
|
||||
if (!b[0].toMap().value("codeText").toString().contains("code"))
|
||||
return fail("tilde code");
|
||||
}
|
||||
|
||||
// parseBlocksWithDiff: правка в обычном тексте — два отдельных tinted-блока
|
||||
// (del, затем add), как стандартный diff; словного уровня нет
|
||||
{
|
||||
const QString oldMd = "# T\n\nOld words here\n";
|
||||
const QString newMd = "# T\n\nNew words here\n";
|
||||
auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
||||
if (d.size() != 3) return fail("text diff размер", QString::number(d.size()));
|
||||
if (d[0].toMap().value("change").toString() != "same") return fail("text diff[0] same");
|
||||
if (d[1].toMap().value("change").toString() != "del"
|
||||
|| d[2].toMap().value("change").toString() != "add")
|
||||
return fail("text diff del/add");
|
||||
if (!d[1].toMap().value("html").toString().contains("244,63,94"))
|
||||
return fail("text del без розового");
|
||||
if (!d[2].toMap().value("html").toString().contains("34,197,94"))
|
||||
return fail("text add без зелёного");
|
||||
}
|
||||
|
||||
// parseBlocksWithDiffAsync: тот же результат, что sync, id совпадает
|
||||
{
|
||||
const QString oldMd = "# T\n\n```cpp\nint a = 1;\n```\n";
|
||||
const QString newMd = "# T\n\n```cpp\nint a = 2;\n```\n";
|
||||
QVariantList expect = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir);
|
||||
const int reqId = p.parseBlocksWithDiffAsync(oldMd, newMd, vault, fileDir);
|
||||
QVariantList got;
|
||||
int gotId = -1;
|
||||
QObject::connect(&p, &MdParser::blocksReady,
|
||||
[&](int id, const QVariantList &b) { gotId = id; got = b; });
|
||||
QEventLoop loop;
|
||||
QTimer timer;
|
||||
timer.setSingleShot(true);
|
||||
QObject::connect(&p, &MdParser::blocksReady, &loop, &QEventLoop::quit);
|
||||
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||
timer.start(10000);
|
||||
loop.exec();
|
||||
if (gotId != reqId) return fail("async id", QString::number(gotId));
|
||||
if (got.size() != expect.size()) return fail("async размер", QString::number(got.size()));
|
||||
for (int i = 0; i < got.size(); ++i) {
|
||||
const QVariantMap g = got[i].toMap(), e = expect[i].toMap();
|
||||
if (g.value("type") != e.value("type") || g.value("change") != e.value("change"))
|
||||
return fail("async блок", QString::number(i));
|
||||
if (g.value("type").toString() == "code") {
|
||||
// копирование работает и после async (id свежие, код — новый)
|
||||
if (!p.getCodeBlock(g.value("codeId").toString()).contains("int a = 2;"))
|
||||
return fail("async copy", p.getCodeBlock(g.value("codeId").toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри
|
||||
{
|
||||
const QString oldMd = "# T\n\n```cpp\nint a = 1;\nint b = 2;\n```\n";
|
||||
|
||||
@@ -124,6 +124,28 @@ int main(int argc, char *argv[])
|
||||
if (after.contains(vault + "|" + "old.md")) return fail("old key not migrated");
|
||||
}
|
||||
|
||||
// autosave presets
|
||||
if (s.autosavePreset() != "balanced") return fail("default preset");
|
||||
if (s.autosaveDiskMs() != 5000) return fail("balanced disk");
|
||||
if (s.autocommitIdleMs() != 15000) return fail("balanced idle");
|
||||
if (!s.autosaveEnabled() || !s.autocommitEnabled()) return fail("balanced enabled");
|
||||
s.setAutosavePreset("safe");
|
||||
if (s.autosavePreset() != "safe") return fail("safe preset");
|
||||
if (s.autosaveDiskMs() != 2000 || s.autocommitIdleMs() != 5000) return fail("safe values");
|
||||
s.setAutosavePreset("eco");
|
||||
if (s.autosaveDiskMs() != 10000 || s.autocommitIdleMs() != 60000) return fail("eco values");
|
||||
s.setAutosavePreset("manual");
|
||||
if (s.autosaveEnabled() || s.autocommitEnabled()) return fail("manual disabled");
|
||||
if (s.autosaveDiskMs() != 0 || s.autocommitIdleMs() != 0) return fail("manual zeros");
|
||||
s.setAutosavePreset(" wat? ");
|
||||
if (s.autosavePreset() != "balanced") return fail("invalid preset fallback");
|
||||
s.setAutosavePreset("eco");
|
||||
{
|
||||
SettingsStore s2;
|
||||
if (s2.autosavePreset() != "eco") return fail("preset persistence");
|
||||
if (s2.autosaveDiskMs() != 10000) return fail("preset values persistence");
|
||||
}
|
||||
|
||||
std::printf("SETTINGS TEST OK\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,75 @@ int main(int argc, char *argv[])
|
||||
if (files2.size() != 2)
|
||||
return fail("filesWithTag(тег2)", QString::number(files2.size()));
|
||||
|
||||
// --- инкремент: updateFile меняет только один файл (убираем теги из a.md) ---
|
||||
put("a.md", "# Заголовок\n\nСсылка на [[b]].\n");
|
||||
idx.updateFile(a);
|
||||
if (idx.fileCount() != 3)
|
||||
return fail("updateFile fileCount", QString::number(idx.fileCount()));
|
||||
// a.md теперь без тегов — тег1 остался только... нигде: filesWithTag пуст
|
||||
if (!idx.filesWithTag("тег1").isEmpty())
|
||||
return fail("updateFile снял тег1", idx.filesWithTag("тег1").join(","));
|
||||
if (idx.tagsOfFile(a).size() != 0)
|
||||
return fail("updateFile tagsOfFile", idx.tagsOfFile(a).join(","));
|
||||
// b.md не трогали — его теги на месте
|
||||
if (!idx.tagsOfFile(b).contains("тег2"))
|
||||
return fail("updateFile не тронул b");
|
||||
|
||||
// --- инкремент: новый файл чинит висячую ссылку (relink) ---
|
||||
put("d.md", "Ссылка на [[future]].\n");
|
||||
const QString d = dir.path() + "/d.md";
|
||||
idx.updateFile(d);
|
||||
if (idx.fileCount() != 4)
|
||||
return fail("updateFile new fileCount", QString::number(idx.fileCount()));
|
||||
if (!idx.linksOfFile(d).isEmpty())
|
||||
return fail("висячая ссылка должна быть пустой");
|
||||
put("sub/future.md", "# Future\n");
|
||||
const QString future = dir.path() + "/sub/future.md";
|
||||
idx.updateFile(future);
|
||||
{
|
||||
bool foundFuture = false;
|
||||
for (const auto &l : idx.linksOfFile(d))
|
||||
if (l.toMap().value("path").toString() == future)
|
||||
foundFuture = true;
|
||||
if (!foundFuture)
|
||||
return fail("relink не починил [[future]]");
|
||||
}
|
||||
|
||||
// --- инкремент: removeFile чистит теги/ссылки/узел ---
|
||||
// (в реальности вызывается потому что файл удалён — удаляем и с диска)
|
||||
QFile::remove(future);
|
||||
idx.removeFile(future);
|
||||
if (idx.fileCount() != 4)
|
||||
return fail("removeFile fileCount", QString::number(idx.fileCount()));
|
||||
{
|
||||
bool foundFuture = false;
|
||||
for (const auto &l : idx.linksOfFile(d))
|
||||
if (l.toMap().value("path").toString() == future)
|
||||
foundFuture = true;
|
||||
if (foundFuture)
|
||||
return fail("removeFile не убрал входящую ссылку");
|
||||
}
|
||||
// removeFile несуществующего — тихо, без падения
|
||||
idx.removeFile(dir.path() + "/нет-такого.md");
|
||||
idx.updateFile(dir.path() + "/нет-такого.md");
|
||||
|
||||
// --- инкремент: syncIncremental догоняет внешние изменения ---
|
||||
put("ext.md", "Внешний #внешний\n");
|
||||
const QString ext = dir.path() + "/ext.md";
|
||||
if (idx.fileCount() != 4)
|
||||
return fail("до sync файл не виден", QString::number(idx.fileCount()));
|
||||
idx.syncIncremental();
|
||||
if (idx.fileCount() != 5)
|
||||
return fail("sync подхватил файл", QString::number(idx.fileCount()));
|
||||
if (!idx.tagsOfFile(ext).contains("внешний"))
|
||||
return fail("sync теги");
|
||||
QFile::remove(ext);
|
||||
idx.syncIncremental();
|
||||
if (idx.fileCount() != 4)
|
||||
return fail("sync убрал файл", QString::number(idx.fileCount()));
|
||||
if (idx.tagsOfFile(ext).size() != 0)
|
||||
return fail("sync теги удалены");
|
||||
|
||||
std::printf("VAULTINDEX TEST OK\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user