package dev.zprestige.prestige.ui; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; import dev.zprestige.prestige.client.AppPaths; import dev.zprestige.prestige.client.Prestige; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.stream.Stream; /** * Local (offline) backend of the Configs page. Every config is one JSON file in the shared OpenPrestige data directory: * the card metadata plus the client's own snapshot ({@code ConfigManager.saveToJson()}), which is applied again with * {@code ConfigManager.loadFromJson(String)}. "Public configs" lists the configs saved with Public visibility, "Personal configs" * lists all of them; Create saves the current module/HUD state, Update overwrites it, Delete removes the file. */ public final class LocalConfigs { private LocalConfigs() {} /** Cards per page of the Public tab. */ static final int PAGE = 9; private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); /** On-disk format. */ static final class Entry { int id; String name = "", desc = "", author = "", date = ""; List tags = new ArrayList<>(); int visibility; // 0 personal, 1 public int downloads; JsonObject data; // snapshot produced by ConfigManager.saveToJson() } private static boolean ready; private static final List ALL = new ArrayList<>(); private static int lastTab = -1, lastPage = -1; private static String lastQuery = null; static Path dir() { return AppPaths.configs().resolve("presets"); } private static Path legacyDir() { Path base; try { base = net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir(); } catch (Throwable t) { base = Path.of("."); } return base.resolve("prestige").resolve("configs"); } /** Wires the hooks of {@link ConfigsPage}; called once from the first frame of the page. */ static void init() { if (ready) return; ready = true; ConfigsPage.loading = false; ConfigsPage.currentUser = CreateConfigPage.username; ConfigsPage.onDownload = LocalConfigs::download; ConfigsPage.onEdit = c -> {}; ConfigsPage.onUpdate = c -> WidgetsPage.dialog(1, "Update Config", "Overwrite \"" + c.name + "\" with your current module settings?", () -> update(c)); ConfigsPage.onDelete = c -> WidgetsPage.dialog(1, "Delete Config", "Delete \"" + c.name + "\"? This cannot be undone.", () -> delete(c)); ConfigsPage.onPageChange = LocalConfigs::rebuild; ConfigsPage.onSearch = () -> { ConfigsPage.pageIndex = 0; rebuild(); }; ConfigsPage.onTabChange = () -> { ConfigsPage.pageIndex = 0; rebuild(); }; reload(); } /** Per-frame: runs the init, reacts to search/tab changes and to the Create button. */ static void tick() { init(); if (CreateConfigPage.createRequested) { CreateConfigPage.createRequested = false; create(); } if (ConfigsPage.tab != lastTab || ConfigsPage.pageIndex != lastPage || !ConfigsPage.query.equals(lastQuery)) rebuild(); } // ------------------------------------------------------------------ disk static void reload() { ALL.clear(); Path d = dir(); try { Files.createDirectories(d); try (Stream s = Files.list(d)) { for (Path p : (Iterable) s.filter(f -> f.getFileName().toString().endsWith(".json"))::iterator) { try { Entry e = GSON.fromJson(Files.readString(p, StandardCharsets.UTF_8), Entry.class); if (normalize(e)) ALL.add(e); } catch (Throwable t) { System.out.println("[prestige-ui] config " + p.getFileName() + ": " + t); } } } importLegacy(d); } catch (IOException e) { System.out.println("[prestige-ui] configs dir: " + e); } ALL.sort(Comparator.comparingInt((Entry e) -> e.id).reversed()); rebuild(); } private static boolean normalize(Entry entry) { if (entry == null || entry.name == null || entry.name.isBlank()) return false; if (entry.tags == null) entry.tags = new ArrayList<>(); entry.tags.removeIf(java.util.Objects::isNull); if (entry.desc == null) entry.desc = ""; if (entry.author == null) entry.author = ""; if (entry.date == null) entry.date = ""; return true; } private static JsonObject configWithoutId(Entry entry) { JsonObject config = GSON.toJsonTree(entry).getAsJsonObject(); config.remove("id"); return config; } private static void importLegacy(Path directory) throws IOException { Path legacy = legacyDir().toAbsolutePath().normalize(); if (!Files.isDirectory(legacy)) return; String key = java.util.UUID.nameUUIDFromBytes(legacy.toString().getBytes(StandardCharsets.UTF_8)).toString(); Path marker = directory.resolve(".imported-" + key); if (Files.exists(marker)) return; try (Stream files = Files.list(legacy)) { for (Path path : files.filter(p -> p.getFileName().toString().endsWith(".json")).toList()) { Entry entry; try { entry = GSON.fromJson(Files.readString(path, StandardCharsets.UTF_8), Entry.class); } catch (RuntimeException error) { System.out.println("[prestige-ui] could not import " + path.getFileName() + ": " + error); continue; } if (!normalize(entry)) continue; JsonObject content = configWithoutId(entry); if (ALL.stream().anyMatch(existing -> configWithoutId(existing).equals(content))) continue; int originalId = entry.id; if (ALL.stream().anyMatch(existing -> existing.id == originalId)) { entry.id = ALL.stream().mapToInt(existing -> existing.id).max().orElse(0) + 1; } Files.writeString(file(entry), GSON.toJson(entry), StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.CREATE_NEW); ALL.add(entry); } } Files.writeString(marker, ""); } private static Path file(Entry e) { return dir().resolve(e.id + "_" + e.name.replaceAll("[^A-Za-z0-9_.-]", "_") + ".json"); } private static void write(Entry e) { try { Files.createDirectories(dir()); // an id only ever has one file (the name part can change) deleteFiles(e.id); Files.writeString(file(e), GSON.toJson(e), StandardCharsets.UTF_8); } catch (IOException ex) { System.out.println("[prestige-ui] config save: " + ex); WidgetsPage.toast("Could not save config"); } } private static void deleteFiles(int id) { try (Stream s = Files.list(dir())) { s.filter(f -> f.getFileName().toString().startsWith(id + "_")).forEach(f -> { try { Files.deleteIfExists(f); } catch (IOException ignored) {} }); } catch (IOException ignored) {} } // ------------------------------------------------------------------ view static void rebuild() { lastTab = ConfigsPage.tab; lastPage = ConfigsPage.pageIndex; lastQuery = ConfigsPage.query; String q = ConfigsPage.query.trim().toLowerCase(Locale.ROOT); List match = new ArrayList<>(); for (Entry e : ALL) { if (ConfigsPage.tab == 0 && e.visibility != 1) continue; if (!q.isEmpty() && !(e.name.toLowerCase(Locale.ROOT).contains(q) || e.desc.toLowerCase(Locale.ROOT).contains(q) || e.author.toLowerCase(Locale.ROOT).contains(q) || e.tags.stream().anyMatch(t -> t.toLowerCase(Locale.ROOT).contains(q)))) continue; match.add(e); } List out = ConfigsPage.tab == 0 ? ConfigsPage.publicCfgs : ConfigsPage.personalCfgs; out.clear(); int from = 0, to = match.size(); if (ConfigsPage.tab == 0) { int pages = Math.max(1, (match.size() + PAGE - 1) / PAGE); ConfigsPage.pageTotal = pages - 1; ConfigsPage.pageIndex = Math.max(0, Math.min(ConfigsPage.pageIndex, pages - 1)); lastPage = ConfigsPage.pageIndex; from = ConfigsPage.pageIndex * PAGE; to = Math.min(match.size(), from + PAGE); } for (Entry e : match.subList(from, to)) { ConfigsPage.Cfg c = new ConfigsPage.Cfg(e.id, e.name, e.downloads, e.desc, e.author, e.date, true, e.tags.toArray(new String[0])); out.add(c); } ConfigsPage.selected = -1; } private static Entry find(ConfigsPage.Cfg c) { for (Entry e : ALL) if (e.id == c.id) return e; return null; } // ------------------------------------------------------------------ actions private static String snapshot() { if (Prestige.configManager == null) return null; try { return Prestige.configManager.saveToJson(); } catch (Throwable t) { System.out.println("[prestige-ui] snapshot: " + t); return null; } } static void create() { String name = CreateConfigPage.name.toString().trim(); if (name.isEmpty()) { WidgetsPage.dialog(2, "Create Config", "Please enter a name for the config.", null); return; } for (Entry e : ALL) if (e.name.equalsIgnoreCase(name)) { WidgetsPage.dialog(2, "Create Config", "A config called \"" + e.name + "\" already exists. Use Update on its card instead.", null); return; } String snap = snapshot(); if (snap == null) { WidgetsPage.dialog(2, "Create Config", "The client is not ready yet, try again in a moment.", null); return; } Entry e = new Entry(); int next = 1; for (Entry o : ALL) next = Math.max(next, o.id + 1); e.id = next; e.name = name; e.desc = CreateConfigPage.desc.toString().trim(); e.author = CreateConfigPage.username; e.date = LocalDate.now().toString(); e.visibility = CreateConfigPage.visibility; for (int i = 0; i < CreateConfigPage.TAGS.length; i++) if (CreateConfigPage.tagSel[i]) e.tags.add(CreateConfigPage.TAGS[i]); try { e.data = GSON.fromJson(snap, JsonObject.class); } catch (RuntimeException ex) { System.out.println("[prestige-ui] config snapshot: " + ex); WidgetsPage.toast("Could not create config"); return; } write(e); ALL.add(0, e); CreateConfigPage.name.setLength(0); CreateConfigPage.desc.setLength(0); java.util.Arrays.fill(CreateConfigPage.tagSel, false); CreateConfigPage.visibility = 0; ConfigsPage.query = ""; ConfigsPage.setTab(1); rebuild(); WidgetsPage.toast("Config created"); } static void download(ConfigsPage.Cfg c) { Entry e = find(c); if (e == null || e.data == null) { WidgetsPage.toast("Config is empty"); return; } if (Prestige.configManager == null) { WidgetsPage.toast("Client is not ready"); return; } try { Prestige.configManager.loadFromJson(e.data.toString()); String report = Prestige.configManager.consumeLoadReport(); e.downloads++; c.downloads = e.downloads; write(e); // b() is the client's load summary ("||") System.out.println("[prestige-ui] config '" + e.name + "' loaded " + report); WidgetsPage.toast("Loaded " + e.name); } catch (Throwable t) { System.out.println("[prestige-ui] config load: " + t); WidgetsPage.toast("Failed to load config"); } } static void update(ConfigsPage.Cfg c) { Entry e = find(c); String snap = snapshot(); if (e == null || snap == null) { WidgetsPage.toast("Could not update config"); return; } try { e.data = GSON.fromJson(snap, JsonObject.class); } catch (RuntimeException ex) { System.out.println("[prestige-ui] config snapshot: " + ex); WidgetsPage.toast("Could not update config"); return; } e.date = LocalDate.now().toString(); write(e); rebuild(); WidgetsPage.toast("Config updated"); } static void delete(ConfigsPage.Cfg c) { Entry e = find(c); if (e == null) return; deleteFiles(e.id); ALL.remove(e); rebuild(); WidgetsPage.toast("Config deleted"); } /** Dev: create -> download -> update -> delete round trip (DevCmd "state cfgtest"). */ static String selfTest() { init(); StringBuilder r = new StringBuilder("CFGTEST"); CreateConfigPage.name.setLength(0); CreateConfigPage.name.append("selftest"); CreateConfigPage.desc.setLength(0); CreateConfigPage.desc.append("round trip"); CreateConfigPage.visibility = 1; int before = ALL.size(); create(); r.append(" created=").append(ALL.size() - before); Entry e = ALL.isEmpty() ? null : ALL.get(0); if (e == null || !e.name.equals("selftest")) return r.append(" FAIL(no entry)").toString(); r.append(" file=").append(Files.exists(file(e))).append(" dataKeys=").append(e.data == null ? -1 : e.data.size()); r.append(" publicCards=").append(ConfigsPage.publicCfgs.size()); ConfigsPage.Cfg c = ConfigsPage.personalCfgs.stream().filter(x -> x.id == e.id).findFirst().orElse(null); if (c == null) return r.append(" FAIL(no card)").toString(); download(c); r.append(" downloads=").append(e.downloads); update(c); delete(c); r.append(" afterDelete=").append(ALL.size() - before).append(" file=").append(Files.exists(file(e))); return r.toString(); } }