Фаза 7: правая панель — VaultIndex (теги/[[вики]]/md-ссылки, граф), MonthCalendar (заметки дня), RightPanel (ссылки+теги, чипы, попап файлов), GraphView (Canvas, drag, клик-открытие); dailyNotePath; тест vaultindex (16/16); self-contained Flora.app
This commit is contained in:
@@ -21,5 +21,9 @@ Thumbs.db
|
|||||||
# Служебные артефакты
|
# Служебные артефакты
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Собранное приложение
|
||||||
|
Flora.app/
|
||||||
|
Flora.dmg
|
||||||
|
|
||||||
# Все markdown-файлы (служебные документы проекта не пушим в gitea)
|
# Все markdown-файлы (служебные документы проекта не пушим в gitea)
|
||||||
*.md
|
*.md
|
||||||
@@ -40,6 +40,7 @@ set(FLORA_HEADERS
|
|||||||
src/core/DocumentManager.h
|
src/core/DocumentManager.h
|
||||||
src/core/FileStore.h
|
src/core/FileStore.h
|
||||||
src/core/SettingsStore.h
|
src/core/SettingsStore.h
|
||||||
|
src/core/VaultIndex.h
|
||||||
src/git/GitManager.h
|
src/git/GitManager.h
|
||||||
src/markdown/MdParser.h
|
src/markdown/MdParser.h
|
||||||
src/sync/SyncManager.h
|
src/sync/SyncManager.h
|
||||||
@@ -51,6 +52,7 @@ set(FLORA_SOURCES
|
|||||||
src/core/DocumentManager.cpp
|
src/core/DocumentManager.cpp
|
||||||
src/core/FileStore.cpp
|
src/core/FileStore.cpp
|
||||||
src/core/SettingsStore.cpp
|
src/core/SettingsStore.cpp
|
||||||
|
src/core/VaultIndex.cpp
|
||||||
src/git/GitManager.cpp
|
src/git/GitManager.cpp
|
||||||
src/markdown/MdParser.cpp
|
src/markdown/MdParser.cpp
|
||||||
src/sync/SyncManager.cpp
|
src/sync/SyncManager.cpp
|
||||||
@@ -114,6 +116,9 @@ qt_add_qml_module(flora
|
|||||||
qml/panels/FormatToolbar.qml
|
qml/panels/FormatToolbar.qml
|
||||||
qml/panels/TbButton.qml
|
qml/panels/TbButton.qml
|
||||||
qml/panels/RightPanel.qml
|
qml/panels/RightPanel.qml
|
||||||
|
qml/panels/TagChip.qml
|
||||||
|
qml/panels/GraphView.qml
|
||||||
|
qml/panels/MonthCalendar.qml
|
||||||
qml/wizard/WelcomeWizard.qml
|
qml/wizard/WelcomeWizard.qml
|
||||||
qml/dialogs/ProviderSetupGuide.qml
|
qml/dialogs/ProviderSetupGuide.qml
|
||||||
qml/dialogs/NameDialog.qml
|
qml/dialogs/NameDialog.qml
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
// Небольшое полотно графа связей: узлы-файлы (синие) и теги (зелёные),
|
||||||
|
// рёбра — связи; узлы можно перетаскивать, клик по файлу открывает его.
|
||||||
|
Canvas {
|
||||||
|
id: g
|
||||||
|
|
||||||
|
property var nodes: [] // [{key,name,type}]
|
||||||
|
property var edges: [] // [{from,to}]
|
||||||
|
signal fileClicked(string path)
|
||||||
|
|
||||||
|
property var pos: ({}) // key -> {x,y}
|
||||||
|
property string dragKey: ""
|
||||||
|
|
||||||
|
function nodeForKey(key) {
|
||||||
|
for (let i = 0; i < nodes.length; ++i)
|
||||||
|
if (nodes[i].key === key) return nodes[i]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function relayout() {
|
||||||
|
pos = {}
|
||||||
|
const n = nodes.length
|
||||||
|
if (n === 0) return
|
||||||
|
const cx = width / 2, cy = height / 2
|
||||||
|
const rad = Math.min(width, height) / 2 - 30
|
||||||
|
for (let i = 0; i < n; ++i) {
|
||||||
|
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
||||||
|
pos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
|
||||||
|
}
|
||||||
|
requestPaint()
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodesChanged: relayout()
|
||||||
|
onWidthChanged: relayout()
|
||||||
|
onHeightChanged: relayout()
|
||||||
|
|
||||||
|
onPaint: {
|
||||||
|
const ctx = getContext("2d")
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
if (nodes.length === 0) return
|
||||||
|
|
||||||
|
// рёбра
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.strokeStyle = "#b9c6d6"
|
||||||
|
for (let i = 0; i < edges.length; ++i) {
|
||||||
|
const f = pos[edges[i].from]
|
||||||
|
const t = pos[edges[i].to]
|
||||||
|
if (!f || !t) continue
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(f.x, f.y)
|
||||||
|
ctx.lineTo(t.x, t.y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// узлы
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
for (let i = 0; i < nodes.length; ++i) {
|
||||||
|
const nd = nodes[i]
|
||||||
|
const p = pos[nd.key]
|
||||||
|
if (!p) continue
|
||||||
|
const isFile = nd.type === "file"
|
||||||
|
const r = isFile ? 6 : 5
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(p.x, p.y, r, 0, 2 * Math.PI)
|
||||||
|
ctx.fillStyle = isFile ? (nd.key === g.dragKey ? "#1a6ce0" : "#3f7fd0")
|
||||||
|
: "#3aa06a"
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = "#ffffff"
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.fillStyle = "#5a6a7d"
|
||||||
|
ctx.font = "9px sans-serif"
|
||||||
|
const label = isFile ? nd.name : nd.name
|
||||||
|
const txt = label.length > 14 ? label.substring(0, 13) + "…" : label
|
||||||
|
ctx.fillText(txt, p.x, p.y + r + 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onPressed: (m) => {
|
||||||
|
dragKey = ""
|
||||||
|
for (const key in g.pos) {
|
||||||
|
const p = g.pos[key]
|
||||||
|
const dx = m.x - p.x, dy = m.y - p.y
|
||||||
|
if (dx * dx + dy * dy <= 144) { // радиус захвата 12px
|
||||||
|
dragKey = key
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onPositionChanged: (m) => {
|
||||||
|
if (dragKey.length > 0 && g.pos[dragKey]) {
|
||||||
|
g.pos[dragKey] = { x: m.x, y: m.y }
|
||||||
|
g.requestPaint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onReleased: (m) => {
|
||||||
|
if (dragKey.length === 0) {
|
||||||
|
for (const key in g.pos) {
|
||||||
|
const p = g.pos[key]
|
||||||
|
const dx = m.x - p.x, dy = m.y - p.y
|
||||||
|
if (dx * dx + dy * dy <= 100) {
|
||||||
|
const nd = g.nodeForKey(key)
|
||||||
|
if (nd && nd.type === "file")
|
||||||
|
g.fileClicked(nd.key)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragKey = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
// Простой календарь: шапка (месяц/год, ←/→), сетка дней.
|
||||||
|
// Клик по дню → onDaySelected("YYYY-MM-DD").
|
||||||
|
Item {
|
||||||
|
id: cal
|
||||||
|
|
||||||
|
signal daySelected(string dateStr)
|
||||||
|
|
||||||
|
property date currentMonth: new Date()
|
||||||
|
readonly property int year: currentMonth.getFullYear()
|
||||||
|
readonly property int month: currentMonth.getMonth() // 0..11
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
function pad(n) { return n < 10 ? "0" + n : "" + n }
|
||||||
|
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridModel() {
|
||||||
|
const first = new Date(year, month, 1)
|
||||||
|
const startDow = first.getDay() // 0=вс
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
|
const list = []
|
||||||
|
for (let i = 0; i < startDow; ++i)
|
||||||
|
list.push(0)
|
||||||
|
for (let d = 1; d <= daysInMonth; ++d)
|
||||||
|
list.push(d)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
ToolButton {
|
||||||
|
text: "<"
|
||||||
|
onClicked: cal.currentMonth = new Date(year, month - 1, 1)
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
font.bold: true
|
||||||
|
text: Qt.locale().monthName(month, Locale.LongFormat) + " " + year
|
||||||
|
}
|
||||||
|
ToolButton {
|
||||||
|
text: ">"
|
||||||
|
onClicked: cal.currentMonth = new Date(year, month + 1, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Repeater {
|
||||||
|
model: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
font.pixelSize: 10
|
||||||
|
color: "#888"
|
||||||
|
text: modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
columns: 7
|
||||||
|
rowSpacing: 1
|
||||||
|
columnSpacing: 1
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: cal.gridModel()
|
||||||
|
delegate: Rectangle {
|
||||||
|
required property int modelData
|
||||||
|
width: cal.width / 7 - 2
|
||||||
|
height: cal.height / 10 - 1
|
||||||
|
radius: 3
|
||||||
|
color: modelData > 0 && modelData === now.day ? "#dbeafe"
|
||||||
|
: (modelData > 0 ? "#ffffff" : "transparent")
|
||||||
|
|
||||||
|
readonly property var now: {
|
||||||
|
const t = new Date()
|
||||||
|
return { day: t.getDate(), month: t.getMonth(), year: t.getFullYear() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: modelData > 0
|
||||||
|
text: modelData
|
||||||
|
font.pixelSize: 11
|
||||||
|
color: modelData > 0 ? "#222" : "#ddd"
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: modelData > 0
|
||||||
|
onClicked: {
|
||||||
|
const d = new Date(cal.year, cal.month, modelData)
|
||||||
|
cal.daySelected(fmtDate(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
-14
@@ -2,36 +2,219 @@ import QtQuick
|
|||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
|
||||||
// Правая панель: календарь + ссылки/теги + граф связей.
|
// Правая панель: календарь (заметки дня), «Ссылки и теги» активного файла,
|
||||||
// Фаза 0 — каркас; контент наполняется в Фазах 7 и 8.
|
// граф связей (файлы + теги, рёбра по ссылкам).
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
function activePath() {
|
||||||
|
return documentManager.pathAt(documentManager.activeIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshLinksTags() {
|
||||||
|
const p = root.activePath()
|
||||||
|
linksModel.clear()
|
||||||
|
if (p.length > 0) {
|
||||||
|
const links = vaultIndex.linksOfFile(p)
|
||||||
|
for (let i = 0; i < links.length; ++i) {
|
||||||
|
linksModel.append({ text: links[i].text, path: links[i].path })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagsModel.clear()
|
||||||
|
const tags = p.length > 0 ? vaultIndex.tagsOfFile(p) : []
|
||||||
|
for (let i = 0; i < tags.length; ++i)
|
||||||
|
tagsModel.append({ tag: tags[i] })
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (fileStore.ready)
|
||||||
|
vaultIndex.rebuild(fileStore.vault)
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: fileStore
|
||||||
|
function onVaultChanged() {
|
||||||
|
if (fileStore.ready)
|
||||||
|
vaultIndex.rebuild(fileStore.vault)
|
||||||
|
}
|
||||||
|
function onReadyChanged() {
|
||||||
|
if (fileStore.ready)
|
||||||
|
vaultIndex.rebuild(fileStore.vault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: documentManager
|
||||||
|
function onActiveIndexChanged() { root.refreshLinksTags() }
|
||||||
|
function onOpenTabsChanged() { root.refreshLinksTags() }
|
||||||
|
function onContentChanged(i) {
|
||||||
|
if (i === documentManager.activeIndex) root.refreshLinksTags()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: vaultIndex
|
||||||
|
function onIndexChanged() { root.refreshLinksTags() }
|
||||||
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
|
// ---------- Календарь ----------
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 218
|
||||||
|
color: "#fafafa"
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 0
|
||||||
|
Label {
|
||||||
|
text: qsTr("Календарь")
|
||||||
|
font.bold: true
|
||||||
|
color: "#555"
|
||||||
|
padding: 6
|
||||||
|
}
|
||||||
|
MonthCalendar {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
onDaySelected: (ds) => {
|
||||||
|
const path = fileStore.dailyNotePath(ds)
|
||||||
|
if (path.length > 0)
|
||||||
|
documentManager.openDocument(path)
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Ссылки и теги активного файла ----------
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: "#fafafa"
|
color: "#f4f4f4"
|
||||||
Label {
|
ColumnLayout {
|
||||||
anchors.centerIn: parent
|
anchors.fill: parent
|
||||||
text: qsTr("Календарь\n(Фаза 7)")
|
anchors.margins: 6
|
||||||
horizontalAlignment: Text.AlignHCenter
|
spacing: 4
|
||||||
color: "#bbb"
|
|
||||||
|
Label {
|
||||||
|
text: qsTr("Ссылки и теги")
|
||||||
|
font.bold: true
|
||||||
|
color: "#555"
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: root.activePath().length > 0
|
||||||
|
? qsTr("Файл: %1").arg(root.baseName(activePath()))
|
||||||
|
: qsTr("Нет активного файла")
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
color: "#888"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
Label { text: qsTr("Ссылки"); color: "#666"; font.pixelSize: 11 }
|
||||||
|
ListView {
|
||||||
|
id: linksList
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: Math.min(120, linksModel.count * 22)
|
||||||
|
model: ListModel { id: linksModel }
|
||||||
|
clip: true
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
width: ListView.view.width
|
||||||
|
height: 22
|
||||||
|
text: model.text
|
||||||
|
font.pixelSize: 12
|
||||||
|
onClicked: documentManager.openDocument(model.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
visible: linksModel.count === 0
|
||||||
|
text: qsTr("— нет ссылок —")
|
||||||
|
color: "#bbb"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
Label { text: qsTr("Теги"); color: "#666"; font.pixelSize: 11 }
|
||||||
|
Flow {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
readonly property var m: ListModel { id: tagsModel }
|
||||||
|
Repeater {
|
||||||
|
model: tagsModel
|
||||||
|
delegate: TagChip {
|
||||||
|
text: "#" + model.tag
|
||||||
|
onClicked: {
|
||||||
|
tagFilesModel.clear()
|
||||||
|
const files = vaultIndex.filesWithTag(model.tag)
|
||||||
|
for (let i = 0; i < files.length; ++i)
|
||||||
|
tagFilesModel.append({ path: files[i], name: baseName(files[i]) })
|
||||||
|
tagFilesTitle.text = qsTr("Файлы с тегом #%1 (%2)").arg(model.tag).arg(files.length)
|
||||||
|
tagFilesPopup.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Граф связей ----------
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: "#f5f5f5"
|
color: "#eef1f5"
|
||||||
Label {
|
ColumnLayout {
|
||||||
anchors.centerIn: parent
|
anchors.fill: parent
|
||||||
text: qsTr("Граф связей\n(Фаза 7)")
|
spacing: 0
|
||||||
horizontalAlignment: Text.AlignHCenter
|
RowLayout {
|
||||||
color: "#bbb"
|
Layout.fillWidth: true
|
||||||
|
Label {
|
||||||
|
text: qsTr("Граф связей")
|
||||||
|
font.bold: true
|
||||||
|
color: "#555"
|
||||||
|
padding: 6
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Button {
|
||||||
|
text: qsTr("Обновить")
|
||||||
|
font.pixelSize: 11
|
||||||
|
implicitHeight: 22
|
||||||
|
onClicked: vaultIndex.rebuild(fileStore.vault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GraphView {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
nodes: vaultIndex.nodes
|
||||||
|
edges: vaultIndex.edges
|
||||||
|
onFileClicked: (path) => documentManager.openDocument(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseName(p) {
|
||||||
|
const idx = p.lastIndexOf("/")
|
||||||
|
return idx >= 0 ? p.substring(idx + 1) : p
|
||||||
|
}
|
||||||
|
|
||||||
|
Popup {
|
||||||
|
id: tagFilesPopup
|
||||||
|
modal: true
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: 300
|
||||||
|
ColumnLayout {
|
||||||
|
width: parent.width
|
||||||
|
Label { id: tagFilesTitle; font.bold: true }
|
||||||
|
ListView {
|
||||||
|
Layout.preferredHeight: Math.min(220, contentHeight)
|
||||||
|
Layout.fillWidth: true
|
||||||
|
model: ListModel { id: tagFilesModel }
|
||||||
|
clip: true
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
width: ListView.view.width
|
||||||
|
text: model.name
|
||||||
|
onClicked: {
|
||||||
|
documentManager.openDocument(model.path)
|
||||||
|
tagFilesPopup.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
|
||||||
|
// Кликабельный «чип» тега.
|
||||||
|
Item {
|
||||||
|
id: chip
|
||||||
|
|
||||||
|
property string text: ""
|
||||||
|
signal clicked()
|
||||||
|
|
||||||
|
implicitWidth: label.implicitWidth + 16
|
||||||
|
implicitHeight: 20
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: height / 2
|
||||||
|
color: mouse.containsMouse ? "#dbeafe" : "#e8eef7"
|
||||||
|
border.color: "#b7c9e0"
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: chip.text
|
||||||
|
font.pixelSize: 10
|
||||||
|
color: "#2b4b74"
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: chip.clicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,6 +261,27 @@ QString FileStore::createFile(const QString &parentPath, const QString &name)
|
|||||||
return QString();
|
return QString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString FileStore::dailyNotePath(const QString &dateStr)
|
||||||
|
{
|
||||||
|
if (m_vault.isEmpty())
|
||||||
|
return QString();
|
||||||
|
if (!QDir().mkpath(m_vault + "/Журнал"))
|
||||||
|
return QString();
|
||||||
|
const QString path = m_vault + "/Журнал/" + dateStr + ".md";
|
||||||
|
if (!QFileInfo::exists(path)) {
|
||||||
|
QFile f(path);
|
||||||
|
if (f.open(QIODevice::WriteOnly)) {
|
||||||
|
f.write(QStringLiteral("# %1\n\n").arg(dateStr).toUtf8());
|
||||||
|
f.close();
|
||||||
|
} else {
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
m_expanded.insert(m_vault + "/Журнал");
|
||||||
|
scheduleRebuild();
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
QString FileStore::createFolder(const QString &parentPath, const QString &name)
|
QString FileStore::createFolder(const QString &parentPath, const QString &name)
|
||||||
{
|
{
|
||||||
const QString dir = parentPath.isEmpty() ? m_vault : parentPath;
|
const QString dir = parentPath.isEmpty() ? m_vault : parentPath;
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ public:
|
|||||||
|
|
||||||
Q_INVOKABLE QString createFile(const QString &parentPath, const QString &name);
|
Q_INVOKABLE QString createFile(const QString &parentPath, const QString &name);
|
||||||
Q_INVOKABLE QString createFolder(const QString &parentPath, const QString &name);
|
Q_INVOKABLE QString createFolder(const QString &parentPath, const QString &name);
|
||||||
|
|
||||||
|
// Заметка дня: Журнал/ГГГГ-ММ-ДД.md (создаётся при отсутствии).
|
||||||
|
Q_INVOKABLE QString dailyNotePath(const QString &dateStr);
|
||||||
Q_INVOKABLE QString renameItem(const QString &path, const QString &newName);
|
Q_INVOKABLE QString renameItem(const QString &path, const QString &newName);
|
||||||
Q_INVOKABLE QString removeItem(const QString &path);
|
Q_INVOKABLE QString removeItem(const QString &path);
|
||||||
Q_INVOKABLE QString moveItem(const QString &srcPath, const QString &destDir);
|
Q_INVOKABLE QString moveItem(const QString &srcPath, const QString &destDir);
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
#include "core/VaultIndex.h"
|
||||||
|
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QStringList kIgnoredNames = {".git", ".flora", ".stfolder"};
|
||||||
|
|
||||||
|
// Извлекает теги #tag из текста (пропуская блоки кода).
|
||||||
|
QStringList extractTags(const QString &text)
|
||||||
|
{
|
||||||
|
QStringList tags;
|
||||||
|
static const QRegularExpression tagRe(
|
||||||
|
QStringLiteral("(?:^|[^\\w#])#([\\p{L}\\w][\\p{L}\\w-]*)"));
|
||||||
|
bool inCode = false;
|
||||||
|
const QStringList lines = text.split(QLatin1Char('\n'));
|
||||||
|
for (const QString &line : lines) {
|
||||||
|
const QString t = line.trimmed();
|
||||||
|
if (t.startsWith(QLatin1String("```"))) {
|
||||||
|
inCode = !inCode;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCode)
|
||||||
|
continue;
|
||||||
|
// Заголовок "# Заголовок" — не тег (после # идёт пробел)
|
||||||
|
if (t.startsWith(QLatin1String("# ")) || t.startsWith(QLatin1String("## ")))
|
||||||
|
continue;
|
||||||
|
auto it = tagRe.globalMatch(line);
|
||||||
|
while (it.hasNext()) {
|
||||||
|
const QString tag = it.next().captured(1);
|
||||||
|
if (!tags.contains(tag))
|
||||||
|
tags.append(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString cleanDir(const QString &p)
|
||||||
|
{
|
||||||
|
QString s = p;
|
||||||
|
while (s.endsWith(QLatin1Char('/')))
|
||||||
|
s.chop(1);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
VaultIndex::VaultIndex(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString VaultIndex::displayName(const QString &nodeKey) const
|
||||||
|
{
|
||||||
|
if (nodeKey.startsWith(QLatin1String("tag:")))
|
||||||
|
return nodeKey.mid(4);
|
||||||
|
const int idx = nodeKey.lastIndexOf(QLatin1Char('/'));
|
||||||
|
return idx >= 0 ? nodeKey.mid(idx + 1) : nodeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString VaultIndex::nodeKeyFor(const QString &path, bool isFile) const
|
||||||
|
{
|
||||||
|
return isFile ? QDir::cleanPath(path) : QStringLiteral("tag:%1").arg(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
int VaultIndex::ensureNode(const QString &key, const QString &name, const QString &type)
|
||||||
|
{
|
||||||
|
auto it = m_nodeById.constFind(key);
|
||||||
|
if (it != m_nodeById.constEnd())
|
||||||
|
return it.value();
|
||||||
|
QVariantMap node;
|
||||||
|
node["key"] = key;
|
||||||
|
node["name"] = name;
|
||||||
|
node["type"] = type;
|
||||||
|
m_nodes.append(node);
|
||||||
|
const int id = m_nodes.size() - 1;
|
||||||
|
m_nodeById.insert(key, id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VaultIndex::addEdge(const QString &from, const QString &to)
|
||||||
|
{
|
||||||
|
if (from == to)
|
||||||
|
return;
|
||||||
|
QVariantMap edge;
|
||||||
|
edge["from"] = from;
|
||||||
|
edge["to"] = to;
|
||||||
|
m_edges.append(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString VaultIndex::resolveLink(const QString &fileDir, const QString &target) const
|
||||||
|
{
|
||||||
|
QString t = target.trimmed();
|
||||||
|
if (t.isEmpty())
|
||||||
|
return QString();
|
||||||
|
if (t.contains(QLatin1String("://")))
|
||||||
|
return QString(); // внешняя
|
||||||
|
QStringList cands;
|
||||||
|
cands << cleanDir(fileDir) + '/' + t
|
||||||
|
<< cleanDir(fileDir) + '/' + t + ".md";
|
||||||
|
for (const QString &c : cands)
|
||||||
|
if (QFileInfo::exists(c))
|
||||||
|
return QDir::cleanPath(c);
|
||||||
|
// поиск по имени файла по всему хранилищу (без расширения)
|
||||||
|
auto it = m_fileByBase.constFind(t);
|
||||||
|
if (it != m_fileByBase.constEnd())
|
||||||
|
return it.value();
|
||||||
|
it = m_fileByBase.constFind(t + ".md");
|
||||||
|
if (it != m_fileByBase.constEnd())
|
||||||
|
return it.value();
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void VaultIndex::rebuild(const QString &vault)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
if (!base.isEmpty() && !m_fileByBase.contains(base))
|
||||||
|
m_fileByBase.insert(base, f);
|
||||||
|
}
|
||||||
|
// Проход 2: узлы/теги/ссылки
|
||||||
|
for (const QString &f : files)
|
||||||
|
parseFile(f);
|
||||||
|
|
||||||
|
m_busy = false;
|
||||||
|
emit busyChanged();
|
||||||
|
emit indexChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void VaultIndex::collectFiles(const QString &dir, int depth, QStringList &out) const
|
||||||
|
{
|
||||||
|
if (depth > 24)
|
||||||
|
return;
|
||||||
|
QDir d(dir);
|
||||||
|
const QFileInfoList entries = d.entryInfoList(
|
||||||
|
QDir::AllEntries | QDir::NoDotAndDotDot | QDir::System | QDir::NoSymLinks);
|
||||||
|
for (const QFileInfo &fi : entries) {
|
||||||
|
if (kIgnoredNames.contains(fi.fileName()))
|
||||||
|
continue;
|
||||||
|
if (fi.isDir())
|
||||||
|
collectFiles(fi.absoluteFilePath(), depth + 1, out);
|
||||||
|
else if (fi.suffix().compare(QLatin1String("md"), Qt::CaseInsensitive) == 0)
|
||||||
|
out.append(fi.absoluteFilePath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VaultIndex::parseFile(const QString &path)
|
||||||
|
{
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly))
|
||||||
|
return;
|
||||||
|
const QString text = QString::fromUtf8(f.readAll());
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
// m_fileCount уже установлен в rebuild (files.size())
|
||||||
|
const QString fileDir = QFileInfo(path).absolutePath();
|
||||||
|
const QString key = nodeKeyFor(path, true);
|
||||||
|
ensureNode(key, QFileInfo(path).fileName(), QStringLiteral("file"));
|
||||||
|
{
|
||||||
|
const QString base = QFileInfo(path).completeBaseName();
|
||||||
|
if (!base.isEmpty() && !m_fileByBase.contains(base))
|
||||||
|
m_fileByBase.insert(base, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Теги
|
||||||
|
const QStringList tags = extractTags(text);
|
||||||
|
m_tagsOfFile.insert(path, tags);
|
||||||
|
for (const QString &tag : tags) {
|
||||||
|
const QString tagKey = nodeKeyFor(tag, false);
|
||||||
|
ensureNode(tagKey, QStringLiteral("#%1").arg(tag), QStringLiteral("tag"));
|
||||||
|
addEdge(key, tagKey);
|
||||||
|
m_filesWithTag[tag].append(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылки [[X]] и [text](rel)
|
||||||
|
QVariantList links;
|
||||||
|
static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]"));
|
||||||
|
auto wi = wikiRe.globalMatch(text);
|
||||||
|
while (wi.hasNext()) {
|
||||||
|
const QString target = wi.next().captured(1);
|
||||||
|
const QString resolved = resolveLink(fileDir, target);
|
||||||
|
if (!resolved.isEmpty()) {
|
||||||
|
links.append(QVariantMap{{"text", target}, {"path", resolved}});
|
||||||
|
addEdge(key, nodeKeyFor(resolved, true));
|
||||||
|
ensureNode(nodeKeyFor(resolved, true),
|
||||||
|
QFileInfo(resolved).fileName(), QStringLiteral("file"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static const QRegularExpression mdLinkRe(
|
||||||
|
QStringLiteral("\\[([^\\]]+)\\]\\(([^\\)]+)\\)"));
|
||||||
|
auto mi = mdLinkRe.globalMatch(text);
|
||||||
|
while (mi.hasNext()) {
|
||||||
|
auto m = mi.next();
|
||||||
|
const QString target = m.captured(2);
|
||||||
|
if (target.startsWith(QLatin1String("#")))
|
||||||
|
continue;
|
||||||
|
const QString resolved = resolveLink(fileDir, target);
|
||||||
|
if (!resolved.isEmpty()) {
|
||||||
|
links.append(QVariantMap{{"text", m.captured(1)}, {"path", resolved}});
|
||||||
|
addEdge(key, nodeKeyFor(resolved, true));
|
||||||
|
ensureNode(nodeKeyFor(resolved, true),
|
||||||
|
QFileInfo(resolved).fileName(), QStringLiteral("file"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_linksOfFile.insert(path, links);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList VaultIndex::tagsOfFile(const QString &path) const
|
||||||
|
{
|
||||||
|
return m_tagsOfFile.value(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantList VaultIndex::linksOfFile(const QString &path) const
|
||||||
|
{
|
||||||
|
return m_linksOfFile.value(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList VaultIndex::filesWithTag(const QString &tag) const
|
||||||
|
{
|
||||||
|
return m_filesWithTag.value(tag);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
// Индекс связей хранилища: теги, [[перекрёстные ссылки]], md-ссылки между
|
||||||
|
// файлами, а также узлы и рёбра графа. Сканируется синхронно.
|
||||||
|
class VaultIndex : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
Q_PROPERTY(QVariantList nodes READ nodes NOTIFY indexChanged)
|
||||||
|
Q_PROPERTY(QVariantList edges READ edges NOTIFY indexChanged)
|
||||||
|
Q_PROPERTY(int fileCount READ fileCount NOTIFY indexChanged)
|
||||||
|
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit VaultIndex(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QVariantList nodes() const { return m_nodes; }
|
||||||
|
QVariantList edges() const { return m_edges; }
|
||||||
|
int fileCount() const { return m_fileCount; }
|
||||||
|
bool busy() const { return m_busy; }
|
||||||
|
|
||||||
|
// Полное пересканирование хранилища.
|
||||||
|
Q_INVOKABLE void rebuild(const QString &vault);
|
||||||
|
|
||||||
|
// Теги файла / ссылки файла (список {text,path} для открытия).
|
||||||
|
Q_INVOKABLE QStringList tagsOfFile(const QString &path) const;
|
||||||
|
Q_INVOKABLE QVariantList linksOfFile(const QString &path) const;
|
||||||
|
|
||||||
|
// Все файлы с тегом tag.
|
||||||
|
Q_INVOKABLE QStringList filesWithTag(const QString &tag) const;
|
||||||
|
|
||||||
|
Q_INVOKABLE QString displayName(const QString &nodeKey) const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void indexChanged();
|
||||||
|
void busyChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
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);
|
||||||
|
|
||||||
|
QVariantList m_nodes;
|
||||||
|
QVariantList m_edges;
|
||||||
|
QHash<QString, int> m_nodeById;
|
||||||
|
QHash<QString, QStringList> m_tagsOfFile;
|
||||||
|
QHash<QString, QVariantList> m_linksOfFile;
|
||||||
|
QHash<QString, QStringList> m_filesWithTag;
|
||||||
|
QHash<QString, QString> m_fileByBase; // имя без расширения → путь
|
||||||
|
int m_fileCount = 0;
|
||||||
|
bool m_busy = false;
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "app/WindowController.h"
|
#include "app/WindowController.h"
|
||||||
#include "core/DocumentManager.h"
|
#include "core/DocumentManager.h"
|
||||||
#include "core/SettingsStore.h"
|
#include "core/SettingsStore.h"
|
||||||
|
#include "core/VaultIndex.h"
|
||||||
#include "git/GitManager.h"
|
#include "git/GitManager.h"
|
||||||
#include "markdown/MdParser.h"
|
#include "markdown/MdParser.h"
|
||||||
#include "sync/SyncManager.h"
|
#include "sync/SyncManager.h"
|
||||||
@@ -41,6 +42,7 @@ int main(int argc, char *argv[])
|
|||||||
VaultManager vaultManager(&settingsStore, &gitManager);
|
VaultManager vaultManager(&settingsStore, &gitManager);
|
||||||
DocumentManager documentManager;
|
DocumentManager documentManager;
|
||||||
MdParser mdParser;
|
MdParser mdParser;
|
||||||
|
VaultIndex vaultIndex;
|
||||||
|
|
||||||
QQmlApplicationEngine engine;
|
QQmlApplicationEngine engine;
|
||||||
engine.rootContext()->setContextProperty("settingsStore", &settingsStore);
|
engine.rootContext()->setContextProperty("settingsStore", &settingsStore);
|
||||||
@@ -49,6 +51,7 @@ int main(int argc, char *argv[])
|
|||||||
engine.rootContext()->setContextProperty("vaultManager", &vaultManager);
|
engine.rootContext()->setContextProperty("vaultManager", &vaultManager);
|
||||||
engine.rootContext()->setContextProperty("documentManager", &documentManager);
|
engine.rootContext()->setContextProperty("documentManager", &documentManager);
|
||||||
engine.rootContext()->setContextProperty("mdParser", &mdParser);
|
engine.rootContext()->setContextProperty("mdParser", &mdParser);
|
||||||
|
engine.rootContext()->setContextProperty("vaultIndex", &vaultIndex);
|
||||||
|
|
||||||
WindowController windowController(
|
WindowController windowController(
|
||||||
&engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml"));
|
&engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml"));
|
||||||
|
|||||||
+11
-1
@@ -74,4 +74,14 @@ target_link_libraries(flora_mdparser_test PRIVATE
|
|||||||
if(TARGET KF6SyntaxHighlighting)
|
if(TARGET KF6SyntaxHighlighting)
|
||||||
target_link_libraries(flora_mdparser_test PRIVATE KF6SyntaxHighlighting)
|
target_link_libraries(flora_mdparser_test PRIVATE KF6SyntaxHighlighting)
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME flora_mdparser_test COMMAND flora_mdparser_test)
|
add_test(NAME flora_mdparser_test COMMAND flora_mdparser_test)
|
||||||
|
|
||||||
|
# Тест VaultIndex (теги, ссылки, граф).
|
||||||
|
add_executable(flora_vaultindex_test vaultindex_test.cpp
|
||||||
|
../src/core/VaultIndex.cpp
|
||||||
|
../src/core/VaultIndex.h
|
||||||
|
)
|
||||||
|
target_include_directories(flora_vaultindex_test PRIVATE ../src)
|
||||||
|
target_link_libraries(flora_vaultindex_test PRIVATE
|
||||||
|
Qt6::Core)
|
||||||
|
add_test(NAME flora_vaultindex_test COMMAND flora_vaultindex_test)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Тест VaultIndex (Фаза 7): теги, [[ссылки]], md-ссылки, граф, списки файлов по тегу.
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#include "core/VaultIndex.h"
|
||||||
|
|
||||||
|
static int fail(const char *msg, const QString &detail = QString())
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "FAIL: %s | %s\n", msg, qPrintable(detail));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
QCoreApplication app(argc, argv);
|
||||||
|
|
||||||
|
QTemporaryDir dir;
|
||||||
|
if (!dir.isValid())
|
||||||
|
return fail("temp dir");
|
||||||
|
|
||||||
|
QDir(dir.path()).mkpath("sub");
|
||||||
|
auto put = [&](const QString &rel, const QString &body) {
|
||||||
|
QFile f(dir.path() + "/" + rel);
|
||||||
|
f.open(QIODevice::WriteOnly);
|
||||||
|
f.write(body.toUtf8());
|
||||||
|
f.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
put("a.md", "# Заголовок #тег1\n\nСсылка на [[b]] и [[b]].\nТег ещё #тег2 и #тег1.\n");
|
||||||
|
put("sub/b.md", "# B\n\nпростой текст #тег2\n");
|
||||||
|
put("c.md", "[Перейти на b](sub/b.md)\n\n```\n# не тег\nint x;\n```\n");
|
||||||
|
|
||||||
|
VaultIndex idx;
|
||||||
|
idx.rebuild(dir.path());
|
||||||
|
if (idx.fileCount() != 3)
|
||||||
|
return fail("fileCount", QString::number(idx.fileCount()));
|
||||||
|
|
||||||
|
const QString a = dir.path() + "/a.md";
|
||||||
|
const QString b = dir.path() + "/sub/b.md";
|
||||||
|
const QString c = dir.path() + "/c.md";
|
||||||
|
|
||||||
|
// Теги файла a: тег1, тег2 (заголовок "# Заголовок" не тег)
|
||||||
|
QStringList tagsA = idx.tagsOfFile(a);
|
||||||
|
if (!tagsA.contains("тег1") || !tagsA.contains("тег2") || tagsA.size() != 2)
|
||||||
|
return fail("tagsOfFile(a)", tagsA.join(","));
|
||||||
|
|
||||||
|
// Ссылки a → b (вики)
|
||||||
|
auto linksA = idx.linksOfFile(a);
|
||||||
|
if (linksA.isEmpty())
|
||||||
|
return fail("linksOfFile(a) пусто");
|
||||||
|
bool foundB = false;
|
||||||
|
for (const auto &l : linksA) {
|
||||||
|
if (l.toMap().value("path").toString() == b)
|
||||||
|
foundB = true;
|
||||||
|
}
|
||||||
|
if (!foundB)
|
||||||
|
return fail("вики-ссылка на b не разрешена");
|
||||||
|
|
||||||
|
// c → b по md-ссылке
|
||||||
|
auto linksC = idx.linksOfFile(c);
|
||||||
|
foundB = false;
|
||||||
|
for (const auto &l : linksC)
|
||||||
|
if (l.toMap().value("path").toString() == b)
|
||||||
|
foundB = true;
|
||||||
|
if (!foundB)
|
||||||
|
return fail("md-ссылка на b не разрешена", QString());
|
||||||
|
|
||||||
|
// Граф: узлы-файлы и рёбра; тег как узел
|
||||||
|
bool haveTagNode = false;
|
||||||
|
for (const auto &n : idx.nodes()) {
|
||||||
|
if (n.toMap().value("type").toString() == "tag")
|
||||||
|
haveTagNode = true;
|
||||||
|
}
|
||||||
|
if (!haveTagNode)
|
||||||
|
return fail("нет узла-тега в графе");
|
||||||
|
if (idx.edges().isEmpty())
|
||||||
|
return fail("нет рёбер в графе");
|
||||||
|
|
||||||
|
// filesWithTag
|
||||||
|
QStringList files1 = idx.filesWithTag("тег1");
|
||||||
|
if (files1.size() != 1 || files1.first() != a)
|
||||||
|
return fail("filesWithTag(тег1)", files1.join(","));
|
||||||
|
QStringList files2 = idx.filesWithTag("тег2");
|
||||||
|
if (files2.size() != 2)
|
||||||
|
return fail("filesWithTag(тег2)", QString::number(files2.size()));
|
||||||
|
|
||||||
|
std::printf("VAULTINDEX TEST OK\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user