Files
aventuriers-des-mots/scripts/profiles/save_transfer.gd
T
2026-10-11 22:49:01 +02:00

132 lines
5.1 KiB
GDScript

class_name SaveTransfer
extends RefCounted
## Export et restauration d'une sauvegarde de profil vers un dossier choisi par
## l'adulte (cahier des charges §10). Le fichier exporte est exactement la
## sauvegarde versionnee du profil : le format n'est pas modifie. La
## restauration est non destructive : elle cree toujours un NOUVEAU profil
## (identifiant neuf) et n'ecrase jamais un profil existant. Pas pur (touche le
## systeme de fichiers) mais sans arbre de scenes : testable en headless.
const EXPORT_FOLDER_NAME := "AventuriersDesMots"
## Dossier d'export par defaut : Documents de l'utilisateur, ou user://exports
## si la plateforme n'en expose pas.
static func default_export_dir() -> String:
var documents := OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS)
if documents.is_empty():
return "user://exports"
return documents.path_join(EXPORT_FOLDER_NAME)
## Exporte le profil `profile_id` dans `dest_dir`. Renvoie le chemin du fichier
## cree, ou "" en cas d'echec (profil illisible, dossier non inscriptible).
static func export_profile(profile_id: String, schema: Dictionary, dest_dir: String) -> String:
var save: Variant = SaveIO.read(profile_id, schema)
if save == null:
return ""
if (
DirAccess.make_dir_recursive_absolute(dest_dir) != OK
and not DirAccess.dir_exists_absolute(dest_dir)
):
return ""
var base := _safe_file_stem(save["display_name"], save["school_level"])
var path := dest_dir.path_join(base + ".json")
var counter := 2
while FileAccess.file_exists(path):
path = dest_dir.path_join("%s_%d.json" % [base, counter])
counter += 1
var tmp_path := path + ".tmp"
var file := FileAccess.open(tmp_path, FileAccess.WRITE)
if file == null:
return ""
file.store_string(JSON.stringify(save, " "))
file.close()
var dir := DirAccess.open(dest_dir)
if dir == null or dir.rename(tmp_path, path) != OK:
return ""
return path
## Fichiers de sauvegarde exportes trouves dans `dir`, tries par nom : chemin,
## nom d'affichage et niveau, ou valid=false pour un fichier illisible.
static func list_exports(dir_path: String, schema: Dictionary) -> Array[Dictionary]:
var found: Array[Dictionary] = []
var dir := DirAccess.open(dir_path)
if dir == null:
return found
var names: Array[String] = []
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if not dir.current_is_dir() and entry.ends_with(".json"):
names.append(entry)
entry = dir.get_next()
names.sort()
for file_name in names:
var path := dir_path.path_join(file_name)
var save: Variant = read_export(path, schema)
if save == null:
found.append({"path": path, "valid": false, "display_name": "", "school_level": ""})
else:
(
found
. append(
{
"path": path,
"valid": true,
"display_name": save["display_name"],
"school_level": save["school_level"],
}
)
)
return found
## Lit, migre et valide un fichier exporte. Null s'il n'est pas une sauvegarde
## valable (JSON invalide, schema non respecte, version inconnue).
static func read_export(path: String, schema: Dictionary) -> Variant:
if not FileAccess.file_exists(path):
return null
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return null
# JSON.new().parse() renvoie un code d'erreur au lieu de journaliser une
# erreur moteur : un fichier choisi par l'adulte peut etre n'importe quoi.
var json := JSON.new()
if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
return null
var parsed: Dictionary = json.data
var migrated: Dictionary = SaveMigration.migrate(parsed)
if migrated.is_empty():
return null
if not SchemaValidator.validate(migrated, schema).is_empty():
return null
return migrated
## Restaure le fichier `path` comme nouveau profil `new_profile_id`. Renvoie
## true si la sauvegarde a ete ecrite.
static func import_as_new_profile(path: String, schema: Dictionary, new_profile_id: String) -> bool:
var save: Variant = read_export(path, schema)
if save == null:
return false
var restored: Dictionary = save
restored["profile_id"] = new_profile_id
return SaveIO.write(new_profile_id, restored)
static func _safe_file_stem(display_name: String, school_level: String) -> String:
# On retire seulement ce qu'un systeme de fichiers refuse ; les accents
# restent, pour que « Léa » reste « Léa ».
var forbidden := '/\\:*?"<>|.'
var cleaned := ""
for character in display_name.strip_edges():
if character == " ":
cleaned += "_"
elif not forbidden.contains(character) and character.unicode_at(0) >= 32:
cleaned += character
if cleaned.is_empty():
cleaned = "profil"
return "%s_%s" % [cleaned, school_level]