You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
feat(furnidata): FurnidataWriter single-file comment-preserving atomic write + backup
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Comment-preserving, atomic, backed-up writer for furnidata name/description, keyed by
|
||||
* classname. Supports single-file and split-tier (writes the tier that currently resolves
|
||||
* the classname). Edit-only: refuses classnames absent from the furnidata.
|
||||
*/
|
||||
public class FurnidataWriter {
|
||||
private final Path source; // file (single) or base dir (split-tier)
|
||||
private final boolean directory; // true => split-tier
|
||||
private final long maxBytes;
|
||||
private final int backupKeep;
|
||||
|
||||
public FurnidataWriter(Path source, boolean directory, long maxBytes, int backupKeep) {
|
||||
this.source = source;
|
||||
this.directory = directory;
|
||||
this.maxBytes = maxBytes;
|
||||
this.backupKeep = Math.max(1, backupKeep);
|
||||
}
|
||||
|
||||
/** @return true if an entry for classname was found and written. */
|
||||
public boolean write(String classname, String name, String description) throws IOException {
|
||||
String cn = classname == null ? "" : classname.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (cn.isEmpty()) return false;
|
||||
String safeName = FurnitureTextProvider.sanitize(name);
|
||||
String safeDesc = FurnitureTextProvider.sanitize(description);
|
||||
|
||||
Path target = locateFile(cn);
|
||||
if (target == null) return false;
|
||||
|
||||
String raw = Files.readString(target, StandardCharsets.UTF_8);
|
||||
String edited = replaceEntryFields(raw, cn, safeName, safeDesc);
|
||||
if (edited == null || edited.equals(raw)) {
|
||||
// classname not present in this file, or no change
|
||||
return edited != null && !edited.equals(raw);
|
||||
}
|
||||
backup(target);
|
||||
atomicWrite(target, edited);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** For single-file just returns the file; for split-tier, the tier file that contains cn. */
|
||||
private Path locateFile(String cn) throws IOException {
|
||||
if (!directory) {
|
||||
// confirm existence via the reader (size-guarded, parses the same way)
|
||||
return containsClassname(source, cn) ? source : null;
|
||||
}
|
||||
// split-tier: iterate tiers in OVERRIDE order (later tiers win); pick the last containing cn
|
||||
Path winner = null;
|
||||
for (Path tierFile : splitTierFilesInOrder()) {
|
||||
if (containsClassname(tierFile, cn)) winner = tierFile;
|
||||
}
|
||||
return winner;
|
||||
}
|
||||
|
||||
private boolean containsClassname(Path file, String cn) {
|
||||
for (FurnidataEntry e : new FurnidataReader(file, maxBytes).read()) {
|
||||
if (e.classname() != null && e.classname().trim().toLowerCase(java.util.Locale.ROOT).equals(cn)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the "name" and "description" string values inside the JSON object that holds
|
||||
* "classname": "<cn>". Preserves everything else (comments, ordering, formatting).
|
||||
* Handles double- and single-quoted JSON5 keys/values. Returns null if cn not found.
|
||||
*/
|
||||
static String replaceEntryFields(String raw, String cn, String name, String description) {
|
||||
// find the classname value occurrence (case-insensitive on the value)
|
||||
Pattern classProp = Pattern.compile(
|
||||
"([\"'])classname\\1\\s*:\\s*([\"'])((?:\\\\.|(?!\\2).)*)\\2", Pattern.CASE_INSENSITIVE);
|
||||
Matcher m = classProp.matcher(raw);
|
||||
int objStart = -1, objEnd = -1;
|
||||
while (m.find()) {
|
||||
String val = m.group(3).trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (!val.equals(cn)) continue;
|
||||
// expand to the enclosing { ... }
|
||||
objStart = lastUnbalancedBrace(raw, m.start());
|
||||
objEnd = matchingClose(raw, objStart);
|
||||
break;
|
||||
}
|
||||
if (objStart < 0 || objEnd < 0) return null;
|
||||
String obj = raw.substring(objStart, objEnd + 1);
|
||||
String newObj = replaceField(obj, "name", name);
|
||||
newObj = replaceField(newObj, "description", description);
|
||||
return raw.substring(0, objStart) + newObj + raw.substring(objEnd + 1);
|
||||
}
|
||||
|
||||
private static String replaceField(String obj, String field, String value) {
|
||||
Pattern p = Pattern.compile(
|
||||
"(([\"'])" + Pattern.quote(field) + "\\2\\s*:\\s*)([\"'])((?:\\\\.|(?!\\3).)*)\\3");
|
||||
Matcher m = p.matcher(obj);
|
||||
if (!m.find()) return obj; // field absent → leave object as-is
|
||||
String replacement = m.group(1) + '"' + jsonEscape(value) + '"';
|
||||
return obj.substring(0, m.start()) + replacement + obj.substring(m.end());
|
||||
}
|
||||
|
||||
private static int lastUnbalancedBrace(String s, int from) {
|
||||
int depth = 0;
|
||||
for (int i = from; i >= 0; i--) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '}') depth++;
|
||||
else if (c == '{') { if (depth == 0) return i; depth--; }
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int matchingClose(String s, int open) {
|
||||
int depth = 0; boolean inStr = false; char q = 0;
|
||||
for (int i = open; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (inStr) { if (c == '\\') { i++; } else if (c == q) inStr = false; continue; }
|
||||
if (c == '"' || c == '\'') { inStr = true; q = c; }
|
||||
else if (c == '{') depth++;
|
||||
else if (c == '}') { depth--; if (depth == 0) return i; }
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static String jsonEscape(String v) {
|
||||
StringBuilder b = new StringBuilder(v.length() + 8);
|
||||
for (int i = 0; i < v.length(); i++) {
|
||||
char c = v.charAt(i);
|
||||
if (c == '"' || c == '\\') b.append('\\').append(c);
|
||||
else b.append(c);
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
private List<Path> splitTierFilesInOrder() throws IOException {
|
||||
// Mirrors FurnidataReader split-tier resolution at a coarse level: the manifest order.
|
||||
// For the plan we reuse the reader's defaults; the concrete enumeration is implemented
|
||||
// in Task 4 alongside the split-tier test. Single-file path does not call this.
|
||||
throw new UnsupportedOperationException("implemented in Task 4");
|
||||
}
|
||||
|
||||
private void backup(Path target) throws IOException {
|
||||
Path bak = target.resolveSibling(target.getFileName() + ".bak." + System.nanoTime());
|
||||
Files.copy(target, bak, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
pruneBackups(target);
|
||||
}
|
||||
|
||||
private void pruneBackups(Path target) throws IOException {
|
||||
String prefix = target.getFileName() + ".bak.";
|
||||
try (var stream = Files.list(target.getParent())) {
|
||||
List<Path> baks = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
|
||||
.sorted(Comparator.comparingLong(p -> backupStamp(p))).toList();
|
||||
for (int i = 0; i < baks.size() - backupKeep; i++) Files.deleteIfExists(baks.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
private static long backupStamp(Path p) {
|
||||
String s = p.getFileName().toString();
|
||||
try { return Long.parseLong(s.substring(s.lastIndexOf('.') + 1)); } catch (Exception e) { return 0L; }
|
||||
}
|
||||
|
||||
private void atomicWrite(Path target, String content) throws IOException {
|
||||
Path tmp = target.resolveSibling(target.getFileName() + ".tmp." + System.nanoTime());
|
||||
Files.writeString(tmp, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore the most recent backup of the (single-file) target. @return true if restored. */
|
||||
public boolean revertLastBackup() throws IOException {
|
||||
if (directory) return revertSplitTier();
|
||||
return revertFile(source);
|
||||
}
|
||||
|
||||
private boolean revertFile(Path target) throws IOException {
|
||||
String prefix = target.getFileName() + ".bak.";
|
||||
try (var stream = Files.list(target.getParent())) {
|
||||
Path latest = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
|
||||
.max(Comparator.comparingLong(FurnidataWriter::backupStamp)).orElse(null);
|
||||
if (latest == null) return false;
|
||||
atomicWrite(target, Files.readString(latest, StandardCharsets.UTF_8));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean revertSplitTier() throws IOException {
|
||||
boolean any = false;
|
||||
for (Path f : splitTierFilesInOrder()) any |= revertFile(f);
|
||||
return any;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.eu.habbo.habbohotel.items;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.nio.file.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FurnidataWriterTest {
|
||||
|
||||
private static final String SINGLE =
|
||||
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||
" { \"id\": 1, \"classname\": \"01_caterhead\", \"name\": \"old name\", \"description\": \"old desc\" }\n" +
|
||||
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
||||
|
||||
@Test
|
||||
void writesNameAndDescriptionByClassnameSingleFile() throws Exception {
|
||||
Path dir = Files.createTempDirectory("fd");
|
||||
Path file = dir.resolve("FurnitureData.json");
|
||||
Files.writeString(file, SINGLE);
|
||||
|
||||
FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10);
|
||||
boolean ok = w.write("01_caterhead", "Cat Head", "A cat head");
|
||||
|
||||
assertTrue(ok);
|
||||
String after = Files.readString(file);
|
||||
assertTrue(after.contains("\"Cat Head\""));
|
||||
assertTrue(after.contains("\"A cat head\""));
|
||||
assertFalse(after.contains("old name"));
|
||||
// backup created
|
||||
assertTrue(Files.list(dir).anyMatch(p -> p.getFileName().toString().startsWith("FurnitureData.json.bak")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownClassname() throws Exception {
|
||||
Path dir = Files.createTempDirectory("fd");
|
||||
Path file = dir.resolve("FurnitureData.json");
|
||||
Files.writeString(file, SINGLE);
|
||||
FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10);
|
||||
assertFalse(w.write("does_not_exist", "x", "y"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user