Инкрементальный индекс, пресеты автосейва, диалог Настройки
- 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, ~~~-блоки
This commit is contained in:
@@ -131,6 +131,7 @@ qt_add_qml_module(flora
|
|||||||
qml/dialogs/ConfirmDialog.qml
|
qml/dialogs/ConfirmDialog.qml
|
||||||
qml/dialogs/TableEditor.qml
|
qml/dialogs/TableEditor.qml
|
||||||
qml/dialogs/HistoryDialog.qml
|
qml/dialogs/HistoryDialog.qml
|
||||||
|
qml/dialogs/SettingsDialog.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
# Theme как синглтон
|
# Theme как синглтон
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ ApplicationWindow {
|
|||||||
// статус можно показать в UI, пока просто игнорируем
|
// статус можно показать в UI, пока просто игнорируем
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyAutosavePreset() {
|
||||||
|
if (settingsStore.autosaveEnabled) {
|
||||||
|
documentManager.setAutosaveIntervalMs(settingsStore.autosaveDiskMs)
|
||||||
|
documentManager.setAutosaveEnabled(true)
|
||||||
|
} else {
|
||||||
|
documentManager.setAutosaveEnabled(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
if (windowVault.length > 0) {
|
if (windowVault.length > 0) {
|
||||||
fileStore.openVault(windowVault)
|
fileStore.openVault(windowVault)
|
||||||
@@ -58,6 +67,11 @@ ApplicationWindow {
|
|||||||
if (vaultManager.vaults.length === 0) {
|
if (vaultManager.vaults.length === 0) {
|
||||||
welcomeWizard.open()
|
welcomeWizard.open()
|
||||||
}
|
}
|
||||||
|
applyAutosavePreset()
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: settingsStore
|
||||||
|
function onAutosavePresetChanged() { applyAutosavePreset() }
|
||||||
}
|
}
|
||||||
|
|
||||||
menuBar: MenuBar {
|
menuBar: MenuBar {
|
||||||
@@ -72,6 +86,7 @@ ApplicationWindow {
|
|||||||
Action { text: qsTr("В PDF…"); enabled: false }
|
Action { text: qsTr("В PDF…"); enabled: false }
|
||||||
}
|
}
|
||||||
MenuSeparator { }
|
MenuSeparator { }
|
||||||
|
Action { text: qsTr("Настройки…"); onTriggered: settingsDialog.open() }
|
||||||
Action { text: qsTr("Выход"); onTriggered: window.close() }
|
Action { text: qsTr("Выход"); onTriggered: window.close() }
|
||||||
}
|
}
|
||||||
Menu {
|
Menu {
|
||||||
@@ -164,6 +179,10 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsDialog {
|
||||||
|
id: settingsDialog
|
||||||
|
}
|
||||||
|
|
||||||
Popup {
|
Popup {
|
||||||
id: vaultSwitcher
|
id: vaultSwitcher
|
||||||
x: 12
|
x: 12
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-13
@@ -782,23 +782,26 @@ Item {
|
|||||||
}
|
}
|
||||||
refreshGitStatus()
|
refreshGitStatus()
|
||||||
}
|
}
|
||||||
|
function armAutoCommit(path) {
|
||||||
|
// Коммит только после паузы в печати (idle), а не на каждое сохранение
|
||||||
|
if (!settingsStore.autocommitEnabled || !fileStore.ready || !path || path.length === 0)
|
||||||
|
return
|
||||||
|
autoCommitDebounce.filePath = path
|
||||||
|
autoCommitDebounce.restart()
|
||||||
|
}
|
||||||
Connections {
|
Connections {
|
||||||
target: documentManager
|
target: documentManager
|
||||||
function onActiveIndexChanged() { root.loadContent() }
|
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 onOpenTabsChanged() { root.loadContent() }
|
||||||
function onDirtyFlagChanged() { refreshGitStatus() }
|
function onDirtyFlagChanged() { refreshGitStatus() }
|
||||||
function onDocumentSaved(idx) {
|
function onDocumentSaved(idx) {
|
||||||
refreshGitStatus()
|
refreshGitStatus()
|
||||||
// Авто-коммит для простого пользователя — каждый сохранённый файл попадает в историю
|
if (idx >= 0) root.armAutoCommit(documentManager.pathAt(idx))
|
||||||
if (fileStore.ready && idx >= 0) {
|
|
||||||
const p = documentManager.pathAt(idx)
|
|
||||||
if (p && p.length>0) {
|
|
||||||
// Небольшая задержка чтобы не спамить на каждый автосейв
|
|
||||||
autoCommitDebounce.filePath = p
|
|
||||||
autoCommitDebounce.restart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
|
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
|
||||||
@@ -806,9 +809,9 @@ Item {
|
|||||||
Timer {
|
Timer {
|
||||||
id: autoCommitDebounce
|
id: autoCommitDebounce
|
||||||
property string filePath: ""
|
property string filePath: ""
|
||||||
interval: 2000
|
interval: settingsStore.autocommitIdleMs
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (fileStore.ready && filePath.length>0) {
|
if (settingsStore.autocommitEnabled && fileStore.ready && filePath.length>0) {
|
||||||
gitManager.autoCommit(fileStore.vault, filePath)
|
gitManager.autoCommit(fileStore.vault, filePath)
|
||||||
refreshGitStatus()
|
refreshGitStatus()
|
||||||
}
|
}
|
||||||
@@ -996,7 +999,16 @@ Item {
|
|||||||
|
|
||||||
TableEditor { id: tableEditor; onMarkdownReady: (md) => { textArea.insert(textArea.cursorPosition, md); documentManager.updateContent(documentManager.activeIndex, textArea.text) } }
|
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+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
|
||||||
Shortcut { sequence: "Ctrl+H"; onActivated: {
|
Shortcut { sequence: "Ctrl+H"; onActivated: {
|
||||||
const p = documentManager.pathAt(documentManager.activeIndex)
|
const p = documentManager.pathAt(documentManager.activeIndex)
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ Item {
|
|||||||
if (drop.source && drop.source.dragPath && fileStore.ready) {
|
if (drop.source && drop.source.dragPath && fileStore.ready) {
|
||||||
const err = fileStore.moveItem(drop.source.dragPath, fileStore.vault)
|
const err = fileStore.moveItem(drop.source.dragPath, fileStore.vault)
|
||||||
if (err.length > 0) showStatus(err, false)
|
if (err.length > 0) showStatus(err, false)
|
||||||
|
else vaultIndex.syncIncremental()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,6 +283,7 @@ Item {
|
|||||||
if (!rowRect.isDir || !drop.source || !drop.source.dragPath) return
|
if (!rowRect.isDir || !drop.source || !drop.source.dragPath) return
|
||||||
const err = fileStore.moveItem(drop.source.dragPath, rowRect.path)
|
const err = fileStore.moveItem(drop.source.dragPath, rowRect.path)
|
||||||
if (err.length > 0) root.showStatus(err, false)
|
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)
|
if (pendingCreate.kind==="file") err=fileStore.createFile(createIn,name)
|
||||||
else if (pendingCreate.kind==="folder") err=fileStore.createFolder(createIn,name)
|
else if (pendingCreate.kind==="folder") err=fileStore.createFolder(createIn,name)
|
||||||
else if (pendingCreate.kind==="rename") err=fileStore.renameItem(renameTarget.path,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=""
|
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() } } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,16 +26,31 @@ Item {
|
|||||||
target: fileStore
|
target: fileStore
|
||||||
function onVaultChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
function onVaultChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||||
function onReadyChanged() { 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 {
|
Connections {
|
||||||
target: documentManager
|
target: documentManager
|
||||||
function onActiveIndexChanged() { root.refreshLinksTags() }
|
function onActiveIndexChanged() { root.refreshLinksTags() }
|
||||||
function onOpenTabsChanged() { root.refreshLinksTags() }
|
function onOpenTabsChanged() { root.refreshLinksTags() }
|
||||||
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); rebuildDebounce.restart() } }
|
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); indexDebounce.restart() } }
|
||||||
function onDocumentSaved() { rebuildDebounce.restart() }
|
function onDocumentSaved(idx, path) {
|
||||||
|
if (fileStore.ready && path && path.length > 0) vaultIndex.updateFile(path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } }
|
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 {
|
SplitView {
|
||||||
|
|||||||
@@ -63,6 +63,21 @@ void DocumentManager::setSideBySide(bool on)
|
|||||||
emit sideBySideChanged();
|
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; }
|
int DocumentManager::autosaveIntervalMs() const { return m_autosaveMs; }
|
||||||
void DocumentManager::setAutosaveIntervalMs(int ms)
|
void DocumentManager::setAutosaveIntervalMs(int ms)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class DocumentManager : public QObject
|
|||||||
Q_PROPERTY(bool sideBySide READ sideBySide WRITE setSideBySide NOTIFY sideBySideChanged)
|
Q_PROPERTY(bool sideBySide READ sideBySide WRITE setSideBySide NOTIFY sideBySideChanged)
|
||||||
Q_PROPERTY(int autosaveIntervalMs READ autosaveIntervalMs WRITE setAutosaveIntervalMs
|
Q_PROPERTY(int autosaveIntervalMs READ autosaveIntervalMs WRITE setAutosaveIntervalMs
|
||||||
NOTIFY autosaveIntervalChanged)
|
NOTIFY autosaveIntervalChanged)
|
||||||
|
Q_PROPERTY(bool autosaveEnabled READ autosaveEnabled WRITE setAutosaveEnabled
|
||||||
|
NOTIFY autosaveEnabledChanged)
|
||||||
Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged)
|
Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
@@ -31,6 +33,8 @@ public:
|
|||||||
Q_INVOKABLE void setSideBySide(bool on);
|
Q_INVOKABLE void setSideBySide(bool on);
|
||||||
int autosaveIntervalMs() const;
|
int autosaveIntervalMs() const;
|
||||||
Q_INVOKABLE void setAutosaveIntervalMs(int ms);
|
Q_INVOKABLE void setAutosaveIntervalMs(int ms);
|
||||||
|
bool autosaveEnabled() const;
|
||||||
|
Q_INVOKABLE void setAutosaveEnabled(bool on);
|
||||||
QString lastStatus() const;
|
QString lastStatus() const;
|
||||||
|
|
||||||
// Открывает документ (при необходимости создаётся вкладка). Возвращает индекс.
|
// Открывает документ (при необходимости создаётся вкладка). Возвращает индекс.
|
||||||
@@ -60,6 +64,7 @@ signals:
|
|||||||
void secondaryIndexChanged();
|
void secondaryIndexChanged();
|
||||||
void sideBySideChanged();
|
void sideBySideChanged();
|
||||||
void autosaveIntervalChanged();
|
void autosaveIntervalChanged();
|
||||||
|
void autosaveEnabledChanged();
|
||||||
void lastStatusChanged();
|
void lastStatusChanged();
|
||||||
|
|
||||||
// Уведомляет вкладку о смене признака «изменено».
|
// Уведомляет вкладку о смене признака «изменено».
|
||||||
@@ -89,6 +94,7 @@ public:
|
|||||||
int m_secondary = -1;
|
int m_secondary = -1;
|
||||||
bool m_sideBySide = false;
|
bool m_sideBySide = false;
|
||||||
int m_autosaveMs = 5000;
|
int m_autosaveMs = 5000;
|
||||||
|
bool m_autosaveEnabled = true;
|
||||||
QString m_lastStatus;
|
QString m_lastStatus;
|
||||||
QTimer *m_autosave = nullptr;
|
QTimer *m_autosave = nullptr;
|
||||||
};
|
};
|
||||||
@@ -12,7 +12,7 @@ FileStore::FileStore(QObject *parent)
|
|||||||
{
|
{
|
||||||
m_debounce.setSingleShot(true);
|
m_debounce.setSingleShot(true);
|
||||||
m_debounce.setInterval(300);
|
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,
|
connect(&m_watcher, &QFileSystemWatcher::directoryChanged,
|
||||||
this, &FileStore::scheduleRebuild);
|
this, &FileStore::scheduleRebuild);
|
||||||
}
|
}
|
||||||
@@ -223,6 +223,12 @@ void FileStore::scheduleRebuild()
|
|||||||
m_debounce.start();
|
m_debounce.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void FileStore::onExternalTimeout()
|
||||||
|
{
|
||||||
|
rebuild();
|
||||||
|
emit externalChanged();
|
||||||
|
}
|
||||||
|
|
||||||
void FileStore::toggleExpand(int row)
|
void FileStore::toggleExpand(int row)
|
||||||
{
|
{
|
||||||
if (row < 0 || row >= m_rows.size() || !m_rows[row].isDir)
|
if (row < 0 || row >= m_rows.size() || !m_rows[row].isDir)
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ signals:
|
|||||||
void readyChanged();
|
void readyChanged();
|
||||||
void fileActivated(const QString &path);
|
void fileActivated(const QString &path);
|
||||||
void statusMessage(const QString &message);
|
void statusMessage(const QString &message);
|
||||||
|
// Сработало внешнее изменение (синхронизация/другая программа) — дерево уже
|
||||||
|
// перестроено; собственные операции этот сигнал не вызывают.
|
||||||
|
void externalChanged();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Row {
|
struct Row {
|
||||||
@@ -78,6 +81,7 @@ private:
|
|||||||
};
|
};
|
||||||
|
|
||||||
void rebuild();
|
void rebuild();
|
||||||
|
void onExternalTimeout();
|
||||||
void collectDir(const QString &dir, int depth, QVector<Row> &out) const;
|
void collectDir(const QString &dir, int depth, QVector<Row> &out) const;
|
||||||
bool isIgnored(const QString &path) const;
|
bool isIgnored(const QString &path) const;
|
||||||
int rowOf(const QString &path) const;
|
int rowOf(const QString &path) const;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ constexpr auto kKeyTheme = "ui/theme";
|
|||||||
constexpr auto kKeyLeftW = "ui/leftPanelWidth";
|
constexpr auto kKeyLeftW = "ui/leftPanelWidth";
|
||||||
constexpr auto kKeyRightW = "ui/rightPanelWidth";
|
constexpr auto kKeyRightW = "ui/rightPanelWidth";
|
||||||
constexpr auto kKeyViewModes = "ui/viewModes";
|
constexpr auto kKeyViewModes = "ui/viewModes";
|
||||||
|
constexpr auto kKeyAutosavePreset = "autosave/preset";
|
||||||
|
|
||||||
QString encodeKey(const QString &s) {
|
QString encodeKey(const QString &s) {
|
||||||
// Процент-кодирование чтобы избежать интерпретации "/" как разделителя групп в QSettings Ini
|
// Процент-кодирование чтобы избежать интерпретации "/" как разделителя групп в QSettings Ini
|
||||||
@@ -276,4 +277,53 @@ void SettingsStore::setViewModeForFile(const QString &vault, const QString &file
|
|||||||
root.insert(encKey, cleanMode);
|
root.insert(encKey, cleanMode);
|
||||||
s.setValue(kKeyViewModes, root);
|
s.setValue(kKeyViewModes, root);
|
||||||
s.sync();
|
s.sync();
|
||||||
}
|
}
|
||||||
|
QString SettingsStore::autosavePreset() const
|
||||||
|
{
|
||||||
|
QSettings s;
|
||||||
|
const QString p = s.value(kKeyAutosavePreset, QStringLiteral("balanced")).toString();
|
||||||
|
if (p == "safe" || p == "balanced" || p == "eco" || p == "manual")
|
||||||
|
return p;
|
||||||
|
return QStringLiteral("balanced");
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsStore::setAutosavePreset(const QString &p)
|
||||||
|
{
|
||||||
|
QString clean = p.trimmed();
|
||||||
|
if (clean != "safe" && clean != "balanced" && clean != "eco" && clean != "manual")
|
||||||
|
clean = QStringLiteral("balanced");
|
||||||
|
QSettings s;
|
||||||
|
if (s.value(kKeyAutosavePreset).toString() == clean)
|
||||||
|
return;
|
||||||
|
s.setValue(kKeyAutosavePreset, clean);
|
||||||
|
s.sync();
|
||||||
|
emit autosavePresetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsStore::autosaveDiskMs() const
|
||||||
|
{
|
||||||
|
const QString p = autosavePreset();
|
||||||
|
if (p == "safe") return 2000;
|
||||||
|
if (p == "eco") return 10000;
|
||||||
|
if (p == "manual") return 0;
|
||||||
|
return 5000; // balanced
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsStore::autocommitIdleMs() const
|
||||||
|
{
|
||||||
|
const QString p = autosavePreset();
|
||||||
|
if (p == "safe") return 5000;
|
||||||
|
if (p == "eco") return 60000;
|
||||||
|
if (p == "manual") return 0;
|
||||||
|
return 15000; // balanced
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsStore::autosaveEnabled() const
|
||||||
|
{
|
||||||
|
return autosavePreset() != "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsStore::autocommitEnabled() const
|
||||||
|
{
|
||||||
|
return autosavePreset() != "manual";
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ class SettingsStore : public QObject
|
|||||||
Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged)
|
Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged)
|
||||||
Q_PROPERTY(int leftPanelWidth READ leftPanelWidth WRITE setLeftPanelWidth NOTIFY leftPanelWidthChanged)
|
Q_PROPERTY(int leftPanelWidth READ leftPanelWidth WRITE setLeftPanelWidth NOTIFY leftPanelWidthChanged)
|
||||||
Q_PROPERTY(int rightPanelWidth READ rightPanelWidth WRITE setRightPanelWidth NOTIFY rightPanelWidthChanged)
|
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:
|
public:
|
||||||
explicit SettingsStore(QObject *parent = nullptr);
|
explicit SettingsStore(QObject *parent = nullptr);
|
||||||
@@ -43,6 +51,13 @@ public:
|
|||||||
int rightPanelWidth() const;
|
int rightPanelWidth() const;
|
||||||
void setRightPanelWidth(int w);
|
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 QString viewModeForFile(const QString &vault, const QString &filePath) const;
|
||||||
Q_INVOKABLE void setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode);
|
Q_INVOKABLE void setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode);
|
||||||
|
|
||||||
@@ -53,6 +68,7 @@ signals:
|
|||||||
void themeChanged();
|
void themeChanged();
|
||||||
void leftPanelWidthChanged();
|
void leftPanelWidthChanged();
|
||||||
void rightPanelWidthChanged();
|
void rightPanelWidthChanged();
|
||||||
|
void autosavePresetChanged();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QVariantList m_providers;
|
QVariantList m_providers;
|
||||||
|
|||||||
+259
-45
@@ -140,53 +140,11 @@ QString VaultIndex::resolveLink(const QString &fileDir, const QString &target) c
|
|||||||
return QString();
|
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;
|
QSet<QString> seen;
|
||||||
QVariantList uniq;
|
QVariantList uniq;
|
||||||
|
uniq.reserve(m_edges.size());
|
||||||
for (const auto &e : std::as_const(m_edges)) {
|
for (const auto &e : std::as_const(m_edges)) {
|
||||||
const QString k = e.toMap().value("from").toString() + "->" + e.toMap().value("to").toString();
|
const QString k = e.toMap().value("from").toString() + "->" + e.toMap().value("to").toString();
|
||||||
if (!seen.contains(k)) {
|
if (!seen.contains(k)) {
|
||||||
@@ -195,12 +153,258 @@ void VaultIndex::rebuild(const QString &vault)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
m_edges = uniq;
|
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;
|
m_busy = false;
|
||||||
emit busyChanged();
|
emit busyChanged();
|
||||||
emit indexChanged();
|
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
|
void VaultIndex::collectFiles(const QString &dir, int depth, QStringList &out) const
|
||||||
{
|
{
|
||||||
if (depth > 24)
|
if (depth > 24)
|
||||||
@@ -251,8 +455,10 @@ void VaultIndex::parseFile(const QString &path)
|
|||||||
m_filesWithTag[tag].append(path);
|
m_filesWithTag[tag].append(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki)
|
// Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki).
|
||||||
|
// Неразрешённые цели запоминаем — вдруг нужный файл создадут позже.
|
||||||
QVariantList links;
|
QVariantList links;
|
||||||
|
QStringList unresolved;
|
||||||
static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]"));
|
static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]"));
|
||||||
auto wi = wikiRe.globalMatch(text);
|
auto wi = wikiRe.globalMatch(text);
|
||||||
while (wi.hasNext()) {
|
while (wi.hasNext()) {
|
||||||
@@ -270,6 +476,8 @@ void VaultIndex::parseFile(const QString &path)
|
|||||||
addEdge(key, nodeKeyFor(resolved, true));
|
addEdge(key, nodeKeyFor(resolved, true));
|
||||||
ensureNode(nodeKeyFor(resolved, true),
|
ensureNode(nodeKeyFor(resolved, true),
|
||||||
QFileInfo(resolved).fileName(), QStringLiteral("file"));
|
QFileInfo(resolved).fileName(), QStringLiteral("file"));
|
||||||
|
} else if (!unresolved.contains(target)) {
|
||||||
|
unresolved.append(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
static const QRegularExpression mdLinkRe(
|
static const QRegularExpression mdLinkRe(
|
||||||
@@ -286,9 +494,15 @@ void VaultIndex::parseFile(const QString &path)
|
|||||||
addEdge(key, nodeKeyFor(resolved, true));
|
addEdge(key, nodeKeyFor(resolved, true));
|
||||||
ensureNode(nodeKeyFor(resolved, true),
|
ensureNode(nodeKeyFor(resolved, true),
|
||||||
QFileInfo(resolved).fileName(), QStringLiteral("file"));
|
QFileInfo(resolved).fileName(), QStringLiteral("file"));
|
||||||
|
} else if (!unresolved.contains(target)) {
|
||||||
|
unresolved.append(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m_linksOfFile.insert(path, links);
|
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
|
QStringList VaultIndex::tagsOfFile(const QString &path) const
|
||||||
|
|||||||
+34
-3
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
@@ -21,12 +22,22 @@ public:
|
|||||||
|
|
||||||
QVariantList nodes() const { return m_nodes; }
|
QVariantList nodes() const { return m_nodes; }
|
||||||
QVariantList edges() const { return m_edges; }
|
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; }
|
bool busy() const { return m_busy; }
|
||||||
|
|
||||||
// Полное пересканирование хранилища.
|
// Полное пересканирование хранилища (старт, смена хранилища, вручную, внешние изменения).
|
||||||
Q_INVOKABLE void rebuild(const QString &vault);
|
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} для открытия).
|
// Теги файла / ссылки файла (список {text,path} для открытия).
|
||||||
Q_INVOKABLE QStringList tagsOfFile(const QString &path) const;
|
Q_INVOKABLE QStringList tagsOfFile(const QString &path) const;
|
||||||
Q_INVOKABLE QVariantList linksOfFile(const QString &path) const;
|
Q_INVOKABLE QVariantList linksOfFile(const QString &path) const;
|
||||||
@@ -41,13 +52,32 @@ signals:
|
|||||||
void busyChanged();
|
void busyChanged();
|
||||||
|
|
||||||
private:
|
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;
|
QString nodeKeyFor(const QString &path, bool isFile) const;
|
||||||
void collectFiles(const QString &dir, int depth, QStringList &out) const;
|
void collectFiles(const QString &dir, int depth, QStringList &out) const;
|
||||||
void parseFile(const QString &path);
|
void parseFile(const QString &path);
|
||||||
QString resolveLink(const QString &fileDir, const QString &target) const;
|
QString resolveLink(const QString &fileDir, const QString &target) const;
|
||||||
int ensureNode(const QString &key, const QString &name, const QString &type);
|
int ensureNode(const QString &key, const QString &name, const QString &type);
|
||||||
void addEdge(const QString &from, const QString &to);
|
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_nodes;
|
||||||
QVariantList m_edges;
|
QVariantList m_edges;
|
||||||
QHash<QString, int> m_nodeById;
|
QHash<QString, int> m_nodeById;
|
||||||
@@ -55,6 +85,7 @@ private:
|
|||||||
QHash<QString, QVariantList> m_linksOfFile;
|
QHash<QString, QVariantList> m_linksOfFile;
|
||||||
QHash<QString, QStringList> m_filesWithTag;
|
QHash<QString, QStringList> m_filesWithTag;
|
||||||
QHash<QString, QString> m_fileByBase; // имя без расширения → путь
|
QHash<QString, QString> m_fileByBase; // имя без расширения → путь
|
||||||
int m_fileCount = 0;
|
QHash<QString, QStringList> m_unresolvedByFile; // файл → неразрешённые цели ссылок
|
||||||
|
QHash<QString, FileMeta> m_fileMeta; // проиндексированные .md
|
||||||
bool m_busy = false;
|
bool m_busy = false;
|
||||||
};
|
};
|
||||||
@@ -959,14 +959,6 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
|||||||
return blocks;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Потокобезопасное ядро diff-блоков: работает с явно переданным Impl
|
// Потокобезопасное ядро diff-блоков: работает с явно переданным Impl
|
||||||
// (в главном потоке — m, в рабочем — локальный), состояние только в нём.
|
// (в главном потоке — m, в рабочем — локальный), состояние только в нём.
|
||||||
static QVariantList buildDiffBlocks(MdParser::Impl *impl, const QString &oldMd,
|
static QVariantList buildDiffBlocks(MdParser::Impl *impl, const QString &oldMd,
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ public:
|
|||||||
Q_INVOKABLE void copyToClipboard(const QString &text) const;
|
Q_INVOKABLE void copyToClipboard(const QString &text) const;
|
||||||
// Скопировать кодовый блок по id (get + copy одним вызовом). false — нечего копировать.
|
// Скопировать кодовый блок по id (get + copy одним вызовом). false — нечего копировать.
|
||||||
Q_INVOKABLE bool copyCodeBlock(const QString &id) const;
|
Q_INVOKABLE bool copyCodeBlock(const QString &id) const;
|
||||||
Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const;
|
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void blocksReady(int requestId, const QVariantList &blocks);
|
void blocksReady(int requestId, const QVariantList &blocks);
|
||||||
|
|||||||
@@ -129,6 +129,26 @@ int main(int argc, char *argv[])
|
|||||||
|| dm.isTextForEditor("/x/a.pdf"))
|
|| dm.isTextForEditor("/x/a.pdf"))
|
||||||
return fail("isTextForEditor");
|
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");
|
std::printf("DOCUMENT MANAGER TEST OK\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -138,11 +138,11 @@ int main(int argc, char *argv[])
|
|||||||
|
|
||||||
// Подсветка: переносы строк — явные <br/>, иначе RichText склеит в одну строку
|
// Подсветка: переносы строк — явные <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("<br/>")) return fail("highlighted без <br/>", hl.left(200));
|
||||||
if (hl.contains("\n")) return fail("highlighted с сырым \\n", 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
|
// parseBlocksWithDiff: версия old vs текущее new
|
||||||
|
|||||||
@@ -124,6 +124,28 @@ int main(int argc, char *argv[])
|
|||||||
if (after.contains(vault + "|" + "old.md")) return fail("old key not migrated");
|
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");
|
std::printf("SETTINGS TEST OK\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,75 @@ int main(int argc, char *argv[])
|
|||||||
if (files2.size() != 2)
|
if (files2.size() != 2)
|
||||||
return fail("filesWithTag(тег2)", QString::number(files2.size()));
|
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");
|
std::printf("VAULTINDEX TEST OK\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user