#include "core/FileStore.h" #include #include #include #include #include #include FileStore::FileStore(QObject *parent) : QAbstractListModel(parent) { m_debounce.setSingleShot(true); m_debounce.setInterval(300); connect(&m_debounce, &QTimer::timeout, this, &FileStore::rebuild); connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileStore::scheduleRebuild); } QModelIndex FileStore::index(int row, int column, const QModelIndex &parent) const { if (parent.isValid() || column != 0 || row < 0 || row >= m_rows.size()) return QModelIndex(); return createIndex(row, 0); } QModelIndex FileStore::parent(const QModelIndex &child) const { Q_UNUSED(child) return QModelIndex(); } int FileStore::rowCount(const QModelIndex &parent) const { return parent.isValid() ? 0 : m_rows.size(); } QVariant FileStore::data(const QModelIndex &index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) return QVariant(); const Row &r = m_rows.at(index.row()); switch (role) { case NameRole: return r.name; case PathRole: return r.path; case DepthRole: return r.depth; case IsDirRole: return r.isDir; case ExpandedRole: return r.expanded; case HasChildrenRole: return r.hasChildren; case IconRole: return iconFor(r); default: return QVariant(); } } QHash FileStore::roleNames() const { QHash roles; roles[NameRole] = "display"; roles[PathRole] = "path"; roles[DepthRole] = "depth"; roles[IsDirRole] = "isDir"; roles[ExpandedRole] = "expanded"; roles[HasChildrenRole] = "hasChildren"; roles[IconRole] = "icon"; return roles; } QString FileStore::iconFor(const Row &r) const { if (r.isDir) return r.hasChildren ? QStringLiteral("folder") : QStringLiteral("folder-empty"); const QString ext = QFileInfo(r.name).suffix().toLower(); if (ext == "md" || ext == "markdown") return QStringLiteral("file-md"); if (ext == "html" || ext == "htm") return QStringLiteral("file-html"); if (ext == "pdf") return QStringLiteral("file-pdf"); if (ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "gif" || ext == "svg" || ext == "webp" || ext == "bmp") return QStringLiteral("file-img"); if (ext == "txt" || ext == "log") return QStringLiteral("file-text"); if (ext == "c" || ext == "cpp" || ext == "h" || ext == "hpp" || ext == "py" || ext == "go" || ext == "rs" || ext == "js" || ext == "ts" || ext == "json" || ext == "yaml" || ext == "yml" || ext == "sh" || ext == "bash" || ext == "css") return QStringLiteral("file-code"); return QStringLiteral("file"); } QString FileStore::openVault(const QString &path) { const QString clean = QDir::cleanPath(path.trimmed()); if (clean.isEmpty()) return QStringLiteral("Путь к хранилищу не указан."); QFileInfo fi(clean); if (!fi.exists()) return QStringLiteral("Папка «%1» не существует.").arg(clean); if (!fi.isDir()) return QStringLiteral("Путь «%1» не является папкой.").arg(clean); m_vault = clean; m_expanded.clear(); rebuild(); registerWatcher(); emit vaultChanged(); emit readyChanged(); return QString(); } void FileStore::rebuild() { QVector rows; collectDir(m_vault, 0, rows); beginResetModel(); m_rows = std::move(rows); endResetModel(); registerWatcher(); } void FileStore::collectDir(const QString &dir, int depth, QVector &out) const { QDir d(dir); d.setFilter(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::NoSymLinks | QDir::System); const QFileInfoList entries = d.entryInfoList(); QVector dirs, files; for (const QFileInfo &fi : entries) { const QString path = QDir::cleanPath(fi.absoluteFilePath()); if (isIgnored(path)) continue; Row row; row.path = path; row.name = fi.fileName(); row.depth = depth; row.isDir = fi.isDir(); if (row.isDir) { QDir cd(path); cd.setFilter(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::System | QDir::NoSymLinks); const QFileInfoList children = cd.entryInfoList(); int visible = 0; for (const auto &c : children) if (!isIgnored(c.absoluteFilePath())) ++visible; row.hasChildren = visible > 0; } row.expanded = row.isDir && m_expanded.contains(path); (row.isDir ? dirs : files).append(row); } std::sort(dirs.begin(), dirs.end(), [](const Row &a, const Row &b) { return a.name.toLower() < b.name.toLower(); }); std::sort(files.begin(), files.end(), [](const Row &a, const Row &b) { return a.name.toLower() < b.name.toLower(); }); for (const Row &r : dirs) { out.append(r); if (r.expanded) collectDir(r.path, depth + 1, out); } for (const Row &r : files) out.append(r); } bool FileStore::isIgnored(const QString &path) const { const QString name = QFileInfo(path).fileName(); return name == QLatin1String(".git") || name == QLatin1String(".flora") || name == QLatin1String(".stfolder") || name == QLatin1String(".DS_Store") || name == QLatin1String("Thumbs.db"); } void FileStore::registerWatcher() { const auto dirs = m_watcher.directories(); for (const QString &d : dirs) m_watcher.removePath(d); if (m_vault.isEmpty() || !QDir(m_vault).exists()) return; // Следим за всеми каталогами хранилища, даже свёрнутыми, чтобы внешние изменения // внутри свёрнутых папок приводили к rebuild (иначе watcher пропустит события). QVector dirsToWatch; dirsToWatch.append(m_vault); // Собираем рекурсивно все подкаталоги, игнорируя служебные std::function collect = [&](const QString &cur) { QDir d(cur); d.setFilter(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks | QDir::System); const QFileInfoList subs = d.entryInfoList(); for (const QFileInfo &fi : subs) { if (isIgnored(fi.absoluteFilePath())) continue; const QString clean = QDir::cleanPath(fi.absoluteFilePath()); if (!dirsToWatch.contains(clean)) dirsToWatch.append(clean); collect(fi.absoluteFilePath()); } }; collect(m_vault); // Также добавляем видимые dirs на случай рассинхрона for (const Row &r : m_rows) if (r.isDir && !dirsToWatch.contains(r.path)) dirsToWatch.append(r.path); // Ограничение QFileSystemWatcher: лимит дескрипторов (обычно 256-512) // При превышении addPaths вернёт пустой список без ошибки - проверяем if (dirsToWatch.size() > 512) { // Оставляем только верхний уровень + видимые для экономии дескрипторов QVector limited; limited.append(m_vault); for (const Row &r : m_rows) if (r.isDir && !limited.contains(r.path)) limited.append(r.path); dirsToWatch = limited; } const QStringList failed = m_watcher.addPaths(dirsToWatch); Q_UNUSED(failed) } void FileStore::scheduleRebuild() { if (!m_vault.isEmpty()) m_debounce.start(); } void FileStore::toggleExpand(int row) { if (row < 0 || row >= m_rows.size() || !m_rows[row].isDir) return; const QString path = m_rows[row].path; if (m_expanded.contains(path)) m_expanded.remove(path); else m_expanded.insert(path); rebuild(); } void FileStore::expandAll() { for (const Row &r : m_rows) if (r.isDir) m_expanded.insert(r.path); rebuild(); } void FileStore::collapseAll() { m_expanded.clear(); rebuild(); } int FileStore::rowOf(const QString &path) const { for (int i = 0; i < m_rows.size(); ++i) if (m_rows[i].path == path) return i; return -1; } QString FileStore::validateName(const QString &name) const { const QString n = name.trimmed(); if (n.isEmpty()) return QStringLiteral("Имя не может быть пустым."); if (n == "." || n == "..") return QStringLiteral("Имя не может быть «.» или «..»."); if (n.startsWith('.')) return QStringLiteral("Имя не может начинаться с точки."); // Запрещённые символы (кроссплатформенно): / \ : * ? " < > | и управляющие 0x00-0x1F static const QRegularExpression invalidRe(QStringLiteral("[\\\\/:*?\"<>|\\x00-\\x1F]")); if (n.contains('/') || n.contains('\\') || invalidRe.match(n).hasMatch()) return QStringLiteral("Имя содержит недопустимые символы (/, \\, :, *, ?, \", <, >, |)."); // Зарезервированные имена Windows (CON, PRN, AUX, NUL, COM1-9, LPT1-9) static const QSet reserved = { "CON","PRN","AUX","NUL", "COM1","COM2","COM3","COM4","COM5","COM6","COM7","COM8","COM9", "LPT1","LPT2","LPT3","LPT4","LPT5","LPT6","LPT7","LPT8","LPT9" }; const QString upper = n.split('.').first().toUpper(); if (reserved.contains(upper)) return QStringLiteral("Имя зарезервировано системой."); if (n.length() > 200) return QStringLiteral("Имя слишком длинное."); if (n.endsWith(' ') || n.endsWith('.')) return QStringLiteral("Имя не может заканчиваться пробелом или точкой."); return QString(); } bool FileStore::isInsideVault(const QString &path) const { if (m_vault.isEmpty()) return false; const QString vault = QDir::cleanPath(m_vault); const QString p = QDir::cleanPath(path); if (p == vault) return true; // Должен начинаться с vault + '/' return p.startsWith(vault + '/'); } QString FileStore::parentDirOfRow(int row) const { if (row <= 0) return m_vault; const Row &cur = m_rows[row]; for (int i = row - 1; i >= 0; --i) if (m_rows[i].isDir && m_rows[i].depth < cur.depth) return m_rows[i].path; return m_vault; } QString FileStore::createFile(const QString &parentPath, const QString &name) { if (m_vault.isEmpty()) return QStringLiteral("Хранилище не открыто."); QString dir = parentPath.isEmpty() ? m_vault : QDir::cleanPath(parentPath); if (!isInsideVault(dir)) return QStringLiteral("Путь вне хранилища: %1").arg(dir); QFileInfo dirInfo(dir); if (!dirInfo.exists() || !dirInfo.isDir()) return QStringLiteral("Папка не существует: %1").arg(dir); const QString trimmed = name.trimmed(); if (trimmed.isEmpty()) return QStringLiteral("Имя файла не может быть пустым."); // Если имя без расширения — добавляем .md QString actualName = trimmed; if (!actualName.contains(QLatin1Char('.'))) actualName += QStringLiteral(".md"); const QString err = validateName(actualName); if (!err.isEmpty()) return err; const QString path = QDir(dir).filePath(actualName); const QString cleanPath = QDir::cleanPath(path); if (!isInsideVault(cleanPath)) return QStringLiteral("Итоговый путь вне хранилища."); if (QFileInfo::exists(path)) return QStringLiteral("Файл «%1» уже существует в этом каталоге.").arg(actualName); QFile f(path); if (!f.open(QIODevice::WriteOnly)) return QStringLiteral("Не удалось создать файл: %1").arg(path); f.close(); rebuild(); return QString(); } QString FileStore::dailyNotePath(const QString &dateStr) { if (m_vault.isEmpty()) return QString(); // Валидация формата даты: YYYY-MM-DD static const QRegularExpression dateRe(QStringLiteral("^\\d{4}-\\d{2}-\\d{2}$")); if (!dateRe.match(dateStr).hasMatch()) return QString(); const QString journalDir = QDir::cleanPath(m_vault + "/Журнал"); if (!isInsideVault(journalDir)) return QString(); if (!QDir().mkpath(journalDir)) return QString(); const QString path = QDir(journalDir).filePath(dateStr + ".md"); const QString cleanPath = QDir::cleanPath(path); if (!isInsideVault(cleanPath)) return QString(); 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(journalDir); rebuild(); } return cleanPath; } QString FileStore::createFolder(const QString &parentPath, const QString &name) { if (m_vault.isEmpty()) return QStringLiteral("Хранилище не открыто."); QString dir = parentPath.isEmpty() ? m_vault : QDir::cleanPath(parentPath); if (!isInsideVault(dir)) return QStringLiteral("Путь вне хранилища: %1").arg(dir); QFileInfo dirInfo(dir); if (!dirInfo.exists() || !dirInfo.isDir()) return QStringLiteral("Папка не существует: %1").arg(dir); const QString err = validateName(name.trimmed()); if (!err.isEmpty()) return err; const QString path = QDir(dir).filePath(name.trimmed()); const QString cleanPath = QDir::cleanPath(path); if (!isInsideVault(cleanPath)) return QStringLiteral("Итоговый путь вне хранилища."); if (QFileInfo::exists(path)) return QStringLiteral("Папка с таким именем уже существует."); if (!QDir().mkpath(path)) return QStringLiteral("Не удалось создать папку: %1").arg(path); m_expanded.insert(QDir::cleanPath(path)); rebuild(); return QString(); } QString FileStore::renameItem(const QString &path, const QString &newName) { if (m_vault.isEmpty()) return QStringLiteral("Хранилище не открыто."); const QString cleanPath = QDir::cleanPath(path); if (!isInsideVault(cleanPath)) return QStringLiteral("Путь вне хранилища: %1").arg(path); QFileInfo fi(cleanPath); if (!fi.exists()) return QStringLiteral("Элемент не существует: %1").arg(path); const QString n = newName.trimmed(); const QString err = validateName(n); if (!err.isEmpty()) return err; const QString newPath = QDir(fi.absolutePath()).filePath(n); const QString cleanNew = QDir::cleanPath(newPath); if (!isInsideVault(cleanNew)) return QStringLiteral("Новый путь вне хранилища."); if (cleanPath == cleanNew) return QString(); if (QFileInfo::exists(newPath)) return QStringLiteral("Имя уже занято."); if (!QFile::rename(cleanPath, newPath) && !QDir().rename(cleanPath, newPath)) return QStringLiteral("Не удалось переименовать: %1").arg(path); if (fi.isDir()) { const QString oldAbs = cleanPath; const QString newAbs = cleanNew; // Обновляем все раскрытые пути с префиксом oldAbs (включая вложенные) QSet updated; for (const QString &p : std::as_const(m_expanded)) { if (p == oldAbs) updated.insert(newAbs); else if (p.startsWith(oldAbs + '/')) updated.insert(newAbs + p.mid(oldAbs.size())); else updated.insert(p); } m_expanded = std::move(updated); } rebuild(); return QString(); } QString FileStore::removeItem(const QString &path) { if (m_vault.isEmpty()) return QStringLiteral("Хранилище не открыто."); const QString clean = QDir::cleanPath(path); if (!isInsideVault(clean)) return QStringLiteral("Путь вне хранилища: %1").arg(path); if (clean == QDir::cleanPath(m_vault)) return QStringLiteral("Нельзя удалить корень хранилища."); QFileInfo fi(clean); if (!fi.exists()) return QStringLiteral("Элемент не существует: %1").arg(path); if (fi.isDir()) { QDir d(clean); if (!d.removeRecursively()) return QStringLiteral("Не удалось удалить папку «%1».").arg(path); QSet updated; for (const QString &p : std::as_const(m_expanded)) { if (p != clean && !p.startsWith(clean + '/')) updated.insert(p); } m_expanded = std::move(updated); } else { if (!QFile::remove(clean)) return QStringLiteral("Не удалось удалить файл «%1».").arg(path); } rebuild(); return QString(); } QString FileStore::moveItem(const QString &srcPath, const QString &destDir) { if (m_vault.isEmpty()) return QStringLiteral("Хранилище не открыто."); if (srcPath.isEmpty() || destDir.isEmpty()) return QStringLiteral("Не указаны пути."); const QString src = QDir::cleanPath(srcPath); const QString dstDir = QDir::cleanPath(destDir); if (!isInsideVault(src) || !isInsideVault(dstDir)) return QStringLiteral("Путь вне хранилища."); QFileInfo srcInfo(src); QFileInfo dstInfo(dstDir); if (!srcInfo.exists()) return QStringLiteral("Источник не существует: %1").arg(src); if (!dstInfo.exists() || !dstInfo.isDir()) return QStringLiteral("Папка назначения не существует или не является папкой: %1").arg(dstDir); if (src == QDir::cleanPath(m_vault)) return QStringLiteral("Нельзя переместить корень хранилища."); if (src == dstDir || dstDir == QDir::cleanPath(QFileInfo(src).absolutePath())) return QString(); // Запрещаем перемещение папки в саму себя или в подпапку if (dstDir == src || dstDir.startsWith(src + '/')) return QStringLiteral("Нельзя переместить папку в саму себя."); const QFileInfo fi(src); const QString newPath = QDir(dstDir).filePath(fi.fileName()); const QString cleanNew = QDir::cleanPath(newPath); if (!isInsideVault(cleanNew)) return QStringLiteral("Итоговый путь вне хранилища."); if (QFileInfo::exists(newPath)) return QStringLiteral("Элемент с таким именем уже есть в папке назначения."); bool ok = false; if (fi.isDir()) ok = QDir().rename(src, newPath); else ok = QFile::rename(src, newPath); if (!ok) return QStringLiteral("Не удалось переместить «%1».").arg(src); // переносим состояние раскрытия для перемещаемой папки и её подпапок if (fi.isDir()) { const QString oldAbs = src; const QString newAbs = cleanNew; QSet updated; for (const QString &p : std::as_const(m_expanded)) { if (p == oldAbs) updated.insert(newAbs); else if (p.startsWith(oldAbs + '/')) updated.insert(newAbs + p.mid(oldAbs.size())); else updated.insert(p); } m_expanded = std::move(updated); } rebuild(); return QString(); } QString FileStore::itemName(int row) const { if (row < 0 || row >= m_rows.size()) return QString(); return m_rows[row].name; } QString FileStore::itemPath(int row) const { if (row < 0 || row >= m_rows.size()) return QString(); return m_rows[row].path; } bool FileStore::itemIsDir(int row) const { return row >= 0 && row < m_rows.size() && m_rows[row].isDir; } bool FileStore::itemHasChildren(int row) const { return row >= 0 && row < m_rows.size() && m_rows[row].hasChildren; } bool FileStore::fileExists(const QString &path) const { return QFileInfo::exists(path); }