Skip to content

Commit 9b9ddc1

Browse files
committed
Add archive search index and CLI features
Introduce a new ArchiveSearchIndex (src/archive/*) to index archive entries and provide tokenized search with scoring. Extend the CLI with new actions (save-as, convert, preview-export), selection/mounting flags, and additional save-as options; update CLI implementation and headers. Update docs: expand README CLI examples/options and note async updater/manifest verification in RELEASES; add a proposals/improvements.md assessment. Add unit tests for archive search and archive writer round-trip and apply related updates across UI, update service, WAD/ZIP backends, and build files.
1 parent f0324be commit 9b9ddc1

19 files changed

Lines changed: 3249 additions & 811 deletions

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,21 @@ Core actions:
166166
- `-l, --list` : list entries.
167167
- `-i, --info` : show archive summary.
168168
- `-x, --extract` : extract entries (`-o, --output <dir>` optional).
169+
- `--save-as <archive>` : rebuild selected entries into a new archive.
170+
- `--convert <format>` : convert selected images to a supported image-writer output (`png`, `jpg`, `bmp`, `gif`, `tga`, `tiff`, `pcx`, `wal`, `swl`, `mip`, `lmp`, `ftx`, `dds`) or IDWAV audio to `wav`.
171+
- `--preview-export <entry>` : export the CLI preview rendition of one entry; images write an image file, text and binary entries write bytes.
169172
- `--check-updates` : query GitHub Releases.
170173
- `--qa-practical` : run practical archive-ops smoke QA (selection/marquee/modifier checks).
171174

175+
Archive selection and mounting:
176+
- `--entry <path>` : limit `--list`, `--extract`, `--save-as`, or `--convert` to an exact archive entry. Repeat for multiple entries.
177+
- `--prefix <dir>` : limit `--list`, `--extract`, `--save-as`, or `--convert` to entries under a directory prefix. Repeat for multiple prefixes.
178+
- `--mount <entry>` : mount a nested archive entry first, then run the requested action against the mounted archive.
179+
180+
Save-as options:
181+
- `--format <pak|sin|zip|pk3|pk4|pkz|wad|wad2>` : choose the output archive format. If omitted, PakFu infers it from `--save-as`.
182+
- `--quakelive-encrypt-pk3` : write ZIP-family `--save-as` output with Quake Live Beta PK3 encryption.
183+
172184
Installation profile actions:
173185
- `--list-game-installs`
174186
- `--auto-detect-game-installs`
@@ -188,6 +200,11 @@ Examples:
188200
./builddir/src/pakfu --cli --info path/to/archive.pk3
189201
./builddir/src/pakfu --cli --list path/to/archive.wad
190202
./builddir/src/pakfu --cli --extract -o out_dir path/to/archive.resources
203+
./builddir/src/pakfu --cli --extract --prefix maps -o maps_out path/to/archive.pk3
204+
./builddir/src/pakfu --cli --save-as mod_subset.pk3 --format pk3 --prefix textures path/to/archive.pk3
205+
./builddir/src/pakfu --cli --convert png --prefix textures -o converted_textures path/to/archive.pk3
206+
./builddir/src/pakfu --cli --preview-export gfx/conback.lmp -o previews path/to/pak0.pak
207+
./builddir/src/pakfu --cli --mount nested/maps.pk3 --list path/to/archive.pk3
191208
./builddir/src/pakfu --cli --check-updates
192209
./builddir/src/pakfu --cli --qa-practical
193210
./builddir/src/pakfu --cli --list-game-installs
@@ -239,8 +256,10 @@ Selector support:
239256

240257
## Updates and Releases
241258
PakFu can check GitHub Releases at runtime and from CLI.
259+
GUI auto-checks run after the main window is shown, so network latency does not block startup.
242260

243261
GUI updater install handoff:
262+
- Downloaded update assets are verified against the release manifest (`size` and `sha256`) before PakFu opens the download folder or launches an installer.
244263
- Windows: `Install and Restart` waits for installer completion, then relaunches PakFu (preferring the installer-managed `%LOCALAPPDATA%\PakFu\pakfu.exe` after MSI updates).
245264
- macOS/Linux: PakFu exits and starts the downloaded installer artifact (`.pkg` / `.AppImage`) after shutdown.
246265

docs/RELEASES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ Required assets per platform:
4242

4343
Current updater behavior prefers installer assets and falls back to portable
4444
archives when needed.
45+
Runtime update checks are asynchronous and do not block the main window from
46+
opening. When the GUI updater downloads an asset, it first downloads the
47+
matching `pakfu-<version>-release-manifest.json` asset and verifies the selected
48+
package by exact asset name, byte size, and SHA-256 before opening the downloaded
49+
folder or launching an installer handoff.
4550

4651
Linux portable archives are produced from the same deployed AppDir used for the
4752
AppImage, so Qt and other runtime libraries are bundled in both Linux assets.

docs/proposals/improvements.md

Lines changed: 180 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
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

Comments
 (0)