|
| 1 | +#include "archive/archive_search_index.h" |
| 2 | + |
| 3 | +#include <algorithm> |
| 4 | + |
| 5 | +#include <QFileInfo> |
| 6 | +#include <QHash> |
| 7 | +#include <QtGlobal> |
| 8 | + |
| 9 | +#include "archive/path_safety.h" |
| 10 | + |
| 11 | +namespace { |
| 12 | +[[nodiscard]] bool path_starts_with(const QString& path, const QString& prefix) { |
| 13 | + if (path.isEmpty() || prefix.isEmpty()) { |
| 14 | + return false; |
| 15 | + } |
| 16 | +#if defined(Q_OS_WIN) |
| 17 | + return path.startsWith(prefix, Qt::CaseInsensitive); |
| 18 | +#else |
| 19 | + return path.startsWith(prefix); |
| 20 | +#endif |
| 21 | +} |
| 22 | + |
| 23 | +[[nodiscard]] QString normalize_dir_prefix(QString path) { |
| 24 | + path = normalize_archive_entry_name(std::move(path)); |
| 25 | + if (!path.isEmpty() && !path.endsWith('/')) { |
| 26 | + path += '/'; |
| 27 | + } |
| 28 | + return path; |
| 29 | +} |
| 30 | + |
| 31 | +[[nodiscard]] QString leaf_name(QString path) { |
| 32 | + path = normalize_archive_entry_name(std::move(path)); |
| 33 | + if (path.endsWith('/')) { |
| 34 | + path.chop(1); |
| 35 | + } |
| 36 | + const int slash = path.lastIndexOf('/'); |
| 37 | + return slash >= 0 ? path.mid(slash + 1) : path; |
| 38 | +} |
| 39 | + |
| 40 | +[[nodiscard]] QString file_ext_lower(const QString& path) { |
| 41 | + const QString leaf = leaf_name(path).toLower(); |
| 42 | + const int dot = leaf.lastIndexOf('.'); |
| 43 | + return dot >= 0 ? leaf.mid(dot + 1) : QString(); |
| 44 | +} |
| 45 | + |
| 46 | +[[nodiscard]] bool is_model_ext(const QString& ext) { |
| 47 | + return ext == "mdl" || ext == "md2" || ext == "md3" || ext == "mdc" || ext == "md4" || |
| 48 | + ext == "mdr" || ext == "skb" || ext == "skd" || ext == "mdm" || ext == "glm" || |
| 49 | + ext == "iqm" || ext == "md5mesh" || ext == "tan" || ext == "lwo" || ext == "obj"; |
| 50 | +} |
| 51 | + |
| 52 | +[[nodiscard]] QStringList dependency_hints_for_path(const QString& path) { |
| 53 | + const QString normalized = normalize_archive_entry_name(path); |
| 54 | + if (normalized.isEmpty() || normalized.endsWith('/')) { |
| 55 | + return {}; |
| 56 | + } |
| 57 | + |
| 58 | + const int slash = normalized.lastIndexOf('/'); |
| 59 | + const QString dir = slash >= 0 ? normalized.left(slash + 1) : QString(); |
| 60 | + const QString leaf = slash >= 0 ? normalized.mid(slash + 1) : normalized; |
| 61 | + const QFileInfo info(leaf); |
| 62 | + const QString base = info.completeBaseName(); |
| 63 | + const QString ext = file_ext_lower(leaf); |
| 64 | + |
| 65 | + QStringList hints; |
| 66 | + if (is_model_ext(ext)) { |
| 67 | + hints << dir + base + ".skin"; |
| 68 | + hints << dir + base + ".png"; |
| 69 | + hints << dir + base + ".tga"; |
| 70 | + hints << dir + base + ".jpg"; |
| 71 | + hints << dir + base + ".jpeg"; |
| 72 | + } |
| 73 | + if (ext == "shader") { |
| 74 | + hints << "textures/"; |
| 75 | + hints << "models/"; |
| 76 | + } |
| 77 | + if (ext == "bsp") { |
| 78 | + hints << "scripts/"; |
| 79 | + hints << "textures/"; |
| 80 | + } |
| 81 | + if (ext == "skin") { |
| 82 | + hints << dir + base + ".md3"; |
| 83 | + hints << dir + base + ".mdc"; |
| 84 | + hints << dir + base + ".mdr"; |
| 85 | + } |
| 86 | + |
| 87 | + hints.removeDuplicates(); |
| 88 | + return hints; |
| 89 | +} |
| 90 | + |
| 91 | +[[nodiscard]] QStringList query_tokens(QString query) { |
| 92 | + query.replace('\\', '/'); |
| 93 | + query = query.toLower().simplified(); |
| 94 | + if (query.isEmpty()) { |
| 95 | + return {}; |
| 96 | + } |
| 97 | + return query.split(' ', Qt::SkipEmptyParts); |
| 98 | +} |
| 99 | + |
| 100 | +[[nodiscard]] int score_match(const ArchiveSearchIndex::Item& item, const QStringList& tokens) { |
| 101 | + if (tokens.isEmpty()) { |
| 102 | + return -1; |
| 103 | + } |
| 104 | + |
| 105 | + const QString path = item.path.toLower(); |
| 106 | + const QString leaf = leaf_name(item.path).toLower(); |
| 107 | + const QString source = item.source_path.toLower(); |
| 108 | + const QString deps = item.dependency_hints.join(' ').toLower(); |
| 109 | + const QString kind = item.is_dir ? QStringLiteral("folder directory") : QStringLiteral("file asset"); |
| 110 | + const QString state = item.is_overridden ? QStringLiteral(" modified overridden") : (item.is_added ? QStringLiteral(" added new") : QString()); |
| 111 | + const QString haystack = path + ' ' + leaf + ' ' + source + ' ' + deps + ' ' + kind + state; |
| 112 | + |
| 113 | + int score = 0; |
| 114 | + for (const QString& token : tokens) { |
| 115 | + if (!haystack.contains(token)) { |
| 116 | + return -1; |
| 117 | + } |
| 118 | + if (path == token || leaf == token) { |
| 119 | + score += 0; |
| 120 | + } else if (leaf.startsWith(token)) { |
| 121 | + score += 4; |
| 122 | + } else if (path.startsWith(token)) { |
| 123 | + score += 8; |
| 124 | + } else if (path.contains('/' + token)) { |
| 125 | + score += 14; |
| 126 | + } else if (deps.contains(token)) { |
| 127 | + score += 24; |
| 128 | + } else { |
| 129 | + score += 36; |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + score += qMin(path.size(), 500); |
| 134 | + if (item.is_dir) { |
| 135 | + score += 10; |
| 136 | + } |
| 137 | + return score; |
| 138 | +} |
| 139 | +} // namespace |
| 140 | + |
| 141 | +void ArchiveSearchIndex::clear() { |
| 142 | + items_.clear(); |
| 143 | +} |
| 144 | + |
| 145 | +void ArchiveSearchIndex::rebuild(const BuildInput& input) { |
| 146 | + items_.clear(); |
| 147 | + |
| 148 | + QSet<QString> deleted_files; |
| 149 | + deleted_files.reserve(input.deleted_files.size()); |
| 150 | + for (const QString& path : input.deleted_files) { |
| 151 | + const QString normalized = normalize_archive_entry_name(path); |
| 152 | + if (!normalized.isEmpty()) { |
| 153 | + deleted_files.insert(normalized); |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + QVector<QString> deleted_dirs; |
| 158 | + deleted_dirs.reserve(input.deleted_dir_prefixes.size()); |
| 159 | + for (const QString& path : input.deleted_dir_prefixes) { |
| 160 | + const QString normalized = normalize_dir_prefix(path); |
| 161 | + if (!normalized.isEmpty()) { |
| 162 | + deleted_dirs.push_back(normalized); |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + const auto is_deleted = [&](const QString& path_in) -> bool { |
| 167 | + const QString path = normalize_archive_entry_name(path_in); |
| 168 | + if (path.isEmpty()) { |
| 169 | + return false; |
| 170 | + } |
| 171 | + if (deleted_files.contains(path)) { |
| 172 | + return true; |
| 173 | + } |
| 174 | + for (const QString& dir : deleted_dirs) { |
| 175 | + if (path_starts_with(path, dir)) { |
| 176 | + return true; |
| 177 | + } |
| 178 | + } |
| 179 | + return false; |
| 180 | + }; |
| 181 | + |
| 182 | + QHash<QString, ArchiveEntry> archive_files; |
| 183 | + QSet<QString> dirs; |
| 184 | + archive_files.reserve(input.archive_entries.size()); |
| 185 | + |
| 186 | + const auto add_parent_dirs = [&](const QString& path_in) { |
| 187 | + QString path = normalize_archive_entry_name(path_in); |
| 188 | + if (path.endsWith('/')) { |
| 189 | + path.chop(1); |
| 190 | + } |
| 191 | + int slash = path.indexOf('/'); |
| 192 | + while (slash >= 0) { |
| 193 | + const QString dir = path.left(slash + 1); |
| 194 | + if (!dir.isEmpty() && !is_deleted(dir)) { |
| 195 | + dirs.insert(dir); |
| 196 | + } |
| 197 | + slash = path.indexOf('/', slash + 1); |
| 198 | + } |
| 199 | + }; |
| 200 | + |
| 201 | + for (const ArchiveEntry& entry : input.archive_entries) { |
| 202 | + QString path = normalize_archive_entry_name(entry.name); |
| 203 | + if (path.isEmpty() || is_deleted(path) || !is_safe_archive_entry_name(path)) { |
| 204 | + continue; |
| 205 | + } |
| 206 | + if (path.endsWith('/')) { |
| 207 | + dirs.insert(path); |
| 208 | + add_parent_dirs(path); |
| 209 | + continue; |
| 210 | + } |
| 211 | + archive_files.insert(path, entry); |
| 212 | + add_parent_dirs(path); |
| 213 | + } |
| 214 | + |
| 215 | + for (const QString& dir_in : input.virtual_dirs) { |
| 216 | + const QString dir = normalize_dir_prefix(dir_in); |
| 217 | + if (!dir.isEmpty() && !is_deleted(dir) && is_safe_archive_entry_name(dir)) { |
| 218 | + dirs.insert(dir); |
| 219 | + add_parent_dirs(dir); |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + QSet<QString> overlay_paths; |
| 224 | + overlay_paths.reserve(input.overlay_files.size()); |
| 225 | + for (const OverlayFile& overlay : input.overlay_files) { |
| 226 | + const QString path = normalize_archive_entry_name(overlay.path); |
| 227 | + if (!path.isEmpty()) { |
| 228 | + overlay_paths.insert(path); |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + items_.reserve(archive_files.size() + input.overlay_files.size() + dirs.size()); |
| 233 | + |
| 234 | + QStringList sorted_dirs = dirs.values(); |
| 235 | + std::sort(sorted_dirs.begin(), sorted_dirs.end(), [](const QString& a, const QString& b) { |
| 236 | + return a.compare(b, Qt::CaseInsensitive) < 0; |
| 237 | + }); |
| 238 | + for (const QString& dir : sorted_dirs) { |
| 239 | + Item item; |
| 240 | + item.path = dir; |
| 241 | + item.scope_label = input.scope_label; |
| 242 | + item.is_dir = true; |
| 243 | + item.mtime_utc_secs = input.fallback_mtime_utc_secs; |
| 244 | + items_.push_back(std::move(item)); |
| 245 | + } |
| 246 | + |
| 247 | + QStringList sorted_files = archive_files.keys(); |
| 248 | + std::sort(sorted_files.begin(), sorted_files.end(), [](const QString& a, const QString& b) { |
| 249 | + return a.compare(b, Qt::CaseInsensitive) < 0; |
| 250 | + }); |
| 251 | + for (const QString& path : sorted_files) { |
| 252 | + if (overlay_paths.contains(path)) { |
| 253 | + continue; |
| 254 | + } |
| 255 | + const ArchiveEntry entry = archive_files.value(path); |
| 256 | + Item item; |
| 257 | + item.path = path; |
| 258 | + item.scope_label = input.scope_label; |
| 259 | + item.size = entry.size; |
| 260 | + item.mtime_utc_secs = entry.mtime_utc_secs >= 0 ? entry.mtime_utc_secs : input.fallback_mtime_utc_secs; |
| 261 | + item.dependency_hints = dependency_hints_for_path(path); |
| 262 | + items_.push_back(std::move(item)); |
| 263 | + } |
| 264 | + |
| 265 | + for (const OverlayFile& overlay : input.overlay_files) { |
| 266 | + const QString path = normalize_archive_entry_name(overlay.path); |
| 267 | + if (path.isEmpty() || is_deleted(path) || !is_safe_archive_entry_name(path)) { |
| 268 | + continue; |
| 269 | + } |
| 270 | + Item item; |
| 271 | + item.path = path; |
| 272 | + item.source_path = overlay.source_path; |
| 273 | + item.scope_label = input.scope_label; |
| 274 | + item.size = overlay.size; |
| 275 | + item.mtime_utc_secs = overlay.mtime_utc_secs; |
| 276 | + item.is_added = true; |
| 277 | + item.is_overridden = archive_files.contains(path); |
| 278 | + item.dependency_hints = dependency_hints_for_path(path); |
| 279 | + items_.push_back(std::move(item)); |
| 280 | + } |
| 281 | +} |
| 282 | + |
| 283 | +QVector<ArchiveSearchIndex::Item> ArchiveSearchIndex::search(const QString& query, int max_results) const { |
| 284 | + const QStringList tokens = query_tokens(query); |
| 285 | + if (tokens.isEmpty() || max_results == 0) { |
| 286 | + return {}; |
| 287 | + } |
| 288 | + |
| 289 | + struct ScoredItem { |
| 290 | + Item item; |
| 291 | + int score = 0; |
| 292 | + }; |
| 293 | + |
| 294 | + QVector<ScoredItem> scored; |
| 295 | + scored.reserve(items_.size()); |
| 296 | + for (const Item& item : items_) { |
| 297 | + const int score = score_match(item, tokens); |
| 298 | + if (score >= 0) { |
| 299 | + scored.push_back(ScoredItem{item, score}); |
| 300 | + } |
| 301 | + } |
| 302 | + |
| 303 | + std::sort(scored.begin(), scored.end(), [](const ScoredItem& a, const ScoredItem& b) { |
| 304 | + if (a.score != b.score) { |
| 305 | + return a.score < b.score; |
| 306 | + } |
| 307 | + return a.item.path.compare(b.item.path, Qt::CaseInsensitive) < 0; |
| 308 | + }); |
| 309 | + |
| 310 | + if (max_results > 0 && scored.size() > max_results) { |
| 311 | + scored.resize(max_results); |
| 312 | + } |
| 313 | + |
| 314 | + QVector<Item> out; |
| 315 | + out.reserve(scored.size()); |
| 316 | + for (const ScoredItem& item : scored) { |
| 317 | + out.push_back(item.item); |
| 318 | + } |
| 319 | + return out; |
| 320 | +} |
0 commit comments