From c22987e5dc9ed38171be70e75a656ed3714789e1 Mon Sep 17 00:00:00 2001 From: "Flora (opencode)" Date: Mon, 17 Aug 2026 14:18:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B0=D0=B7=D0=B0=207:=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=8F=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=20=E2=80=94=20VaultIndex=20(=D1=82=D0=B5=D0=B3=D0=B8/[[=D0=B2?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8]]/md-=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8,?= =?UTF-8?q?=20=D0=B3=D1=80=D0=B0=D1=84),=20MonthCalendar=20(=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20=D0=B4=D0=BD=D1=8F),=20RightPa?= =?UTF-8?q?nel=20(=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8+=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8,=20=D1=87=D0=B8=D0=BF=D1=8B,=20=D0=BF=D0=BE=D0=BF=D0=B0?= =?UTF-8?q?=D0=BF=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2),=20GraphView=20(?= =?UTF-8?q?Canvas,=20drag,=20=D0=BA=D0=BB=D0=B8=D0=BA-=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B8=D0=B5);=20dailyNotePath;=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=20vaultindex=20(16/16);=20self-contained=20F?= =?UTF-8?q?lora.app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + CMakeLists.txt | 5 + qml/panels/GraphView.qml | 115 +++++++++++++++++ qml/panels/MonthCalendar.qml | 111 ++++++++++++++++ qml/panels/RightPanel.qml | 211 ++++++++++++++++++++++++++++-- qml/panels/TagChip.qml | 35 +++++ src/core/FileStore.cpp | 21 +++ src/core/FileStore.h | 3 + src/core/VaultIndex.cpp | 240 +++++++++++++++++++++++++++++++++++ src/core/VaultIndex.h | 60 +++++++++ src/main.cpp | 3 + tests/CMakeLists.txt | 12 +- tests/vaultindex_test.cpp | 92 ++++++++++++++ 13 files changed, 897 insertions(+), 15 deletions(-) create mode 100644 qml/panels/GraphView.qml create mode 100644 qml/panels/MonthCalendar.qml create mode 100644 qml/panels/TagChip.qml create mode 100644 src/core/VaultIndex.cpp create mode 100644 src/core/VaultIndex.h create mode 100644 tests/vaultindex_test.cpp diff --git a/.gitignore b/.gitignore index 19adbb0..9dae567 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,9 @@ Thumbs.db # Служебные артефакты *.log +# Собранное приложение +Flora.app/ +Flora.dmg + # Все markdown-файлы (служебные документы проекта не пушим в gitea) *.md \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index feecbbb..a7e25eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,7 @@ set(FLORA_HEADERS src/core/DocumentManager.h src/core/FileStore.h src/core/SettingsStore.h + src/core/VaultIndex.h src/git/GitManager.h src/markdown/MdParser.h src/sync/SyncManager.h @@ -51,6 +52,7 @@ set(FLORA_SOURCES src/core/DocumentManager.cpp src/core/FileStore.cpp src/core/SettingsStore.cpp + src/core/VaultIndex.cpp src/git/GitManager.cpp src/markdown/MdParser.cpp src/sync/SyncManager.cpp @@ -114,6 +116,9 @@ qt_add_qml_module(flora qml/panels/FormatToolbar.qml qml/panels/TbButton.qml qml/panels/RightPanel.qml + qml/panels/TagChip.qml + qml/panels/GraphView.qml + qml/panels/MonthCalendar.qml qml/wizard/WelcomeWizard.qml qml/dialogs/ProviderSetupGuide.qml qml/dialogs/NameDialog.qml diff --git a/qml/panels/GraphView.qml b/qml/panels/GraphView.qml new file mode 100644 index 0000000..5eafcd6 --- /dev/null +++ b/qml/panels/GraphView.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 = "" + } + } +} \ No newline at end of file diff --git a/qml/panels/MonthCalendar.qml b/qml/panels/MonthCalendar.qml new file mode 100644 index 0000000..8466b8d --- /dev/null +++ b/qml/panels/MonthCalendar.qml @@ -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)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/qml/panels/RightPanel.qml b/qml/panels/RightPanel.qml index 032cb6d..497d200 100644 --- a/qml/panels/RightPanel.qml +++ b/qml/panels/RightPanel.qml @@ -2,36 +2,219 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -// Правая панель: календарь + ссылки/теги + граф связей. -// Фаза 0 — каркас; контент наполняется в Фазах 7 и 8. +// Правая панель: календарь (заметки дня), «Ссылки и теги» активного файла, +// граф связей (файлы + теги, рёбра по ссылкам). Item { 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 { anchors.fill: parent 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 { Layout.fillWidth: true Layout.fillHeight: true - color: "#fafafa" - Label { - anchors.centerIn: parent - text: qsTr("Календарь\n(Фаза 7)") - horizontalAlignment: Text.AlignHCenter - color: "#bbb" + color: "#f4f4f4" + ColumnLayout { + anchors.fill: parent + anchors.margins: 6 + spacing: 4 + + 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 { Layout.fillWidth: true Layout.fillHeight: true - color: "#f5f5f5" - Label { - anchors.centerIn: parent - text: qsTr("Граф связей\n(Фаза 7)") - horizontalAlignment: Text.AlignHCenter - color: "#bbb" + color: "#eef1f5" + ColumnLayout { + anchors.fill: parent + spacing: 0 + RowLayout { + 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() + } + } } } } diff --git a/qml/panels/TagChip.qml b/qml/panels/TagChip.qml new file mode 100644 index 0000000..f0fb20d --- /dev/null +++ b/qml/panels/TagChip.qml @@ -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() + } + } +} \ No newline at end of file diff --git a/src/core/FileStore.cpp b/src/core/FileStore.cpp index 97f6c05..e5b5191 100644 --- a/src/core/FileStore.cpp +++ b/src/core/FileStore.cpp @@ -261,6 +261,27 @@ QString FileStore::createFile(const QString &parentPath, const QString &name) 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) { const QString dir = parentPath.isEmpty() ? m_vault : parentPath; diff --git a/src/core/FileStore.h b/src/core/FileStore.h index 0d98c4f..0ec0b1c 100644 --- a/src/core/FileStore.h +++ b/src/core/FileStore.h @@ -47,6 +47,9 @@ public: Q_INVOKABLE QString createFile(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 removeItem(const QString &path); Q_INVOKABLE QString moveItem(const QString &srcPath, const QString &destDir); diff --git a/src/core/VaultIndex.cpp b/src/core/VaultIndex.cpp new file mode 100644 index 0000000..af9dbf3 --- /dev/null +++ b/src/core/VaultIndex.cpp @@ -0,0 +1,240 @@ +#include "core/VaultIndex.h" + +#include +#include +#include +#include + +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); +} \ No newline at end of file diff --git a/src/core/VaultIndex.h b/src/core/VaultIndex.h new file mode 100644 index 0000000..b374173 --- /dev/null +++ b/src/core/VaultIndex.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include + +// Индекс связей хранилища: теги, [[перекрёстные ссылки]], 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 m_nodeById; + QHash m_tagsOfFile; + QHash m_linksOfFile; + QHash m_filesWithTag; + QHash m_fileByBase; // имя без расширения → путь + int m_fileCount = 0; + bool m_busy = false; +}; \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 900d243..38b994d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,6 +7,7 @@ #include "app/WindowController.h" #include "core/DocumentManager.h" #include "core/SettingsStore.h" +#include "core/VaultIndex.h" #include "git/GitManager.h" #include "markdown/MdParser.h" #include "sync/SyncManager.h" @@ -41,6 +42,7 @@ int main(int argc, char *argv[]) VaultManager vaultManager(&settingsStore, &gitManager); DocumentManager documentManager; MdParser mdParser; + VaultIndex vaultIndex; QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("settingsStore", &settingsStore); @@ -49,6 +51,7 @@ int main(int argc, char *argv[]) engine.rootContext()->setContextProperty("vaultManager", &vaultManager); engine.rootContext()->setContextProperty("documentManager", &documentManager); engine.rootContext()->setContextProperty("mdParser", &mdParser); + engine.rootContext()->setContextProperty("vaultIndex", &vaultIndex); WindowController windowController( &engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml")); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 32bd0b4..35e6611 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,4 +74,14 @@ target_link_libraries(flora_mdparser_test PRIVATE if(TARGET KF6SyntaxHighlighting) target_link_libraries(flora_mdparser_test PRIVATE KF6SyntaxHighlighting) endif() -add_test(NAME flora_mdparser_test COMMAND flora_mdparser_test) \ No newline at end of file +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) \ No newline at end of file diff --git a/tests/vaultindex_test.cpp b/tests/vaultindex_test.cpp new file mode 100644 index 0000000..57d1ff7 --- /dev/null +++ b/tests/vaultindex_test.cpp @@ -0,0 +1,92 @@ +// Тест VaultIndex (Фаза 7): теги, [[ссылки]], md-ссылки, граф, списки файлов по тегу. +#include +#include +#include +#include +#include + +#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; +} \ No newline at end of file