Files
Faset_Engine/src/authoring/service.cpp
T

664 lines
33 KiB
C++

#include <algorithm>
#include <cmath>
#include <faset/authoring/service.hpp>
#include <faset/authoring/transforms.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <set>
namespace faset::authoring {
namespace {
Json& entity(Json& scene, const std::string& id) {
for (auto& item : scene["entities"])
if (item.at("id") == id)
return item;
throw Error("entity.missing", "Entity does not exist", {{"entity", id}});
}
Json& component(Json& item, const std::string& id) {
for (auto& value : item["components"])
if (value.at("id") == id)
return value;
throw Error("component.missing", "Component does not exist", {{"component", id}});
}
std::string parent_id(const Json& item) {
return item.contains("parent") && !item["parent"].is_null() ? item["parent"].get<std::string>()
: "";
}
void check_revision(std::uint64_t current, std::uint64_t expected) {
if (current != expected)
throw Error("revision.conflict", "Document changed since it was read",
{{"expected", expected}, {"current", current}});
}
bool finite_json(const Json& value) {
if (value.is_number_float())
return std::isfinite(value.get<double>());
if (value.is_structured())
for (const auto& child : value)
if (!finite_json(child))
return false;
return true;
}
bool valid_id(const Json& value) {
if (!value.is_string())
return false;
const auto& text = value.get_ref<const std::string&>();
return !text.empty() && text.size() <= 128 &&
text.find_first_not_of(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.:") ==
std::string::npos;
}
} // namespace
Json default_simulation_settings() {
return {{"fixed_delta", 1.0 / 60.0},
{"max_catch_up_ticks", 4},
{"physics_substeps", 4},
{"gravity", {0, -9.81, 0}}};
}
Json make_scene(std::string name, int dimension) {
require(dimension == 2 || dimension == 3, "scene.dimension", "Scene dimension must be 2 or 3");
return {{"format", "faset.scene"}, {"version", 1}, {"id", new_id()},
{"name", std::move(name)}, {"dimension", dimension}, {"entities", Json::array()},
{"instances", Json::array()}};
}
Json make_entity(const SchemaRegistry& schemas, std::string name, const std::string& parent) {
Json transform = {{"id", new_id()},
{"type", "faset.transform"},
{"version", 1},
{"fields", schemas.default_fields("faset.transform")}};
return {{"id", new_id()},
{"name", std::move(name)},
{"parent", parent.empty() ? Json(nullptr) : Json(parent)},
{"components", Json::array({transform})}};
}
void validate_scene(const Json& scene, const SchemaRegistry& schemas) {
require(scene.is_object() && scene.value("format", std::string()) == "faset.scene",
"scene.format", "Expected a Faset scene");
require(scene.value("version", 0) == 1, "scene.version", "Unsupported scene format version");
require(scene.contains("id") && valid_id(scene["id"]), "scene.id",
"Scene requires a safe stable ID");
require(scene.contains("name") && scene["name"].is_string(), "scene.name",
"Scene name must be text");
require(scene.value("dimension", 0) == 2 || scene.value("dimension", 0) == 3, "scene.dimension",
"Scene dimension must be 2 or 3");
require(scene.contains("entities") && scene["entities"].is_array(), "scene.entities",
"Scene entities must be an array");
require(finite_json(scene), "validation.finite", "Scene contains a non-finite number");
if (scene.contains("simulation")) {
const auto& settings = scene.at("simulation");
require(settings.is_object(), "simulation.object", "Simulation settings must be an object");
if (settings.contains("fixed_delta"))
require(settings["fixed_delta"].is_number() &&
settings["fixed_delta"].get<double>() > 0 &&
settings["fixed_delta"].get<double>() <= 1,
"simulation.fixed_delta",
"Fixed delta must be greater than zero and at most one second");
for (const auto& [name, maximum] :
std::map<std::string, int>{{"max_catch_up_ticks", 1024}, {"physics_substeps", 128}})
if (settings.contains(name)) {
const auto& value = settings[name];
require(value.is_number_integer() && value.get<double>() >= 1 &&
value.get<double>() <= maximum,
"simulation.integer", "Invalid simulation setting: " + name);
}
if (settings.contains("gravity"))
validate_field(settings["gravity"], {{"type", "vec3"}});
}
std::set<std::string> ids;
std::map<std::string, std::string> parents;
auto insert_id = [&](const Json& value) {
require(valid_id(value), "id.invalid",
"ID must contain at most 128 ASCII identifier characters");
require(ids.insert(value.get<std::string>()).second, "id.duplicate",
"Duplicate document ID");
};
for (const auto& item : scene["entities"]) {
require(item.is_object() && item.contains("id") && item.contains("name") &&
item["name"].is_string(),
"entity.invalid", "Invalid entity record");
insert_id(item["id"]);
parents[item["id"].get<std::string>()] = parent_id(item);
require(item.contains("components") && item["components"].is_array(), "entity.components",
"Entity components must be an array");
std::set<std::string> types;
for (const auto& value : item["components"]) {
require(value.contains("id"), "component.id", "Component requires stable ID");
insert_id(value["id"]);
schemas.validate_component(value);
require(types.insert(value.at("type").get<std::string>()).second,
"component.duplicate_type",
"One component of each type is supported per entity");
}
}
for (const auto& [id, parent] : parents) {
std::set<std::string> visited{id};
auto current = parent;
while (!current.empty()) {
require(parents.contains(current), "entity.parent_missing", "Parent entity is missing");
require(visited.insert(current).second, "entity.cycle", "Hierarchy contains a cycle");
current = parents.at(current);
}
}
if (scene.contains("instances")) {
require(scene["instances"].is_array(), "template.instances",
"Template instances must be an array");
for (const auto& instance : scene["instances"]) {
require(instance.contains("id") && instance.contains("source") &&
instance["source"].is_string(),
"template.instance", "Invalid template instance");
insert_id(instance["id"]);
require(!instance["source"].get_ref<const std::string&>().empty(), "template.source",
"Template source cannot be empty");
auto address = [&](const Json& value, bool field) {
require(value.is_object() && value.contains("object") && valid_id(value["object"]),
"template.address", "Template address needs a stable object ID");
if (value.contains("path")) {
require(value["path"].is_array(), "template.address",
"Instance path must be an array");
for (const auto& part : value["path"])
require(valid_id(part), "template.address",
"Instance path needs stable IDs");
}
if (field)
require(value.contains("component") && valid_id(value["component"]) &&
value.contains("field") && value["field"].is_string() &&
!value["field"].get_ref<const std::string&>().empty(),
"template.address",
"Field address needs stable component and field IDs");
};
for (const auto* collection : {"overrides", "suppressed", "reparents"})
if (instance.contains(collection))
require(instance[collection].is_array(), "template.records",
"Template records must be an array");
for (const auto& record : instance.value("overrides", Json::array())) {
require(record.is_object() && record.contains("address") &&
record.contains("value"),
"template.override", "Override needs an address and value");
address(record["address"], true);
}
for (const auto& record : instance.value("suppressed", Json::array()))
address(record, false);
for (const auto& record : instance.value("reparents", Json::array())) {
require(record.is_object() && record.contains("object") &&
record.contains("parent"),
"template.reparent", "Reparent needs object and parent addresses");
address(record["object"], false);
if (!record["parent"].is_null())
address(record["parent"], false);
if (record.contains("keep_world"))
require(record["keep_world"].is_boolean(), "template.reparent",
"keep_world must be a boolean");
}
if (instance.contains("additions")) {
require(instance["additions"].is_array(), "template.additions",
"Local additions must be an array");
auto additions = make_scene("Local additions");
additions["entities"] = instance["additions"];
// Local cycles are rejected here; parents in the source are checked during
// resolution.
std::set<std::string> local_ids;
for (const auto& item : additions["entities"])
if (item.contains("id") && item["id"].is_string())
local_ids.insert(item["id"].get<std::string>());
for (auto& item : additions["entities"]) {
if (item.contains("parent") && !item["parent"].is_null())
require(valid_id(item["parent"]), "template.addition_parent",
"Local parent must be a stable object ID or null");
if (!item.contains("parent") || item["parent"].is_null() ||
!local_ids.contains(item["parent"].get<std::string>()))
item["parent"] = nullptr;
}
validate_scene(additions, schemas);
}
}
}
}
AuthoringService::AuthoringService(std::filesystem::path root, SchemaRegistry schemas)
: root_(std::filesystem::absolute(std::move(root)).lexically_normal()),
schemas_(std::move(schemas)) {
std::filesystem::create_directories(root_);
}
AuthoringService::State& AuthoringService::state(const std::string& id) {
auto found = documents_.find(id);
require(found != documents_.end(), "document.missing", "Document is not open");
return found->second;
}
const AuthoringService::State& AuthoringService::state(const std::string& id) const {
auto found = documents_.find(id);
require(found != documents_.end(), "document.missing", "Document is not open");
return found->second;
}
Json AuthoringService::summary(const State& value, bool include_data) const {
Json result = {{"id", value.data.at("id")},
{"name", value.data.at("name")},
{"revision", value.revision},
{"dirty", sha256(value.data.dump()) != value.saved_hash},
{"path", generic_path_to_utf8(value.path)},
{"can_undo", !value.undo.empty()},
{"can_redo", !value.redo.empty()}};
if (include_data)
result["scene"] = value.data;
return result;
}
void AuthoringService::journal(const State& value) const {
atomic_write_json(project_path(root_, std::filesystem::path(".faset/recovery") /
(value.data.at("id").get<std::string>() + ".json")),
{{"format", "faset.recovery"},
{"version", 1},
{"path", generic_path_to_utf8(value.path)},
{"revision", value.revision},
{"saved_hash", value.saved_hash},
{"disk_hash", value.disk_hash},
{"scene", value.data}});
}
Json AuthoringService::create(std::string name, int dimension) {
std::lock_guard lock(mutex_);
State value;
value.data = make_scene(std::move(name), dimension);
journal(value);
const auto id = value.data["id"].get<std::string>();
documents_.emplace(id, std::move(value));
return summary(state(id));
}
Json AuthoringService::open(const std::filesystem::path& relative, bool recover) {
std::lock_guard lock(mutex_);
auto path = project_path(root_, relative);
Json data = read_json(path);
validate_scene(data, schemas_);
const auto id = data.at("id").get<std::string>();
if (documents_.contains(id)) {
require(state(id).path == relative.lexically_normal(), "document.id_collision",
"Another open file has the same document ID");
return recover ? this->recover(id, state(id).revision) : summary(state(id));
}
State value;
value.data = data;
value.path = relative.lexically_normal();
value.saved_hash = sha256(data.dump());
value.disk_hash = sha256_file(path);
const auto recovery =
project_path(root_, std::filesystem::path(".faset/recovery") / (id + ".json"));
if (recover && std::filesystem::exists(recovery)) {
const auto recovered = read_json(recovery);
require(recovered.value("disk_hash", std::string()) == value.disk_hash,
"recovery.disk_conflict", "Scene file changed since recovery was written");
validate_scene(recovered.at("scene"), schemas_);
require(recovered.at("scene").at("id") == id, "recovery.id",
"Recovery ID does not match its document");
value.data = recovered.at("scene");
value.revision = recovered.value("revision", 0u);
}
documents_.emplace(id, std::move(value));
return summary(state(id));
}
Json AuthoringService::query(const std::string& id) const {
std::lock_guard lock(mutex_);
return summary(state(id));
}
Json AuthoringService::documents() const {
std::lock_guard lock(mutex_);
Json result = Json::array();
for (const auto& [id, value] : documents_)
result.push_back(summary(value, false));
return result;
}
void AuthoringService::register_schemas(const Json& manifest) {
std::lock_guard lock(mutex_);
schemas_.register_schemas(manifest);
}
void AuthoringService::replace_external_schemas(const Json& manifest) {
std::lock_guard lock(mutex_);
schemas_ = gameplay_schemas(manifest);
}
void AuthoringService::apply(Json& scene, const Json& command) {
require(command.is_object() && command.contains("op") && command["op"].is_string(),
"command.invalid", "Command requires an operation name");
const auto op = command.at("op").get<std::string>();
if (op == "entity.create") {
Json value = command.contains("entity") && command["entity"].is_object()
? command["entity"]
: make_entity(schemas_, command.value("name", std::string("Object")),
command.value("parent", std::string()));
if (!value.contains("id"))
value["id"] = new_id();
scene["entities"].push_back(std::move(value));
} else if (op == "entity.rename") {
entity(scene, command.at("entity").get<std::string>())["name"] = command.at("name");
} else if (op == "scene.simulation") {
const auto& value = command.at("value");
require(value.is_object(), "simulation.object", "Simulation settings must be an object");
const auto defaults = default_simulation_settings();
for (const auto& [key, setting] : value.items())
require(defaults.contains(key), "simulation.setting",
"Unknown simulation setting: " + key);
if (!scene.contains("simulation"))
scene["simulation"] = defaults;
scene["simulation"].update(value);
} else if (op == "entity.delete") {
const auto id = command.at("entity").get<std::string>();
entity(scene, id);
std::set<std::string> removed{id};
bool changed = true;
while (changed) {
changed = false;
for (const auto& item : scene["entities"])
if (removed.contains(parent_id(item)))
changed = removed.insert(item.at("id").get<std::string>()).second || changed;
}
auto& values = scene["entities"];
values.erase(std::remove_if(values.begin(), values.end(),
[&](const Json& value) {
return removed.contains(value.at("id").get<std::string>());
}),
values.end());
} else if (op == "entity.reparent") {
reparent_entity(scene, command.at("entity").get<std::string>(),
command.value("parent", Json(nullptr)), command.value("keep_world", false));
} else if (op == "component.add") {
auto& value = entity(scene, command.at("entity").get<std::string>());
const auto type = command.at("type").get<std::string>();
const auto metadata = schemas_.schema(type);
Json fields = schemas_.default_fields(type);
if (command.contains("fields"))
fields.update(command["fields"]);
value["components"].push_back({{"id", command.value("id", new_id())},
{"type", type},
{"version", metadata.value("version", 1)},
{"fields", fields}});
} else if (op == "component.remove") {
auto& values = entity(scene, command.at("entity").get<std::string>())["components"];
const auto id = command.at("component").get<std::string>();
auto found = std::find_if(values.begin(), values.end(),
[&](const Json& value) { return value.at("id") == id; });
require(found != values.end(), "component.missing", "Component does not exist");
values.erase(found);
} else if (op == "component.migrate") {
Json* object = nullptr;
if (command.contains("instance")) {
const auto instance_id = command.at("instance").get<std::string>();
for (auto& instance : scene.at("instances"))
if (instance.at("id") == instance_id && instance.contains("additions"))
for (auto& addition : instance.at("additions"))
if (addition.at("id") == command.at("entity"))
object = &addition;
require(object, "template.addition_missing", "Instance-local entity is unavailable");
} else
object = &entity(scene, command.at("entity").get<std::string>());
auto& value = component(*object, command.at("component").get<std::string>());
const auto type = value.at("type").get<std::string>();
require(schemas_.contains(type), "schema.missing", "Component schema unavailable: " + type);
require(value.value("version", 1) <= schemas_.schema(type).value("version", 1),
"migration.future", "Cannot migrate a component from a newer schema");
value = schemas_.migrate_component(value);
} else if (op == "component.set") {
auto& value = component(entity(scene, command.at("entity").get<std::string>()),
command.at("component").get<std::string>());
const auto field = command.at("field").get<std::string>();
require(!field.empty(), "field.invalid", "FieldId cannot be empty");
value["fields"][field] = command.at("value");
} else if (op == "entity.duplicate") {
const auto id = command.at("entity").get<std::string>();
entity(scene, id);
std::set<std::string> subtree{id};
bool changed = true;
while (changed) {
changed = false;
for (const auto& item : scene["entities"])
if (subtree.contains(parent_id(item)))
changed = subtree.insert(item.at("id").get<std::string>()).second || changed;
}
std::map<std::string, std::string> mapping;
for (const auto& item : scene["entities"])
if (subtree.contains(item.at("id").get<std::string>())) {
mapping[item.at("id")] = new_id();
for (const auto& component : item["components"])
mapping[component.at("id")] = new_id();
}
Json duplicates = Json::array();
for (const auto& item : scene["entities"])
if (subtree.contains(item.at("id").get<std::string>())) {
auto copy = item;
copy["id"] = mapping.at(item.at("id").get<std::string>());
const auto parent = parent_id(item);
if (mapping.contains(parent))
copy["parent"] = mapping.at(parent);
if (item.at("id") == id)
copy["name"] = item.at("name").get<std::string>() + " Copy";
for (auto& component : copy["components"]) {
component["id"] = mapping.at(component.at("id").get<std::string>());
const auto type = component.at("type").get<std::string>();
if (!schemas_.contains(type))
continue;
const auto metadata = schemas_.schema(type);
if (component.value("version", 1) != metadata.value("version", 1))
continue;
for (auto& [field, value] : component["fields"].items())
if (metadata["fields"].contains(field) &&
metadata["fields"][field].value("type", std::string()) ==
"entity_ref" &&
value.is_string() && mapping.contains(value.get<std::string>()))
value = mapping.at(value.get<std::string>());
}
duplicates.push_back(std::move(copy));
}
for (auto& value : duplicates)
scene["entities"].push_back(std::move(value));
} else if (op == "scene.rename")
scene["name"] = command.at("name");
else if (op == "template.instance") {
Json value = command.at("instance");
if (!value.contains("id"))
value["id"] = new_id();
if (!scene.contains("instances"))
scene["instances"] = Json::array();
scene["instances"].push_back(std::move(value));
} else if (op == "template.override" || op == "template.revert" || op == "template.suppress" ||
op == "template.add" || op == "template.reparent" || op == "template.remove" ||
op == "template.restore" || op == "template.source_set" ||
op == "template.addition_set") {
auto& instances = scene["instances"];
const auto id = command.at("instance").get<std::string>();
auto found = std::find_if(instances.begin(), instances.end(),
[&](const Json& value) { return value.at("id") == id; });
require(found != instances.end(), "template.missing", "Instance not found");
if (op == "template.addition_set") {
const auto& replacement = command.at("value");
const auto addition_id = replacement.at("id");
auto& additions = (*found)["additions"];
require(additions.is_array(), "template.addition_missing",
"Instance has no local additions");
auto addition =
std::find_if(additions.begin(), additions.end(),
[&](const Json& value) { return value.at("id") == addition_id; });
require(addition != additions.end(), "template.addition_missing",
"Local addition not found");
*addition = replacement;
return;
}
if (op == "template.remove") {
instances.erase(found);
return;
}
if (op == "template.source_set") {
const auto source = command.at("source").get<std::string>();
project_path(root_, path_from_utf8(source));
(*found)["source"] = source;
return;
}
if (op == "template.restore") {
auto& records = (*found)["suppressed"];
require(records.is_array(), "template.suppression_missing",
"Instance has no suppressed objects");
const auto address = command.at("address");
const auto before = records.size();
records.erase(std::remove(records.begin(), records.end(), address), records.end());
require(records.size() != before, "template.suppression_missing",
"Suppressed object address not found");
return;
}
const std::string key = op == "template.suppress" ? "suppressed"
: op == "template.add" ? "additions"
: op == "template.reparent" ? "reparents"
: "overrides";
if (!found->contains(key))
(*found)[key] = Json::array();
auto& records = (*found)[key];
if (key == "overrides") {
const auto address = command.at("address");
auto old = std::find_if(records.begin(), records.end(), [&](const Json& value) {
return value.at("address") == address;
});
if (old != records.end())
records.erase(old);
if (op != "template.revert")
records.push_back({{"address", address}, {"value", command.at("value")}});
} else
records.push_back(command.at("value"));
} else
throw Error("command.unknown", "Unknown authoring command: " + op);
}
Json AuthoringService::transact(const std::string& id, std::uint64_t revision,
const Json& operations, const std::string& key) {
std::lock_guard lock(mutex_);
auto& current = state(id);
require(operations.is_array() && !operations.empty(), "transaction.empty",
"Transaction requires an array of operations");
const auto fingerprint =
sha256(Json{{"revision", revision}, {"operations", operations}}.dump());
if (!key.empty() && current.requests.contains(key)) {
const auto& request = current.requests.at(key);
require(request.first == fingerprint, "idempotency.conflict",
"Idempotency key was used with another payload");
return request.second;
}
check_revision(current.revision, revision);
State candidate = current;
for (const auto& operation : operations)
apply(candidate.data, operation);
validate_scene(candidate.data, schemas_);
candidate.undo.push_back(current.data);
if (candidate.undo.size() > 100)
candidate.undo.erase(candidate.undo.begin());
candidate.redo.clear();
++candidate.revision;
journal(candidate);
auto result = summary(candidate);
if (!key.empty()) {
if (candidate.requests.size() >= 256)
candidate.requests.erase(candidate.requests.begin());
candidate.requests[key] = {fingerprint, result};
}
current = std::move(candidate);
return result;
}
Json AuthoringService::history(const std::string& id, std::uint64_t revision, bool forward) {
std::lock_guard lock(mutex_);
auto& current = state(id);
check_revision(current.revision, revision);
State candidate = current;
auto& source = forward ? candidate.redo : candidate.undo;
auto& target = forward ? candidate.undo : candidate.redo;
require(!source.empty(), "history.empty", forward ? "Nothing to redo" : "Nothing to undo");
target.push_back(candidate.data);
candidate.data = source.back();
source.pop_back();
++candidate.revision;
journal(candidate);
current = std::move(candidate);
return summary(current);
}
Json AuthoringService::undo(const std::string& id, std::uint64_t revision) {
return history(id, revision, false);
}
Json AuthoringService::redo(const std::string& id, std::uint64_t revision) {
return history(id, revision, true);
}
Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative,
std::optional<std::uint64_t> expected_revision) {
std::lock_guard lock(mutex_);
auto& current = state(id);
if (expected_revision)
check_revision(current.revision, *expected_revision);
const auto selected = relative.empty() ? current.path : relative.lexically_normal();
require(!selected.empty(), "save.path", "Choose a scene path before saving");
const auto path = project_path(root_, selected);
if (std::filesystem::exists(path)) {
require(selected == current.path && !current.disk_hash.empty(), "save.exists",
"Save As will not overwrite another file");
require(sha256_file(path) == current.disk_hash, "save.disk_conflict",
"File changed outside the Editor; reload or save to another path");
}
atomic_write_json(path, current.data);
current.path = selected;
current.saved_hash = sha256(current.data.dump());
current.disk_hash = sha256_file(path);
journal(current);
return summary(current);
}
Json AuthoringService::recover(const std::string& id,
std::optional<std::uint64_t> expected_revision) {
std::lock_guard lock(mutex_);
require(valid_id(Json(id)), "id.invalid", "Invalid recovery document ID");
const auto record =
read_json(project_path(root_, std::filesystem::path(".faset/recovery") / (id + ".json")));
require(record.value("format", std::string()) == "faset.recovery" &&
record.value("version", 0) == 1,
"recovery.format", "Unsupported recovery record");
State candidate;
candidate.data = record.at("scene");
validate_scene(candidate.data, schemas_);
require(candidate.data.at("id") == id, "recovery.id",
"Recovery ID does not match its document");
candidate.path = path_from_utf8(record.at("path").get<std::string>());
candidate.saved_hash = record.value("saved_hash", std::string());
candidate.disk_hash = record.value("disk_hash", std::string());
candidate.revision = record.value("revision", std::uint64_t(0));
if (!candidate.path.empty()) {
const auto disk = project_path(root_, candidate.path);
require(std::filesystem::exists(disk) && sha256_file(disk) == candidate.disk_hash,
"recovery.disk_conflict",
"Scene file changed or disappeared since recovery was written");
}
validate_scene(candidate.data, schemas_);
if (documents_.contains(id)) {
const auto& current = state(id);
require(expected_revision.has_value(), "recovery.revision_required",
"Recovering an open document requires its current revision");
check_revision(current.revision, *expected_revision);
require(current.path == candidate.path, "document.id_collision",
"Recovery path differs from the open document");
if (candidate.data == current.data)
return summary(current);
candidate.undo = current.undo;
candidate.undo.push_back(current.data);
candidate.revision = std::max(current.revision, candidate.revision) + 1;
} else if (!candidate.path.empty())
candidate.undo.push_back(read_json(project_path(root_, candidate.path)));
journal(candidate);
documents_[id] = std::move(candidate);
return summary(state(id));
}
Json AuthoringService::recovery_documents() const {
std::lock_guard lock(mutex_);
Json result = Json::array();
const auto path = project_path(root_, ".faset/recovery");
if (!std::filesystem::exists(path))
return result;
for (const auto& entry : std::filesystem::directory_iterator(path))
if (entry.is_regular_file() && entry.path().extension() == ".json") {
try {
const auto value = read_json(entry.path());
result.push_back({{"id", value.at("scene").at("id")},
{"name", value.at("scene").at("name")},
{"path", value.at("path")},
{"dirty", sha256(value.at("scene").dump()) !=
value.value("saved_hash", std::string())}});
} catch (const std::exception&) {
result.push_back({{"error", "Invalid recovery record"},
{"file", path_to_utf8(entry.path().filename())}});
}
}
return result;
}
} // namespace faset::authoring