#include "core/VaultIndex.h" #include #include #include #include #include #include namespace { QStringList kIgnoredNames = {".git", ".flora", ".stfolder", ".DS_Store", "Thumbs.db"}; // Извлекает теги #tag из текста (пропуская блоки кода). QStringList extractTags(const QString &text) { QStringList tags; static const QRegularExpression tagRe( QStringLiteral("(?:^|[^\\w#])#([\\p{L}\\w][\\p{L}\\w-]*)")); static const QRegularExpression inlineCodeRe(QStringLiteral("`[^`]*`")); bool inCode = false; const QStringList lines = text.split(QLatin1Char('\n')); for (const QString &origLine : lines) { const QString t = origLine.trimmed(); if (t.startsWith(QLatin1String("```")) || t.startsWith(QLatin1String("~~~"))) { inCode = !inCode; continue; } if (inCode) continue; // Убираем инлайн-код `code #tag` — теги внутри не считаем QString line = origLine; line.remove(inlineCodeRe); // Не пропускаем заголовки целиком — только сам маркер "# " в начале, // теги после текста заголовка всё равно должны учитываться static const QRegularExpression headerMarkerRe(QStringLiteral("^\\s*#{1,6}\\s+")); line.remove(headerMarkerRe); 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(); // внешняя // Отсекаем якорь #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(); QStringList cands; cands << QDir::cleanPath(cleanDir(fileDir) + '/' + t) << QDir::cleanPath(cleanDir(fileDir) + '/' + t + ".md"); for (const QString &c : cands) { if (QFileInfo::exists(c) && QFileInfo(c).isFile()) return QDir::cleanPath(c); } // поиск по имени файла по всему хранилищу (без расширения), 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); 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::deduplicateEdges() { QSet 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; } void VaultIndex::rebuild(const QString &vault) { if (vault.isEmpty() || !QDir(vault).exists()) { m_vault.clear(); m_nodes.clear(); m_edges.clear(); m_nodeById.clear(); m_tagsOfFile.clear(); m_linksOfFile.clear(); m_filesWithTag.clear(); m_fileByBase.clear(); m_unresolvedByFile.clear(); m_fileMeta.clear(); emit indexChanged(); return; } if (m_busy) return; m_busy = true; emit busyChanged(); m_vault = QDir::cleanPath(vault); m_nodes.clear(); m_edges.clear(); m_nodeById.clear(); m_tagsOfFile.clear(); m_linksOfFile.clear(); m_filesWithTag.clear(); m_fileByBase.clear(); m_unresolvedByFile.clear(); m_fileMeta.clear(); // Проход 1: собрать все md-файлы и карту имён QStringList files; collectFiles(m_vault, 0, files); for (const QString &f : files) { const QString clean = QDir::cleanPath(f); const QString base = QFileInfo(clean).completeBaseName(); const QString key = base.toLower(); if (!base.isEmpty() && !m_fileByBase.contains(key)) m_fileByBase.insert(key, clean); // также храним оригинал для точного поиска if (!base.isEmpty() && !m_fileByBase.contains(base)) m_fileByBase.insert(base, clean); m_fileMeta.insert(clean, metaOf(clean)); } // Проход 2: узлы/теги/ссылки for (const QString &f : files) parseFile(QDir::cleanPath(f)); deduplicateEdges(); m_busy = false; emit busyChanged(); emit indexChanged(); } void VaultIndex::updateFile(const QString &path) { if (m_vault.isEmpty()) return; const QString clean = QDir::cleanPath(path); if (!shouldIndex(clean)) { // Удаление / переименование-из / не-md: просто выкинуть из индекса if (m_fileMeta.contains(clean)) { dropFile(clean); emit indexChanged(); } return; } const bool isNew = !m_fileMeta.contains(clean); const QStringList oldTags = m_tagsOfFile.value(clean); const QVariantList oldLinks = m_linksOfFile.value(clean); purgeOutgoing(clean); parseFile(clean); deduplicateEdges(); m_fileMeta.insert(clean, metaOf(clean)); bool changed = isNew || m_tagsOfFile.value(clean) != oldTags || m_linksOfFile.value(clean) != oldLinks; if (isNew) changed = relinkForNewFile(clean) || changed; if (changed) emit indexChanged(); } void VaultIndex::removeFile(const QString &path) { if (m_vault.isEmpty()) return; const QString clean = QDir::cleanPath(path); if (!m_fileMeta.contains(clean) && !m_nodeById.contains(nodeKeyFor(clean, true))) return; dropFile(clean); emit indexChanged(); } void VaultIndex::syncIncremental() { if (m_vault.isEmpty() || !QDir(m_vault).exists() || m_busy) return; // Только метаданные (без чтения содержимого) — дёшево даже на тысячах файлов QStringList files; collectFiles(m_vault, 0, files); QSet seen; seen.reserve(files.size() * 2); bool changed = false; for (const QString &f : files) { const QString clean = QDir::cleanPath(f); seen.insert(clean); auto it = m_fileMeta.constFind(clean); if (it == m_fileMeta.constEnd() || it.value() != metaOf(clean)) { const QStringList oldTags = m_tagsOfFile.value(clean); const QVariantList oldLinks = m_linksOfFile.value(clean); const bool isNew = (it == m_fileMeta.constEnd()); purgeOutgoing(clean); parseFile(clean); m_fileMeta.insert(clean, metaOf(clean)); changed = changed || isNew || m_tagsOfFile.value(clean) != oldTags || m_linksOfFile.value(clean) != oldLinks; if (isNew) changed = relinkForNewFile(clean) || changed; } } for (auto it = m_fileMeta.constBegin(); it != m_fileMeta.constEnd(); ++it) { if (!seen.contains(it.key())) { dropFile(it.key()); changed = true; } } if (changed) { deduplicateEdges(); emit indexChanged(); } } void VaultIndex::collectFiles(const QString &dir, int depth, QStringList &out) const { if (depth > 24) 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()) { 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); } } // Теги 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) (поддерживаем алиас | в wiki). // Неразрешённые цели запоминаем — вдруг нужный файл создадут позже. QVariantList links; QStringList unresolved; static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]")); auto wi = wikiRe.globalMatch(text); while (wi.hasNext()) { 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; 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")); } else if (!unresolved.contains(target)) { unresolved.append(target); } } 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")); } else if (!unresolved.contains(target)) { unresolved.append(target); } } m_linksOfFile.insert(path, links); if (unresolved.isEmpty()) m_unresolvedByFile.remove(path); else m_unresolvedByFile.insert(path, unresolved); } QStringList VaultIndex::tagsOfFile(const QString &path) const { 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); } QStringList VaultIndex::indexedFiles() const { return m_fileMeta.keys(); }