Parse plist to JSON for accurate structural diff

- Convert XML plist to JSON for proper key sorting and comparison
- Generate normalized XML from sorted JSON for diffing
- Hide unchanged lines in diff view (collapsedContextThreshold: 0)
- Simplify diffPlistKeys to use JSON comparison
This commit is contained in:
cc
2026-04-14 17:09:18 +02:00
parent 3f035977cd
commit 03286dca14
3 changed files with 130 additions and 123 deletions
+3 -3
View File
@@ -14,7 +14,7 @@ import { DiffViewer } from "@/components/diff-viewer";
import { addBasePath } from "@/lib/env";
import { createEngine } from "@/lib/engine";
import type { PathHistory } from "@/lib/engine/types";
import { normalizePlist, prettifyXml } from "@/lib/plist";
import { normalizePlist } from "@/lib/plist";
export default function BinaryDetail() {
const params = useSearchParams();
@@ -47,7 +47,7 @@ export default function BinaryDetail() {
const rawXml = await engine.getBinaryXML(build, path!);
try {
const prettified = prettifyXml(rawXml);
const prettified = normalizePlist(rawXml);
setXML(prettified);
const parser = new DOMParser();
@@ -74,7 +74,7 @@ export default function BinaryDetail() {
async function loadCompare() {
const engine = await createEngine(group);
const rawXml = await engine.getBinaryXML(compareWith!, path!);
const prettified = prettifyXml(rawXml);
const prettified = normalizePlist(rawXml);
setCompareXml(prettified);
}
+29 -17
View File
@@ -4,7 +4,7 @@ import { useMemo } from "react";
import { FileDiff } from "@pierre/diffs/react";
import { parseDiffFromFile } from "@pierre/diffs";
import { diffPlistKeys, type PlistDiff } from "@/lib/plist";
import { diffPlistKeys, normalizePlist, type PlistDiff } from "@/lib/plist";
interface DiffViewerProps {
oldXml: string;
@@ -19,6 +19,9 @@ export function DiffViewer({
oldLabel,
newLabel,
}: DiffViewerProps) {
const normalizedOld = useMemo(() => normalizePlist(oldXml), [oldXml]);
const normalizedNew = useMemo(() => normalizePlist(newXml), [newXml]);
const keysDiff = useMemo(
() => diffPlistKeys(oldXml, newXml),
[oldXml, newXml],
@@ -27,34 +30,43 @@ export function DiffViewer({
const fileDiff = useMemo(
() =>
parseDiffFromFile(
{ name: `${oldLabel}.plist`, contents: oldXml },
{ name: `${newLabel}.plist`, contents: newXml },
{ name: `${oldLabel}.plist`, contents: normalizedOld },
{ name: `${newLabel}.plist`, contents: normalizedNew },
),
[oldXml, newXml, oldLabel, newLabel],
[normalizedOld, normalizedNew, oldLabel, newLabel],
);
const hasChanges =
keysDiff.added.length > 0 ||
keysDiff.removed.length > 0 ||
keysDiff.changed.length > 0;
if (!hasChanges) {
return (
<div className="text-sm text-gray-500 dark:text-gray-400 p-4 border rounded-lg">
No changes between versions
</div>
);
}
return (
<div className="space-y-4">
<DiffSummary diff={keysDiff} />
<div className="rounded-lg overflow-hidden border text-sm">
<FileDiff fileDiff={fileDiff} options={{ diffStyle: "split" }} />
<div className="rounded-lg overflow-hidden border">
<FileDiff
fileDiff={fileDiff}
options={{
diffStyle: "split",
expandUnchanged: false,
collapsedContextThreshold: 0,
}}
/>
</div>
</div>
);
}
function DiffSummary({ diff }: { diff: PlistDiff }) {
const hasChanges =
diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
if (!hasChanges) {
return (
<div className="text-sm text-gray-500 dark:text-gray-400">
No changes in root-level keys
</div>
);
}
return (
<div className="flex flex-wrap gap-4 text-sm">
{diff.added.length > 0 && (
+98 -103
View File
@@ -1,57 +1,112 @@
export function parsePlist(xml: string): Document {
type PlistValue =
| string
| number
| boolean
| PlistValue[]
| { [key: string]: PlistValue };
export function plistToJson(xml: string): { [key: string]: PlistValue } {
const parser = new DOMParser();
return parser.parseFromString(xml, "application/xml");
}
interface PlistEntry {
key: string;
value: string;
}
export function normalizePlist(xml: string): string {
const doc = parsePlist(xml);
const doc = parser.parseFromString(xml, "application/xml");
const rootDict = doc.querySelector("plist > dict");
if (!rootDict) return xml;
const entries: PlistEntry[] = [];
const children = Array.from(rootDict.children);
if (!rootDict) return {};
return parseDict(rootDict);
}
function parseDict(dict: Element): { [key: string]: PlistValue } {
const result: { [key: string]: PlistValue } = {};
const children = Array.from(dict.children);
for (let i = 0; i < children.length; i += 2) {
const keyEl = children[i];
const valueEl = children[i + 1];
if (keyEl?.tagName === "key" && valueEl) {
entries.push({
key: keyEl.textContent || "",
value: new XMLSerializer().serializeToString(valueEl),
});
result[keyEl.textContent || ""] = parseValue(valueEl);
}
}
return result;
}
entries.sort((a, b) => a.key.localeCompare(b.key));
function parseValue(el: Element): PlistValue {
switch (el.tagName) {
case "string":
return el.textContent || "";
case "integer":
return parseInt(el.textContent || "0", 10);
case "real":
return parseFloat(el.textContent || "0");
case "true":
return true;
case "false":
return false;
case "array":
return Array.from(el.children).map(parseValue);
case "dict":
return parseDict(el);
case "data":
return el.textContent || "";
default:
return el.textContent || "";
}
}
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<plist version="1.0">',
" <dict>",
...entries.map((e) => ` <key>${e.key}</key>\n ${indentValue(e.value)}`),
" </dict>",
"</plist>",
];
export function jsonToPlistXml(
obj: { [key: string]: PlistValue },
indent = 0,
): string {
const pad = " ".repeat(indent);
const keys = Object.keys(obj).sort();
const lines: string[] = [];
for (const key of keys) {
lines.push(`${pad}<key>${escapeXml(key)}</key>`);
lines.push(valueToXml(obj[key], indent));
}
return lines.join("\n");
}
function indentValue(xml: string): string {
// Simple indentation for single-line values
if (!xml.includes("\n") && !xml.includes("><")) {
return xml;
function valueToXml(val: PlistValue, indent: number): string {
const pad = " ".repeat(indent);
if (val === true) return `${pad}<true/>`;
if (val === false) return `${pad}<false/>`;
if (typeof val === "string") return `${pad}<string>${escapeXml(val)}</string>`;
if (typeof val === "number") {
return Number.isInteger(val)
? `${pad}<integer>${val}</integer>`
: `${pad}<real>${val}</real>`;
}
// For complex values, add indentation after each closing >
return xml
.replace(/></g, ">\n <")
.split("\n")
.map((line, i) => (i === 0 ? line : " " + line))
.join("\n");
if (Array.isArray(val)) {
if (val.length === 0) return `${pad}<array/>`;
const items = val.map((v) => valueToXml(v, indent + 1)).join("\n");
return `${pad}<array>\n${items}\n${pad}</array>`;
}
if (typeof val === "object") {
const inner = jsonToPlistXml(val, indent + 1);
if (!inner) return `${pad}<dict/>`;
return `${pad}<dict>\n${inner}\n${pad}</dict>`;
}
return `${pad}<string>${escapeXml(String(val))}</string>`;
}
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function normalizePlist(xml: string): string {
const json = plistToJson(xml);
const body = jsonToPlistXml(json, 2);
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<plist version="1.0">',
" <dict>",
body,
" </dict>",
"</plist>",
].join("\n");
}
export interface PlistDiff {
@@ -62,11 +117,11 @@ export interface PlistDiff {
}
export function diffPlistKeys(oldXml: string, newXml: string): PlistDiff {
const oldKeys = extractRootKeys(oldXml);
const newKeys = extractRootKeys(newXml);
const oldJson = plistToJson(oldXml);
const newJson = plistToJson(newXml);
const oldDoc = parsePlist(oldXml);
const newDoc = parsePlist(newXml);
const oldKeys = new Set(Object.keys(oldJson));
const newKeys = new Set(Object.keys(newJson));
const added: string[] = [];
const removed: string[] = [];
@@ -84,9 +139,7 @@ export function diffPlistKeys(oldXml: string, newXml: string): PlistDiff {
} else if (inOld && !inNew) {
removed.push(key);
} else {
const oldValue = getKeyValue(oldDoc, key);
const newValue = getKeyValue(newDoc, key);
if (oldValue === newValue) {
if (JSON.stringify(oldJson[key]) === JSON.stringify(newJson[key])) {
unchanged.push(key);
} else {
changed.push(key);
@@ -96,61 +149,3 @@ export function diffPlistKeys(oldXml: string, newXml: string): PlistDiff {
return { added, removed, changed, unchanged };
}
function extractRootKeys(xml: string): Set<string> {
const doc = parsePlist(xml);
const keys = new Set<string>();
const rootDict = doc.querySelector("plist > dict");
if (!rootDict) return keys;
const keyElements = rootDict.querySelectorAll(":scope > key");
keyElements.forEach((el) => {
if (el.textContent) keys.add(el.textContent);
});
return keys;
}
function getKeyValue(doc: Document, keyName: string): string {
const rootDict = doc.querySelector("plist > dict");
if (!rootDict) return "";
const keys = rootDict.querySelectorAll(":scope > key");
for (const key of keys) {
if (key.textContent === keyName) {
const value = key.nextElementSibling;
if (value) {
return value.outerHTML;
}
}
}
return "";
}
export function prettifyXml(src: string): string {
// Remove DOCTYPE to avoid DTD loading issues
const cleanSrc = src.replace(/<!DOCTYPE[^>]*>/i, "");
const xmlDoc = new DOMParser().parseFromString(cleanSrc, "application/xml");
if (xmlDoc.querySelector("parsererror")) {
return src;
}
const xsltDoc = new DOMParser().parseFromString(
`<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output omit-xml-declaration="yes" indent="yes"/>
<xsl:template match="node()|@*">
<xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy>
</xsl:template>
</xsl:stylesheet>`,
"application/xml",
);
try {
const xsltProcessor = new XSLTProcessor();
xsltProcessor.importStylesheet(xsltDoc);
const resultDoc = xsltProcessor.transformToDocument(xmlDoc);
return new XMLSerializer().serializeToString(resultDoc);
} catch {
return src;
}
}