2026-08-17 14:18:43 +03:00
|
|
|
#include "core/VaultIndex.h"
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
#include <QDir>
|
2026-08-17 14:18:43 +03:00
|
|
|
#include <QDirIterator>
|
|
|
|
|
#include <QFile>
|
|
|
|
|
#include <QFileInfo>
|
|
|
|
|
#include <QRegularExpression>
|
2026-09-01 19:25:07 +03:00
|
|
|
#include <QSet>
|
2026-08-17 14:18:43 +03:00
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
QStringList kIgnoredNames = {".git", ".flora", ".stfolder", ".DS_Store", "Thumbs.db"};
|
2026-08-17 14:18:43 +03:00
|
|
|
|
|
|
|
|
// Извлекает теги #tag из текста (пропуская блоки кода).
|
|
|
|
|
QStringList extractTags(const QString &text)
|
|
|
|
|
{
|
|
|
|
|
QStringList tags;
|
|
|
|
|
static const QRegularExpression tagRe(
|
|
|
|
|
QStringLiteral("(?:^|[^\\w#])#([\\p{L}\\w][\\p{L}\\w-]*)"));
|
2026-09-01 19:25:07 +03:00
|
|
|
static const QRegularExpression inlineCodeRe(QStringLiteral("`[^`]*`"));
|
2026-08-17 14:18:43 +03:00
|
|
|
bool inCode = false;
|
|
|
|
|
const QStringList lines = text.split(QLatin1Char('\n'));
|
2026-09-01 19:25:07 +03:00
|
|
|
for (const QString &origLine : lines) {
|
|
|
|
|
const QString t = origLine.trimmed();
|
|
|
|
|
if (t.startsWith(QLatin1String("```")) || t.startsWith(QLatin1String("~~~"))) {
|
2026-08-17 14:18:43 +03:00
|
|
|
inCode = !inCode;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (inCode)
|
|
|
|
|
continue;
|
2026-09-01 19:25:07 +03:00
|
|
|
// Убираем инлайн-код `code #tag` — теги внутри не считаем
|
|
|
|
|
QString line = origLine;
|
|
|
|
|
line.remove(inlineCodeRe);
|
|
|
|
|
// Не пропускаем заголовки целиком — только сам маркер "# " в начале,
|
|
|
|
|
// теги после текста заголовка всё равно должны учитываться
|
|
|
|
|
static const QRegularExpression headerMarkerRe(QStringLiteral("^\\s*#{1,6}\\s+"));
|
|
|
|
|
line.remove(headerMarkerRe);
|
2026-08-17 14:18:43 +03:00
|
|
|
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(); // внешняя
|
2026-09-01 19:25:07 +03:00
|
|
|
// Отсекаем якорь #section и query ?
|
|
|
|
|
int hash = t.indexOf('#');
|
|
|
|
|
if (hash >= 0) t = t.left(hash).trimmed();
|
|
|
|
|
int q = t.indexOf('?');
|
|
|
|
|
if (q >= 0) t = t.left(q).trimmed();
|
|
|
|
|
if (t.isEmpty())
|
|
|
|
|
return QString();
|
|
|
|
|
// Защита от traversal
|
|
|
|
|
if (t.contains(QStringLiteral("..")))
|
|
|
|
|
return QString();
|
2026-08-17 14:18:43 +03:00
|
|
|
QStringList cands;
|
2026-09-01 19:25:07 +03:00
|
|
|
cands << QDir::cleanPath(cleanDir(fileDir) + '/' + t)
|
|
|
|
|
<< QDir::cleanPath(cleanDir(fileDir) + '/' + t + ".md");
|
|
|
|
|
for (const QString &c : cands) {
|
|
|
|
|
if (QFileInfo::exists(c) && QFileInfo(c).isFile())
|
2026-08-17 14:18:43 +03:00
|
|
|
return QDir::cleanPath(c);
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
// поиск по имени файла по всему хранилищу (без расширения), case-insensitive
|
|
|
|
|
const QString lower = t.toLower();
|
|
|
|
|
auto it = m_fileByBase.constFind(lower);
|
|
|
|
|
if (it != m_fileByBase.constEnd())
|
|
|
|
|
return it.value();
|
|
|
|
|
it = m_fileByBase.constFind(lower + ".md");
|
|
|
|
|
if (it != m_fileByBase.constEnd())
|
|
|
|
|
return it.value();
|
|
|
|
|
// fallback точный регистр
|
|
|
|
|
it = m_fileByBase.constFind(t);
|
2026-08-17 14:18:43 +03:00
|
|
|
if (it != m_fileByBase.constEnd())
|
|
|
|
|
return it.value();
|
|
|
|
|
it = m_fileByBase.constFind(t + ".md");
|
|
|
|
|
if (it != m_fileByBase.constEnd())
|
|
|
|
|
return it.value();
|
|
|
|
|
return QString();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 23:02:05 +03:00
|
|
|
void VaultIndex::deduplicateEdges()
|
|
|
|
|
{
|
|
|
|
|
QSet<QString> seen;
|
|
|
|
|
QVariantList uniq;
|
|
|
|
|
uniq.reserve(m_edges.size());
|
|
|
|
|
for (const auto &e : std::as_const(m_edges)) {
|
|
|
|
|
const QString k = e.toMap().value("from").toString() + "->" + e.toMap().value("to").toString();
|
|
|
|
|
if (!seen.contains(k)) {
|
|
|
|
|
seen.insert(k);
|
|
|
|
|
uniq.append(e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 14:18:43 +03:00
|
|
|
void VaultIndex::rebuild(const QString &vault)
|
|
|
|
|
{
|
2026-09-01 19:25:07 +03:00
|
|
|
if (vault.isEmpty() || !QDir(vault).exists()) {
|
2026-09-06 23:02:05 +03:00
|
|
|
m_vault.clear();
|
2026-09-01 19:25:07 +03:00
|
|
|
m_nodes.clear();
|
|
|
|
|
m_edges.clear();
|
|
|
|
|
m_nodeById.clear();
|
|
|
|
|
m_tagsOfFile.clear();
|
|
|
|
|
m_linksOfFile.clear();
|
|
|
|
|
m_filesWithTag.clear();
|
|
|
|
|
m_fileByBase.clear();
|
2026-09-06 23:02:05 +03:00
|
|
|
m_unresolvedByFile.clear();
|
|
|
|
|
m_fileMeta.clear();
|
2026-09-01 19:25:07 +03:00
|
|
|
emit indexChanged();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (m_busy)
|
|
|
|
|
return;
|
2026-08-17 14:18:43 +03:00
|
|
|
m_busy = true;
|
|
|
|
|
emit busyChanged();
|
|
|
|
|
|
2026-09-06 23:02:05 +03:00
|
|
|
m_vault = QDir::cleanPath(vault);
|
2026-08-17 14:18:43 +03:00
|
|
|
m_nodes.clear();
|
|
|
|
|
m_edges.clear();
|
|
|
|
|
m_nodeById.clear();
|
|
|
|
|
m_tagsOfFile.clear();
|
|
|
|
|
m_linksOfFile.clear();
|
|
|
|
|
m_filesWithTag.clear();
|
|
|
|
|
m_fileByBase.clear();
|
2026-09-06 23:02:05 +03:00
|
|
|
m_unresolvedByFile.clear();
|
|
|
|
|
m_fileMeta.clear();
|
2026-08-17 14:18:43 +03:00
|
|
|
|
|
|
|
|
// Проход 1: собрать все md-файлы и карту имён
|
|
|
|
|
QStringList files;
|
2026-09-06 23:02:05 +03:00
|
|
|
collectFiles(m_vault, 0, files);
|
2026-08-17 14:18:43 +03:00
|
|
|
for (const QString &f : files) {
|
2026-09-06 23:02:05 +03:00
|
|
|
const QString clean = QDir::cleanPath(f);
|
|
|
|
|
const QString base = QFileInfo(clean).completeBaseName();
|
2026-09-01 19:25:07 +03:00
|
|
|
const QString key = base.toLower();
|
|
|
|
|
if (!base.isEmpty() && !m_fileByBase.contains(key))
|
2026-09-06 23:02:05 +03:00
|
|
|
m_fileByBase.insert(key, clean);
|
2026-09-01 19:25:07 +03:00
|
|
|
// также храним оригинал для точного поиска
|
2026-08-17 14:18:43 +03:00
|
|
|
if (!base.isEmpty() && !m_fileByBase.contains(base))
|
2026-09-06 23:02:05 +03:00
|
|
|
m_fileByBase.insert(base, clean);
|
|
|
|
|
m_fileMeta.insert(clean, metaOf(clean));
|
2026-08-17 14:18:43 +03:00
|
|
|
}
|
|
|
|
|
// Проход 2: узлы/теги/ссылки
|
|
|
|
|
for (const QString &f : files)
|
2026-09-06 23:02:05 +03:00
|
|
|
parseFile(QDir::cleanPath(f));
|
|
|
|
|
deduplicateEdges();
|
2026-08-17 14:18:43 +03:00
|
|
|
|
|
|
|
|
m_busy = false;
|
|
|
|
|
emit busyChanged();
|
|
|
|
|
emit indexChanged();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 23:02:05 +03:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 14:18:43 +03:00
|
|
|
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();
|
2026-09-01 19:25:07 +03:00
|
|
|
if (!base.isEmpty()) {
|
|
|
|
|
const QString lower = base.toLower();
|
|
|
|
|
if (!m_fileByBase.contains(lower))
|
|
|
|
|
m_fileByBase.insert(lower, path);
|
|
|
|
|
if (!m_fileByBase.contains(base))
|
|
|
|
|
m_fileByBase.insert(base, path);
|
|
|
|
|
}
|
2026-08-17 14:18:43 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Теги
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 23:02:05 +03:00
|
|
|
// Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki).
|
|
|
|
|
// Неразрешённые цели запоминаем — вдруг нужный файл создадут позже.
|
2026-08-17 14:18:43 +03:00
|
|
|
QVariantList links;
|
2026-09-06 23:02:05 +03:00
|
|
|
QStringList unresolved;
|
2026-08-17 14:18:43 +03:00
|
|
|
static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]"));
|
|
|
|
|
auto wi = wikiRe.globalMatch(text);
|
|
|
|
|
while (wi.hasNext()) {
|
2026-09-01 19:25:07 +03:00
|
|
|
QString target = wi.next().captured(1);
|
|
|
|
|
// [[file|alias]] → берём file
|
|
|
|
|
const int pipe = target.indexOf('|');
|
|
|
|
|
if (pipe >= 0)
|
|
|
|
|
target = target.left(pipe).trimmed();
|
|
|
|
|
target = target.trimmed();
|
|
|
|
|
if (target.isEmpty())
|
|
|
|
|
continue;
|
2026-08-17 14:18:43 +03:00
|
|
|
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"));
|
2026-09-06 23:02:05 +03:00
|
|
|
} else if (!unresolved.contains(target)) {
|
|
|
|
|
unresolved.append(target);
|
2026-08-17 14:18:43 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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"));
|
2026-09-06 23:02:05 +03:00
|
|
|
} else if (!unresolved.contains(target)) {
|
|
|
|
|
unresolved.append(target);
|
2026-08-17 14:18:43 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
m_linksOfFile.insert(path, links);
|
2026-09-06 23:02:05 +03:00
|
|
|
if (unresolved.isEmpty())
|
|
|
|
|
m_unresolvedByFile.remove(path);
|
|
|
|
|
else
|
|
|
|
|
m_unresolvedByFile.insert(path, unresolved);
|
2026-08-17 14:18:43 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-07 16:30:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QStringList VaultIndex::indexedFiles() const
|
|
|
|
|
{
|
|
|
|
|
return m_fileMeta.keys();
|
2026-08-17 14:18:43 +03:00
|
|
|
}
|