#include "FrecencyStore.h" #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr qint64 kDayMs = 24LL * 60LL * 60LL * 1000LL; constexpr int kMaxScore = std::numeric_limits::max(); } // namespace FrecencyStore::FrecencyStore(QString filePath) : m_path(std::move(filePath)) {} FrecencyStore::~FrecencyStore() { // An open recorded inside the last save interval still lands, so a quit // never costs the most recent work. const QMutexLocker locker(&m_mutex); if (m_dirty) { writeLocked(); } } FrecencyStore& FrecencyStore::instance() { static FrecencyStore store(defaultPath()); return store; } QString FrecencyStore::defaultPath() { QString fromEnv = qEnvironmentVariable("XENO_FRECENCY_FILE"); if (!fromEnv.isEmpty()) { return fromEnv; } // XDG Base Directory spec: configuration lives under $XDG_CONFIG_HOME, // defaulting to ~/.config. QStandardPaths resolves a different directory on // macOS, so the path is built here to keep one config directory everywhere. QString configDir = qEnvironmentVariable("XDG_CONFIG_HOME"); if (configDir.isEmpty()) { configDir = QDir::home().filePath(QStringLiteral(".config")); } return QDir(configDir).filePath(QStringLiteral("xenomorphic/frecency.json")); } int FrecencyStore::weightForAge(qint64 ageMs) { if (ageMs < 4 * kDayMs) { return 100; } if (ageMs < 14 * kDayMs) { return 70; } if (ageMs < 31 * kDayMs) { return 50; } if (ageMs < 90 * kDayMs) { return 30; } return 20; } void FrecencyStore::record(const QString& path) { if (path.isEmpty()) { return; } const qint64 now = QDateTime::currentMSecsSinceEpoch(); const QMutexLocker locker(&m_mutex); Entry& entry = m_entries[path]; entry.count += 1; entry.lastUsedMs = now; m_dirty = true; if (now - m_lastSaveMs >= kFrecencySaveIntervalMs) { pruneLocked(); writeLocked(); m_lastSaveMs = now; m_dirty = false; } } int FrecencyStore::score(const QString& path) const { const QMutexLocker locker(&m_mutex); const auto it = m_entries.constFind(path); if (it == m_entries.constEnd()) { return 0; } const qint64 age = QDateTime::currentMSecsSinceEpoch() - it->lastUsedMs; if (age > kFrecencyMaxAgeMs) { return 0; } const qint64 product = qint64(it->count) * weightForAge(age); return product > kMaxScore ? kMaxScore : int(product); } int FrecencyStore::count(const QString& path) const { const QMutexLocker locker(&m_mutex); return m_entries.value(path).count; } qint64 FrecencyStore::lastUsedMs(const QString& path) const { const QMutexLocker locker(&m_mutex); return m_entries.value(path).lastUsedMs; } QHash FrecencyStore::snapshot() const { const qint64 now = QDateTime::currentMSecsSinceEpoch(); const QMutexLocker locker(&m_mutex); QHash scores; scores.reserve(m_entries.size()); for (auto it = m_entries.constBegin(); it != m_entries.constEnd(); ++it) { const qint64 age = now - it->lastUsedMs; if (age > kFrecencyMaxAgeMs) { continue; } const qint64 product = qint64(it->count) * weightForAge(age); scores.insert(it.key(), product > kMaxScore ? kMaxScore : int(product)); } return scores; } void FrecencyStore::load() { QHash loaded; QFile file(m_path); if (file.open(QIODevice::ReadOnly)) { const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); if (doc.isObject()) { const QJsonObject entries = doc.object().value("entries").toObject(); for (auto it = entries.constBegin(); it != entries.constEnd(); ++it) { if (!it.value().isObject()) { continue; } const QJsonObject raw = it.value().toObject(); Entry entry; entry.count = raw.value("count").toInt(); entry.lastUsedMs = qint64(raw.value("lastUsedMs").toDouble()); if (entry.count > 0 && entry.lastUsedMs > 0) { loaded.insert(it.key(), entry); } } } } const QMutexLocker locker(&m_mutex); m_entries = std::move(loaded); m_dirty = false; } void FrecencyStore::save() { const QMutexLocker locker(&m_mutex); pruneLocked(); writeLocked(); m_lastSaveMs = QDateTime::currentMSecsSinceEpoch(); m_dirty = false; } void FrecencyStore::prune() { const QMutexLocker locker(&m_mutex); pruneLocked(); m_dirty = true; } int FrecencyStore::size() const { const QMutexLocker locker(&m_mutex); return int(m_entries.size()); } void FrecencyStore::writeLocked() const { QJsonObject entries; for (auto it = m_entries.constBegin(); it != m_entries.constEnd(); ++it) { QJsonObject raw; raw.insert(QStringLiteral("count"), it->count); raw.insert(QStringLiteral("lastUsedMs"), double(it->lastUsedMs)); entries.insert(it.key(), raw); } QJsonObject root; root.insert(QStringLiteral("version"), 1); root.insert(QStringLiteral("entries"), entries); // QSaveFile writes a temporary file and renames it over the target, so a // reader never meets a half-written table. QDir().mkpath(QFileInfo(m_path).absolutePath()); QSaveFile file(m_path); if (file.open(QIODevice::WriteOnly)) { file.write(QJsonDocument(root).toJson(QJsonDocument::Compact)); file.commit(); } } void FrecencyStore::pruneLocked() { const qint64 now = QDateTime::currentMSecsSinceEpoch(); // The keys are collected first, because removing from the table while a // range-for reads it would invalidate the iteration. std::vector stale; for (auto it = m_entries.constBegin(); it != m_entries.constEnd(); ++it) { if (now - it->lastUsedMs > kFrecencyMaxAgeMs) { stale.push_back(it.key()); } } for (const QString& key : stale) { m_entries.remove(key); } if (m_entries.size() <= kFrecencyMaxEntries) { return; } // Beyond the cap the highest scores stay, and the path breaks every tie so // two runs of the same table keep the same entries. std::vector keys; keys.reserve(std::size_t(m_entries.size())); for (auto it = m_entries.constBegin(); it != m_entries.constEnd(); ++it) { keys.push_back(it.key()); } const auto scoreOf = [this, now](const QString& key) { const Entry entry = m_entries.value(key); const qint64 age = now - entry.lastUsedMs; return qint64(entry.count) * (age > 0 ? weightForAge(age) : 100); }; std::ranges::sort(keys, [&scoreOf](const QString& left, const QString& right) { const qint64 leftScore = scoreOf(left); const qint64 rightScore = scoreOf(right); if (leftScore != rightScore) { return leftScore > rightScore; } return left < right; }); keys.resize(std::size_t(kFrecencyMaxEntries)); QHash kept; kept.reserve(qsizetype(keys.size())); for (const QString& key : keys) { kept.insert(key, m_entries.value(key)); } m_entries = std::move(kept); }