Compare commits

..

11 Commits

Author SHA1 Message Date
grendervill e304f503e5 Пузырь ~sql как у кода: фикс невалидного CSS, разделители строк
- Удалены max-width:88%%/box-shadow: невалидный %% гасил весь
  style в Qt rich text (пузырь не красился); заодно в кодоблоке
- Таблица без width=100% (вылезала за панель), td вместо th
  (чёрная плашка заголовка), border-top разделители,
  zero-width space в путях для переносов
2026-09-07 17:20:11 +03:00
grendervill 51372ace4e ~sql в пузыре кодоблока, кликабельные имена и пути
- renderSqlBlock: та же рамка что у кода (Telegram-пузырь), таблица
  светлым по тёмно-синему, кнопка копирования запроса
- Имя файла (title) — ссылка flora://open (новая вкладка рядом);
  путь — ссылка flora://copycode/spath_N (клик копирует путь)
- Database::filePathForTitle: резолв имени в путь (пусто при
  отсутствии/неоднозначности)
- Тесты пузыря, open/copy ссылок и резолва
2026-09-07 17:03:07 +03:00
grendervill 8e8cf3ce70 Графический браузер базы данных (DatabaseBrowser)
- Диалог: готовые выборки (файлы/теги/свойства/связи), свой SQL,
  результат таблицей, пути кликабельны, ошибки красным
- Ctrl+Enter для выполнения, автопересчёт при изменении базы
- Меню Файл -> База данных (Ctrl+D)
2026-09-07 16:39:32 +03:00
grendervill f6e1814945 Тесты связности базы + фикс syncFile (не рвать входящие рёбра)
- db_test: граф index->a, index->b, a->b + изолят c; прямые/обратные
  ссылки, достижимость через WITH RECURSIVE, ~sql из одного файла
  в другие, межфайловые свойства, подключение изолята, удаление
- Database::clearFileData: syncFile больше не удаляет строку files
  и входящие links (id сохраняется, граф целый)
2026-09-07 16:36:24 +03:00
grendervill fdf59f5b39 SQLite-база хранилища: свойства, ~sql-блоки, настройки хранилища
- Frontmatter: шапка --- ключ: значение (источник правды свойств)
- VaultSettings: .flora/settings.json (журнал, sqlEnabled, sqlMaxRows)
- Database: files/tags/file_tags/links/properties, только SELECT/WITH
  на read-only соединении (mode=ro + query_only + зонд), лимит строк
- MdParser: ~sql-блоки таблицей (parseBlocks + Renderer), шапка скрыта в просмотре
- Окно: per-window Database/VaultSettings, sync при сохранении, дебаунс reindex
- QML: VaultSettingsDialog, FilePropertiesDialog, меню, автообновление ~sql
- FileStore.journalDir, DocumentManager.reloadDocument
- Тест flora_db_test (23/23 зелёных)
2026-09-07 16:30:06 +03:00
grendervill 0a46471cc6 Инкрементальный индекс, пресеты автосейва, упрощение git-слоя
- VaultIndex: updateFile/removeFile/syncIncremental по mtime+size, полный рескан только на старте/смене/внешних правках; relink висячих ссылок; FileStore.externalChanged
- QML: точечные обновления индекса вместо rebuild на каждый сейв
- Настройки: пресеты safe/balanced/eco/manual + диалог с +/- и шкалами; коммит после паузы, в manual — по Ctrl+S
- GitManager: кэш истории, пагинация, diff в потоке, точечный commitFile, fileExistedAt, топологический порядок истории
- Удалены highlightedCode, словный merge, fileDiffText/Html, 2-arg fileHistory
2026-09-07 01:06:21 +03:00
grendervill 417d0273b7 Инкрементальный индекс, пресеты автосейва, диалог Настройки
- VaultIndex: updateFile/removeFile/syncIncremental (mtime+size), полный рескан только на старте/смене/внешних изменениях; relink висячих [[ссылок]] при создании файла; FileStore.externalChanged для внешних правок
- QML: сейвы и правки идут точечно, дерево — через syncIncremental
- Настройки: пресеты safe/balanced/eco/manual (диск 2/5/10с/выкл, коммит после паузы 5/15/60с/вручную), диалог с +/-, шкалами и подсказкой; коммит только в простое, в manual — по Ctrl+S
- Удалены highlightedCode, словный merge, fileDiffText/Html, 2-arg fileHistory; renderInner без побочек
- Тесты: инкремент индекса, пресеты, autosaveEnabled, ~~~-блоки
2026-09-06 23:02:05 +03:00
grendervill 1f140d3433 Упрощение git/превью: copyCodeBlock, parseBlocks на splitMdBlocks, кэш diff, пагинация
- MdParser.copyCodeBlock(id): один вызов вместо get+copy в 3 QML-местах
- parseBlocks переписан на splitMdBlocks (убит regex только для ```, заодно чинит ~~~); renderInner без побочек, save/restore удалены
- Кэш diff-блоков (хеши+пути, 8 шт, >1МБ не кэшируем); регистрация кодов всегда в главном потоке
- История: пагинация limit+Показать ещё с сохранением выбора; willDelete; busy только на время async
- GitManager: commitFile точечно (общий commitFromIndex), fileExistedAt, workdirContent через DocumentManager.readTextFile, fileGitStatus через relativePath; удалены fileDiffText/Html и 2-arg fileHistory
- MarkdownView: дебаунс async-ветки 80мс + гашение протухших ответов
2026-09-06 22:17:13 +03:00
grendervill 0846605c2b Git: кэш+пагинация истории, diff в потоке, точечный коммит, честное удаление
- История: кэш по vault|rel|headOid, fileHistory(vault,file,limit), в диалоге Показать ещё по 60 с сохранением выбора
- Diff считается в QtConcurrent-пуле (parseBlocksWithDiffAsync+blocksReady, stale-guard), спиннер busy; MarkdownView.diffApplied
- commitFile точечно вместо всего хранилища (общий commitFromIndex), autoCommit честный; fileExistedAt + willDelete в подтверждении
- DocumentManager.readTextFile общий (GitManager.workdirContent через него); fileGitStatus через relativePath
- Тесты: пагинация/кэш, existed, commitFile не трогает B, async==sync
2026-09-06 21:17:25 +03:00
grendervill f6074df0fc Упрощение git: только просмотр с diff, без словного уровня, render без побочек
- История: убран таб текстового diff — только Просмотр версии с подсветкой; пустые состояния через emptyMessage
- GitManager: удалены fileDiffText/fileDiffHtml (+escHtml), остался fileContentAt/workdirContent/history/restore
- MdParser: удалён словный merge (плейсхолдеры/tokenize/risiko), текст — отдельными del/add как diff; renderInner без сброса codeBlocks, render() — clear+renderInner; убраны save/restore хаки
- Тесты обновлены
2026-09-06 20:49:14 +03:00
grendervill 781e1dfc12 История: словный diff в том же текстовом блоке + единая палитра
- Текст del/add парой сливается в ОДИН блок (change=mod): словный LCS по токенам, плейсхолдеры U+E000/U+E001, рендер, подстановка цветных span только в текстовых узлах; ссылки/URL/код — fallback на два блока чтобы не рвать разметку
- Единые насыщенные цвета везде: rgba(244,63,94,.45)/rgba(34,197,94,.45) — html тинты, строки кода, слова, текстовый diff, легенда (hex с альфой в QML)
- Тесты: text merge, link fallback, цвета
2026-09-06 20:30:01 +03:00
41 changed files with 3933 additions and 562 deletions
+12 -1
View File
@@ -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 как синглтон
+72
View File
@@ -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
+187
View File
@@ -0,0 +1,187 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Flora
// Графическая работа с базой хранилища: готовые выборки, свой SQL,
// результат таблицей (пути кликабельны — открывают заметку).
Dialog {
id: root
title: qsTr("База данных хранилища")
modal: false
width: 760
height: 620
anchors.centerIn: parent
closePolicy: Dialog.CloseOnEscape
standardButtons: Dialog.Close
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
property string lastError: ""
function esc(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;")
}
function openHref(href) {
if (href.startsWith("flora://open/"))
documentManager.openDocument(decodeURIComponent(href.substring(13)))
}
function runQuery() {
const res = vaultDatabase.queryTable(sqlEdit.text)
if (res.error.length > 0) {
root.lastError = res.error
errorLabel.visible = true
resultText.text = ""
countLabel.text = ""
return
}
root.lastError = ""
errorLabel.visible = false
const cols = res.columns
const rows = res.rows
let h = "<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">"
h += "<tr>"
for (let c = 0; c < cols.length; ++c)
h += "<th align=\"left\">" + esc(cols[c]) + "</th>"
h += "</tr>"
for (let r = 0; r < rows.length; ++r) {
h += "<tr>"
for (let c = 0; c < cols.length; ++c) {
const cell = c < rows[r].length ? String(rows[r][c]) : ""
// значение похоже на путь внутри хранилища — делаем ссылкой
if ((cols[c] === "path" || cell.endsWith(".md"))
&& fileStore.fileExists(cell)) {
h += "<td><a href=\"flora://open/" + encodeURIComponent(cell) + "\">"
+ esc(cell) + "</a></td>"
} else {
h += "<td>" + esc(cell) + "</td>"
}
}
h += "</tr>"
}
h += "</table>"
resultText.text = h
countLabel.text = rows.length === 1 ? qsTr("1 строка")
: qsTr("%1 строк").arg(rows.length)
+ (res.truncated ? qsTr(" (показаны первые)") : "")
}
onOpened: {
errorLabel.visible = false
if (sqlEdit.text.length === 0)
presetFiles()
else
root.runQuery()
}
function presetFiles() {
sqlEdit.text = "SELECT title, path, size FROM files ORDER BY title;"
root.runQuery()
}
function presetTags() {
sqlEdit.text = "SELECT t.name AS тег, COUNT(*) AS файлов FROM tags t\n"
+ " JOIN file_tags ft ON ft.tag_id = t.id\n"
+ " GROUP BY t.name ORDER BY файлов DESC;"
root.runQuery()
}
function presetProps() {
sqlEdit.text = "SELECT f.title, p.key, p.value FROM properties p\n"
+ " JOIN files f ON f.id = p.file_id\n"
+ " ORDER BY f.title, p.key;"
root.runQuery()
}
function presetLinks() {
sqlEdit.text = "SELECT s.title AS от, d.title AS к FROM links l\n"
+ " JOIN files s ON s.id = l.src_id\n"
+ " JOIN files d ON d.id = l.dst_id\n"
+ " ORDER BY от, к;"
root.runQuery()
}
// База изменилась (сохранение, свойства) — пересчитать открытое
Timer { id: refreshDebounce; interval: 800; onTriggered: root.runQuery() }
Connections {
target: vaultDatabase
function onChanged() { if (root.visible) refreshDebounce.restart() }
}
ColumnLayout {
anchors.fill: parent
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 6
Label { text: qsTr("Выборки:"); color: Theme.textSecondary }
Button { text: qsTr("Файлы"); onClicked: root.presetFiles() }
Button { text: qsTr("Теги"); onClicked: root.presetTags() }
Button { text: qsTr("Свойства"); onClicked: root.presetProps() }
Button { text: qsTr("Связи"); onClicked: root.presetLinks() }
Item { Layout.fillWidth: true }
Label {
id: countLabel
color: Theme.textFaint
font.pixelSize: 11
}
}
ScrollView {
Layout.fillWidth: true
Layout.preferredHeight: 130
TextArea {
id: sqlEdit
font.family: "Menlo"
wrapMode: TextEdit.NoWrap
placeholderText: qsTr("SELECT … (только чтение)")
Keys.onPressed: (e) => {
if ((e.modifiers & Qt.ControlModifier) && e.key === Qt.Key_Return) {
root.runQuery()
e.accepted = true
}
}
}
}
RowLayout {
Layout.fillWidth: true
Button {
text: qsTr("Выполнить (Ctrl+Enter)")
highlighted: true
onClicked: root.runQuery()
}
Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
color: Theme.textFaint
font.pixelSize: 11
text: qsTr("Таблицы: files, tags, file_tags, links, properties. Только SELECT / WITH.")
}
}
Label {
id: errorLabel
visible: false
Layout.fillWidth: true
wrapMode: Text.Wrap
color: "#e5484d"
text: root.lastError
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
Text {
id: resultText
width: parent.width
wrapMode: Text.Wrap
textFormat: Text.RichText
color: Theme.text
onLinkActivated: (href) => root.openHref(href)
}
}
}
}
+181
View File
@@ -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
}
}
}
+63 -83
View File
@@ -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()
+224
View File
@@ -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 }
}
}
}
}
+163
View File
@@ -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
}
}
}
+1 -3
View File
@@ -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
View File
@@ -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)
+9 -2
View File
@@ -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() } } }
}
+43 -9
View File
@@ -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)
}
}
}
}
+1 -1
View File
@@ -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 // можно подсветить выбранный день
+17 -3
View File
@@ -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 {
+52 -3
View File
@@ -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())
+7 -1
View File
@@ -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;
};
+44 -4
View File
@@ -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())
+15
View File
@@ -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
View File
@@ -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))
+10
View File
@@ -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;
+93
View File
@@ -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;
}
+31
View File
@@ -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);
};
+51 -1
View File
@@ -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";
}
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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;
};
+130
View File
@@ -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();
}
+44
View File
@@ -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;
};
+664
View File
@@ -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%';");
}
+91
View File
@@ -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
View File
@@ -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 += "&amp;"; break;
case '<': out += "&lt;"; break;
case '>': out += "&gt;"; break;
case '"': out += "&quot;"; break;
case '\'': out += "&#39;"; 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+423
View File
@@ -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;
}
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+22
View File
@@ -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;
}
+69
View File
@@ -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;
}