Skip to main content

rpfm_server/state/
packs.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Pack lifecycle and metadata operations: open, create, save, close, flags, settings and notes.
12
13use anyhow::{anyhow, Result};
14use rayon::prelude::*;
15use uuid::Uuid;
16
17use std::collections::BTreeMap;
18use std::fs::DirBuilder;
19use std::io::Cursor;
20use std::path::{Path, PathBuf};
21use std::time::SystemTime;
22
23use rpfm_ipc::api::ApiError;
24use rpfm_ipc::api::notes::{AddNote, NoteEntry};
25use rpfm_ipc::api::files::{FilesInfo, GetFilesInfo};
26use rpfm_ipc::api::packs::{PackDependency, PackDetails, PackSettingsValues, PackSummary, UpdatePack, UpdatePackSettings};
27use rpfm_ipc::api::schema::MissingDefinitions;
28use rpfm_ipc::helpers::ContainerInfo;
29use rpfm_ipc::api::packs::OperationalMode;
30
31use rpfm_lib::compression::CompressionFormat;
32use rpfm_lib::files::{Container, DecodeableExtraData, db::DB, FileType, pack::{Pack, PackSettings, PFHFlags}};
33use rpfm_lib::games::{GameInfo, pfh_file_type::PFHFileType};
34use rpfm_lib::notes::Note;
35use rpfm_lib::utils::{files_from_subdir, files_in_folder_from_newest_to_oldest};
36
37use rpfm_ipc::settings::{backup_autosave_path, Settings};
38use rpfm_ipc::settings_keys::{AUTOSAVE_AMOUNT, DISABLE_UUID_REGENERATION_ON_DB_TABLES};
39
40use rpfm_telemetry::warn;
41
42use super::{SaveOptions, SessionState, decode_tables, encode_extra_data, loaded_schema, pack, pack_mut, pack_summary};
43
44/// Stem used to seed names for newly created Packs (`new_pack.pack`, `new_pack_2.pack`, …).
45const DEFAULT_PACK_STEM: &str = "new_pack";
46
47/// Extension appended to [`DEFAULT_PACK_STEM`] when materialising a new Pack's filename.
48const DEFAULT_PACK_EXT: &str = ".pack";
49
50/// Size of the autosave folder, in bytes, over which a warning is logged after each autosave (25 GB).
51const AUTOSAVE_FOLDER_SIZE_WARNING: u64 = 25 * 1024 * 1024 * 1024;
52
53/// Name of the pack with all the CA packs of the selected game merged.
54const CA_PACKS_NAME: &str = "CA PackFiles";
55
56impl SessionState {
57
58    /// Creates a new empty pack.
59    ///
60    /// # Arguments
61    ///
62    /// * `name` - File name of the pack. If `None`, a free `new_pack.pack`-like name is used.
63    /// * `settings` - Settings, to find the game's install folder.
64    ///
65    /// # Returns
66    ///
67    /// The key of the new pack.
68    ///
69    /// # Errors
70    ///
71    /// Fails if the name is not a file name ending in `.pack`.
72    pub fn new_pack(&mut self, name: Option<&str>, settings: &Settings) -> Result<String> {
73        let name = match name {
74            Some(name) if !name.ends_with(DEFAULT_PACK_EXT) || name.contains(['/', '\\']) => {
75                return Err(ApiError::InvalidParams(format!("The name of the pack must be a file name ending in {DEFAULT_PACK_EXT}: {name}")).into());
76            }
77            Some(name) => name.to_owned(),
78            None => derive_new_pack_name(&self.packs),
79        };
80
81        let pack_version = self.game.pfh_version_by_file_type(PFHFileType::Mod);
82        let mut pack = Pack::new_with_name_and_version(&name, pack_version);
83
84        if let Some(version_number) = self.game.game_version_number(&settings.path_buf(self.game.key())) {
85            pack.set_game_version(version_number);
86        }
87
88        Ok(self.insert_pack(pack))
89    }
90
91    /// Opens one or more packs, merged into a single one.
92    ///
93    /// # Arguments
94    ///
95    /// * `paths` - Paths of the packs to open. If there are more than one, the merged pack has no path, and is named after the first one.
96    /// * `lazy_loading` - If file data should be read from disk only when needed.
97    ///
98    /// # Returns
99    ///
100    /// The key and info of the opened pack.
101    ///
102    /// # Errors
103    ///
104    /// Fails if any of the packs is already open, or if any of them can't be read.
105    pub fn open_packs(&mut self, paths: &[PathBuf], lazy_loading: bool) -> Result<(String, ContainerInfo)> {
106        if let Some(path) = paths.iter().find(|path| {
107            let normalized = path.to_string_lossy().replace('\\', "/");
108            self.packs.values().any(|pack| pack.disk_file_path() == normalized.as_str())
109        }) {
110            return Err(anyhow!("Pack '{}' is already open. Close it first if you want to reopen it.", path.display()));
111        }
112
113        let mut pack = Pack::read_and_merge(paths, &self.game, lazy_loading, false, false)?;
114        decode_tables(&mut pack.files_by_type_mut(&[FileType::DB, FileType::Loc]), &self.schema);
115
116        if paths.len() > 1 {
117            let name = paths[0].file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_default();
118            pack.set_disk_file_path(name);
119        }
120
121        let info = ContainerInfo::from(&pack);
122        let key = self.insert_pack(pack);
123        Ok((key, info))
124    }
125
126    /// Opens all the CA packs of the selected game, merged into a single one.
127    ///
128    /// # Arguments
129    ///
130    /// * `settings` - Settings, to find the game's install folder.
131    ///
132    /// # Returns
133    ///
134    /// The key and info of the opened pack.
135    ///
136    /// # Errors
137    ///
138    /// Fails if they're already open, or if they can't be read.
139    pub fn open_ca_packs(&mut self, settings: &Settings) -> Result<(String, ContainerInfo)> {
140        if self.packs.values().any(|pack| pack.disk_file_path() == CA_PACKS_NAME) {
141            return Err(anyhow!("Pack '{}' is already open. Close it first if you want to reopen it.", CA_PACKS_NAME));
142        }
143
144        let mut pack = Pack::read_and_merge_ca_packs(&self.game, &settings.path_buf(self.game.key()))?;
145        decode_tables(&mut pack.files_by_type_mut(&[FileType::DB, FileType::Loc]), &self.schema);
146        pack.set_disk_file_path(CA_PACKS_NAME.to_owned());
147
148        let info = ContainerInfo::from(&pack);
149        let key = self.insert_pack(pack);
150        Ok((key, info))
151    }
152
153    /// Closes a pack without saving it.
154    pub fn close_pack(&mut self, pack_key: &str) -> Result<()> {
155        self.packs.remove(pack_key).ok_or_else(|| ApiError::PackNotFound(pack_key.to_owned()))?;
156        self.pack_modes.remove(pack_key);
157        self.changed_packs.remove(pack_key);
158        self.session.remove_pack(pack_key);
159        Ok(())
160    }
161
162    /// Closes all open packs without saving them.
163    pub fn close_all_packs(&mut self) {
164        for pack_key in self.packs.keys() {
165            self.session.remove_pack(pack_key);
166        }
167
168        self.packs.clear();
169        self.pack_modes.clear();
170        self.changed_packs.clear();
171    }
172
173    /// Saves a pack to disk.
174    ///
175    /// # Arguments
176    ///
177    /// * `pack_key` - Key of the pack to save.
178    /// * `path` - Absolute path to save the pack to, creating its missing folders. If `None`, the pack is saved to its current path.
179    /// * `options` - How to save the pack.
180    ///
181    /// # Returns
182    ///
183    /// The info of the saved pack.
184    ///
185    /// # Errors
186    ///
187    /// Fails if the pack is of a CA type and editing them is not allowed, if it has no path on disk yet, if the new path is not absolute, or if saving fails.
188    pub fn save_pack(&mut self, pack_key: &str, path: Option<&Path>, options: SaveOptions) -> Result<ContainerInfo> {
189        self.wait_for_autosave();
190        let pack = pack_mut(&mut self.packs, pack_key)?;
191
192        let pack_type = *pack.header().pfh_file_type();
193        if !options.allow_editing_of_ca_packfiles && pack_type != PFHFileType::Mod && pack_type != PFHFileType::Movie {
194            return Err(anyhow!("Pack cannot be saved due to being of CA-Only type. Either change the Pack Type or enable \"Allow Edition of CA Packs\" in the settings."));
195        }
196
197        // New packs only have a bare name, which would silently save to the server's path.
198        if path.is_none() && !Path::new(pack.disk_file_path()).is_absolute() {
199            return Err(anyhow!("Pack '{}' has never been saved to disk. Use Save As to choose where to save it.", pack_key));
200        }
201
202        if let Some(path) = path {
203            if !path.is_absolute() {
204                return Err(ApiError::InvalidParams(format!("The path to save the pack to must be absolute: {}", path.display())).into());
205            }
206
207            if let Some(parent) = path.parent() {
208                DirBuilder::new().recursive(true).create(parent)?;
209            }
210        }
211
212        if options.clean {
213            pack.clean_undecoded();
214        }
215
216        let extra_data = encode_extra_data(&self.game, pack.compression_format(), options.disable_uuid_regeneration);
217        pack.save(path, &self.game, &extra_data)
218            .map_err(|error| anyhow!("Error while trying to save the currently open PackFile: {}", error))?;
219        self.session.set_pack_name(pack_key, &pack.disk_file_name());
220        self.changed_packs.remove(pack_key);
221
222        Ok(ContainerInfo::from(&*pack))
223    }
224
225    /// Changes properties of a pack. Only the properties set in the request are changed.
226    ///
227    /// # Errors
228    ///
229    /// Fails, without changing anything, if the request enables encryption on a pack whose version doesn't support it.
230    pub fn update_pack(&mut self, request: &UpdatePack) -> Result<PackDetails> {
231        let pack = pack_mut(&mut self.packs, &request.pack)?;
232
233        let enables_encryption = request.index_encrypted == Some(true) || request.data_encrypted == Some(true);
234        if enables_encryption && !pack.pfh_version().supports_encryption() {
235            return Err(anyhow!("Encryption is not supported in {} Packs.", pack.pfh_version().value()));
236        }
237
238        if let Some(pack_type) = request.pack_type {
239            pack.set_pfh_file_type(pack_type);
240        }
241
242        if let Some(compression) = request.compression {
243            pack.set_compression_format(compression, &self.game);
244        }
245
246        let mut bitmask = pack.bitmask();
247        for (flag, state) in [
248            (PFHFlags::HAS_ENCRYPTED_INDEX, request.index_encrypted),
249            (PFHFlags::HAS_ENCRYPTED_DATA, request.data_encrypted),
250            (PFHFlags::HAS_INDEX_WITH_TIMESTAMPS, request.index_includes_timestamp),
251        ] {
252            if let Some(state) = state {
253                bitmask.set(flag, state);
254            }
255        }
256        pack.set_bitmask(bitmask);
257
258        if let Some(ref dependencies) = request.dependencies {
259            pack.set_dependencies(dependencies.iter().map(|dependency| (dependency.enabled, dependency.name.clone())).collect());
260        }
261
262        if let Some(ref mode) = request.operational_mode {
263            self.pack_modes.insert(request.pack.clone(), mode.clone());
264        }
265
266        self.pack_details(&request.pack)
267    }
268
269    /// Returns the short description of an open pack.
270    pub fn pack_summary(&self, pack_key: &str) -> Result<PackSummary> {
271        Ok(pack_summary(pack_key, pack(&self.packs, pack_key)?))
272    }
273
274    /// Returns the info of files of a pack: the ones at the requested paths, or all of them.
275    pub fn files_info(&self, request: &GetFilesInfo) -> Result<FilesInfo> {
276        let pack = pack(&self.packs, &request.pack)?;
277        let files = match request.paths {
278            Some(ref paths) => paths.iter().filter_map(|path| pack.files().get(path)).map(From::from).collect(),
279            None => pack.files().par_iter().map(|(_, file)| From::from(file)).collect(),
280        };
281
282        Ok(FilesInfo { files })
283    }
284
285    /// Returns the details of an open pack.
286    pub fn pack_details(&self, pack_key: &str) -> Result<PackDetails> {
287        let pack = pack(&self.packs, pack_key)?;
288        let bitmask = pack.bitmask();
289
290        Ok(PackDetails {
291            summary: pack_summary(pack_key, pack),
292            version: pack.pfh_version(),
293            compression: pack.compression_format(),
294            index_encrypted: bitmask.contains(PFHFlags::HAS_ENCRYPTED_INDEX),
295            data_encrypted: bitmask.contains(PFHFlags::HAS_ENCRYPTED_DATA),
296            index_includes_timestamp: bitmask.contains(PFHFlags::HAS_INDEX_WITH_TIMESTAMPS),
297            extended_header: bitmask.contains(PFHFlags::HAS_EXTENDED_HEADER),
298            timestamp: pack.internal_timestamp(),
299            dependencies: pack.dependencies().iter()
300                .map(|(enabled, name)| PackDependency { enabled: *enabled, name: name.clone() })
301                .collect(),
302            operational_mode: self.pack_operational_mode(pack_key),
303        })
304    }
305
306    /// Returns the path of a pack on disk.
307    pub fn pack_path(&self, pack_key: &str) -> Result<PathBuf> {
308        Ok(PathBuf::from(pack(&self.packs, pack_key)?.disk_file_path()))
309    }
310
311    /// Returns the file name of a pack.
312    pub fn pack_name(&self, pack_key: &str) -> Result<String> {
313        Ok(pack(&self.packs, pack_key)?.disk_file_name())
314    }
315
316    /// Changes the compression format of a pack.
317    ///
318    /// # Returns
319    ///
320    /// The format actually set, which may differ from the requested one if the game doesn't support it.
321    pub fn set_compression_format(&mut self, pack_key: &str, compression_format: CompressionFormat) -> Result<CompressionFormat> {
322        Ok(pack_mut(&mut self.packs, pack_key)?.set_compression_format(compression_format, &self.game))
323    }
324
325    /// Returns the settings of a pack.
326    pub fn pack_settings(&self, pack_key: &str) -> Result<PackSettings> {
327        Ok(pack(&self.packs, pack_key)?.settings().clone())
328    }
329
330    /// Appends a line to the list of files a pack ignores in the diagnostics.
331    pub fn add_pack_ignored_diagnostics_line(&mut self, pack_key: &str, line: String) -> Result<()> {
332        let pack = pack_mut(&mut self.packs, pack_key)?;
333        let settings_text = pack.settings_mut().settings_text_mut();
334        match settings_text.get_mut("diagnostics_files_to_ignore") {
335            Some(diagnostics_ignored) => diagnostics_ignored.push_str(&line),
336            None => { settings_text.insert("diagnostics_files_to_ignore".to_owned(), line); },
337        }
338
339        Ok(())
340    }
341
342    /// Returns the operational mode of a pack. Unknown packs are in normal mode.
343    pub fn pack_operational_mode(&self, pack_key: &str) -> OperationalMode {
344        self.pack_modes.get(pack_key).cloned().unwrap_or(OperationalMode::Normal)
345    }
346
347    /// Returns the settings of a pack.
348    pub fn pack_settings_values(&self, pack_key: &str) -> Result<PackSettingsValues> {
349        Ok(PackSettingsValues::from(pack(&self.packs, pack_key)?.settings()))
350    }
351
352    /// Changes settings of a pack. Only the keys set in the request are changed.
353    ///
354    /// # Returns
355    ///
356    /// All the settings of the pack after the change.
357    pub fn update_pack_settings(&mut self, request: &UpdatePackSettings) -> Result<PackSettingsValues> {
358        let settings = pack_mut(&mut self.packs, &request.pack)?.settings_mut();
359        settings.settings_text_mut().extend(request.settings.text.clone());
360        settings.settings_string_mut().extend(request.settings.string.clone());
361        settings.settings_bool_mut().extend(request.settings.bool.clone());
362        settings.settings_number_mut().extend(request.settings.number.clone());
363        self.pack_settings_values(&request.pack)
364    }
365
366    /// Returns the notes of a pack for a path, with the notes of the folders containing it, as API entries.
367    ///
368    /// If the path is empty, all the notes of the pack are returned.
369    pub fn note_entries(&self, pack_key: &str, path: &str) -> Result<Vec<NoteEntry>> {
370        if path.is_empty() {
371            let notes = pack(&self.packs, pack_key)?.notes().file_notes().values().flatten().map(note_entry).collect();
372            return Ok(notes);
373        }
374
375        Ok(self.notes_for_path(pack_key, path)?.iter().map(note_entry).collect())
376    }
377
378    /// Attaches a note to a file or folder of a pack.
379    ///
380    /// # Returns
381    ///
382    /// The added note, with its ID.
383    pub fn add_note_entry(&mut self, request: &AddNote) -> Result<NoteEntry> {
384        let mut note = Note::default();
385        note.set_path(request.path.clone());
386        note.set_message(request.message.clone());
387        note.set_url(request.url.clone());
388        note.set_id(request.id.unwrap_or_default());
389        Ok(note_entry(&self.add_note(&request.pack, note, request.id.is_some())?))
390    }
391
392    /// Returns the notes of a pack under a path.
393    pub fn notes_for_path(&self, pack_key: &str, path: &str) -> Result<Vec<Note>> {
394        Ok(pack(&self.packs, pack_key)?.notes().notes_by_path(path))
395    }
396
397    /// Adds a note to a pack.
398    ///
399    /// # Returns
400    ///
401    /// The added note, with its ID assigned.
402    pub fn add_note(&mut self, pack_key: &str, note: Note, replace: bool) -> Result<Note> {
403        Ok(pack_mut(&mut self.packs, pack_key)?.notes_mut().add_note(note, replace))
404    }
405
406    /// Deletes a note from a pack.
407    pub fn delete_note(&mut self, pack_key: &str, path: &str, id: u64) -> Result<()> {
408        pack_mut(&mut self.packs, pack_key)?.notes_mut().delete_note(path, id);
409        Ok(())
410    }
411
412    /// Marks packs as changed since their last autosave.
413    ///
414    /// # Arguments
415    ///
416    /// * `pack_key` - Key of the changed pack, or `None` if any open pack may have changed.
417    pub fn mark_packs_changed(&mut self, pack_key: Option<&str>) {
418        match pack_key {
419            Some(pack_key) if self.packs.contains_key(pack_key) => { self.changed_packs.insert(pack_key.to_owned()); },
420            Some(_) => {},
421            None => self.changed_packs.extend(self.packs.keys().cloned()),
422        }
423    }
424
425    /// Saves a backup copy of each pack changed since its last autosave in the autosave folder, keeping the newest ones.
426    ///
427    /// The packs are copied here, but saved from another thread, so the session can keep running requests meanwhile.
428    /// Vanilla packs, packs with autosaves disabled and packs that are neither mods nor movies are skipped.
429    ///
430    /// # Arguments
431    ///
432    /// * `settings` - Settings, for the game's install folder, the amount of autosaves to keep, and how to encode tables.
433    pub fn autosave(&mut self, settings: &Settings) {
434        if let Ok(autosave_path) = backup_autosave_path() {
435            self.autosave_to(settings, autosave_path);
436        }
437    }
438
439    /// Same as [`Self::autosave`], but saving the backups in `autosave_path`.
440    ///
441    /// If the previous autosave is still running, this does nothing, and the changed packs are left for the next one.
442    fn autosave_to(&mut self, settings: &Settings, autosave_path: PathBuf) {
443        if self.autosave_thread.as_ref().is_some_and(|thread| !thread.is_finished()) {
444            return;
445        }
446
447        let ca_paths = self.game.ca_packs_paths(&settings.path_buf(self.game.key()))
448            .unwrap_or_default()
449            .iter()
450            .map(|path| path.to_string_lossy().replace('\\', "/"))
451            .collect::<Vec<_>>();
452
453        let packs = std::mem::take(&mut self.changed_packs).into_iter()
454            .filter_map(|pack_key| self.packs.get(&pack_key))
455            .filter(|pack| {
456                let pack_type = pack.pfh_file_type();
457                !pack.settings().setting_bool("disable_autosaves").unwrap_or(&true) &&
458                    (pack_type == PFHFileType::Mod || pack_type == PFHFileType::Movie) &&
459                    !ca_paths.contains(&pack.disk_file_path().replace('\\', "/"))
460            })
461            .cloned()
462            .collect::<Vec<_>>();
463
464        if packs.is_empty() {
465            return;
466        }
467
468        let game = self.game.clone();
469        let disable_uuid_regeneration = settings.bool(DISABLE_UUID_REGENERATION_ON_DB_TABLES);
470        let autosave_amount = settings.i32(AUTOSAVE_AMOUNT).max(0) as usize;
471        self.autosave_thread = Some(std::thread::spawn(move || {
472            for mut pack in packs {
473                let folder = autosave_path.join(pack.disk_file_name());
474                if let Err(error) = save_autosave(&mut pack, &folder, &game, disable_uuid_regeneration, autosave_amount) {
475                    warn!("Failed to autosave the pack {}: {error}", pack.disk_file_name());
476                }
477            }
478
479            let size = files_from_subdir(&autosave_path, true).unwrap_or_default().iter()
480                .filter_map(|path| path.metadata().ok())
481                .map(|metadata| metadata.len())
482                .sum::<u64>();
483
484            if size > AUTOSAVE_FOLDER_SIZE_WARNING {
485                warn!("The autosave folder is using {} GB. Consider lowering the amount of autosaves to keep, or deleting the ones you don't need.", size / 1024 / 1024 / 1024);
486            }
487        }));
488    }
489
490    /// Waits until the running autosave, if any, finishes.
491    ///
492    /// Autosaves read lazy-loaded files from the packs on disk, so this must be called before replacing any of them.
493    pub fn wait_for_autosave(&mut self) {
494        if let Some(thread) = self.autosave_thread.take() {
495            let _ = thread.join();
496        }
497    }
498
499    /// Returns the tables of a pack with rows that can't be decoded with the schema, sorted by path.
500    ///
501    /// This is slow, and only useful when a new patch lands and you want to know what tables need decoding.
502    pub fn missing_definitions(&mut self, pack_key: &str) -> Result<MissingDefinitions> {
503        let pack = pack_mut(&mut self.packs, pack_key)?;
504        let schema = loaded_schema(&self.schema)?;
505
506        let mut extra_data = DecodeableExtraData::default();
507        extra_data.set_schema(Some(schema));
508        let extra_data = Some(extra_data);
509
510        let mut files = pack.files_by_type_mut(&[FileType::DB]);
511        files.sort_by_key(|file| file.path_in_container_raw().to_lowercase());
512
513        let mut paths = vec![];
514        for file in files {
515            if file.decode(&extra_data, false, false).is_err() && file.load().is_ok() {
516                if let Ok(raw_data) = file.cached() {
517                    let mut reader = Cursor::new(raw_data);
518                    if let Ok((_, _, _, entry_count)) = DB::read_header(&mut reader) {
519                        if entry_count > 0 {
520                            paths.push(file.path_in_container_raw().to_owned());
521                        }
522                    }
523                }
524            }
525        }
526
527        Ok(MissingDefinitions { paths })
528    }
529
530    /// Exports the files of a pack to the game's data folder, so they can be tested without saving the pack.
531    ///
532    /// # Arguments
533    ///
534    /// * `pack_key` - Key of the pack to export.
535    /// * `settings` - Settings, to find the game's install folder.
536    /// * `disable_uuid_regeneration` - If tables keep their GUID when encoded.
537    /// * `tsv_keys_first` - If exported TSV files use the old column order, with keys first.
538    pub fn live_export(&mut self, pack_key: &str, settings: &Settings, disable_uuid_regeneration: bool, tsv_keys_first: bool) -> Result<()> {
539        let pack = pack_mut(&mut self.packs, pack_key)?;
540        let game_path = settings.path_buf(self.game.key());
541        pack.live_export(&self.game, &game_path, disable_uuid_regeneration, tsv_keys_first)?;
542        Ok(())
543    }
544
545    /// Adds a pack to the open ones, in normal mode.
546    ///
547    /// # Returns
548    ///
549    /// The key of the pack.
550    fn insert_pack(&mut self, pack: Pack) -> String {
551        let key = Uuid::new_v4().to_string();
552
553        self.session.set_pack_name(&key, &pack.disk_file_name());
554        self.pack_modes.insert(key.clone(), OperationalMode::Normal);
555        self.packs.insert(key.clone(), pack);
556        key
557    }
558}
559
560/// Returns a note as an API entry.
561fn note_entry(note: &Note) -> NoteEntry {
562    NoteEntry {
563        id: *note.id(),
564        path: note.path().to_owned(),
565        message: note.message().to_owned(),
566        url: note.url().clone(),
567    }
568}
569
570/// Saves a pack as a new autosave in a folder, removing the oldest autosaves over the amount to keep.
571///
572/// # Arguments
573///
574/// * `pack` - Copy of the pack to save.
575/// * `folder` - Folder with the autosaves of the pack.
576/// * `game` - Game the pack is for.
577/// * `disable_uuid_regeneration` - If tables keep their GUID when encoded.
578/// * `autosave_amount` - Amount of autosaves to keep.
579///
580/// # Errors
581///
582/// If the folder can't be created, or the pack can't be saved.
583fn save_autosave(pack: &mut Pack, folder: &Path, game: &GameInfo, disable_uuid_regeneration: bool, autosave_amount: usize) -> Result<()> {
584    DirBuilder::new().recursive(true).create(folder)?;
585
586    let date = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?.as_secs();
587    let extra_data = encode_extra_data(game, pack.compression_format(), disable_uuid_regeneration);
588    pack.save(Some(&folder.join(format!("{date}.pack"))), game, &extra_data)?;
589
590    for file in files_in_folder_from_newest_to_oldest(folder)?.iter().skip(autosave_amount) {
591        let _ = std::fs::remove_file(file);
592    }
593
594    Ok(())
595}
596
597/// Derives a pack name for new (unsaved) packs no open pack has, like "new_pack.pack", "new_pack_2.pack", etc.
598fn derive_new_pack_name(packs: &BTreeMap<String, Pack>) -> String {
599    let taken = |name: &str| packs.values().any(|pack| pack.disk_file_name() == name);
600    let base = format!("{}{}", DEFAULT_PACK_STEM, DEFAULT_PACK_EXT);
601    if !taken(&base) {
602        return base;
603    }
604
605    (2..).map(|suffix| format!("{}_{}{}", DEFAULT_PACK_STEM, suffix, DEFAULT_PACK_EXT))
606        .find(|candidate| !taken(candidate))
607        .expect("an unbounded range always finds a free name")
608}
609
610//-------------------------------------------------------------------------------//
611//                                   Tests
612//-------------------------------------------------------------------------------//
613
614#[cfg(test)]
615#[path = "packs_test.rs"]
616mod packs_test;