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 { addBasePath } from "@/lib/env";
import { createEngine } from "@/lib/engine"; import { createEngine } from "@/lib/engine";
import type { PathHistory } from "@/lib/engine/types"; import type { PathHistory } from "@/lib/engine/types";
import { normalizePlist, prettifyXml } from "@/lib/plist"; import { normalizePlist } from "@/lib/plist";
export default function BinaryDetail() { export default function BinaryDetail() {
const params = useSearchParams(); const params = useSearchParams();
@@ -47,7 +47,7 @@ export default function BinaryDetail() {
const rawXml = await engine.getBinaryXML(build, path!); const rawXml = await engine.getBinaryXML(build, path!);
try { try {
const prettified = prettifyXml(rawXml); const prettified = normalizePlist(rawXml);
setXML(prettified); setXML(prettified);
const parser = new DOMParser(); const parser = new DOMParser();
@@ -74,7 +74,7 @@ export default function BinaryDetail() {
async function loadCompare() { async function loadCompare() {
const engine = await createEngine(group); const engine = await createEngine(group);
const rawXml = await engine.getBinaryXML(compareWith!, path!); const rawXml = await engine.getBinaryXML(compareWith!, path!);
const prettified = prettifyXml(rawXml); const prettified = normalizePlist(rawXml);
setCompareXml(prettified); setCompareXml(prettified);
} }
+29 -17
View File
@@ -4,7 +4,7 @@ import { useMemo } from "react";
import { FileDiff } from "@pierre/diffs/react"; import { FileDiff } from "@pierre/diffs/react";
import { parseDiffFromFile } from "@pierre/diffs"; import { parseDiffFromFile } from "@pierre/diffs";
import { diffPlistKeys, type PlistDiff } from "@/lib/plist"; import { diffPlistKeys, normalizePlist, type PlistDiff } from "@/lib/plist";
interface DiffViewerProps { interface DiffViewerProps {
oldXml: string; oldXml: string;
@@ -19,6 +19,9 @@ export function DiffViewer({
oldLabel, oldLabel,
newLabel, newLabel,
}: DiffViewerProps) { }: DiffViewerProps) {
const normalizedOld = useMemo(() => normalizePlist(oldXml), [oldXml]);
const normalizedNew = useMemo(() => normalizePlist(newXml), [newXml]);
const keysDiff = useMemo( const keysDiff = useMemo(
() => diffPlistKeys(oldXml, newXml), () => diffPlistKeys(oldXml, newXml),
[oldXml, newXml], [oldXml, newXml],
@@ -27,34 +30,43 @@ export function DiffViewer({
const fileDiff = useMemo( const fileDiff = useMemo(
() => () =>
parseDiffFromFile( parseDiffFromFile(
{ name: `${oldLabel}.plist`, contents: oldXml }, { name: `${oldLabel}.plist`, contents: normalizedOld },
{ name: `${newLabel}.plist`, contents: newXml }, { 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<DiffSummary diff={keysDiff} /> <DiffSummary diff={keysDiff} />
<div className="rounded-lg overflow-hidden border text-sm"> <div className="rounded-lg overflow-hidden border">
<FileDiff fileDiff={fileDiff} options={{ diffStyle: "split" }} /> <FileDiff
fileDiff={fileDiff}
options={{
diffStyle: "split",
expandUnchanged: false,
collapsedContextThreshold: 0,
}}
/>
</div> </div>
</div> </div>
); );
} }
function DiffSummary({ diff }: { diff: PlistDiff }) { 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 ( return (
<div className="flex flex-wrap gap-4 text-sm"> <div className="flex flex-wrap gap-4 text-sm">
{diff.added.length > 0 && ( {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(); const parser = new DOMParser();
return parser.parseFromString(xml, "application/xml"); const doc = parser.parseFromString(xml, "application/xml");
}
interface PlistEntry {
key: string;
value: string;
}
export function normalizePlist(xml: string): string {
const doc = parsePlist(xml);
const rootDict = doc.querySelector("plist > dict"); const rootDict = doc.querySelector("plist > dict");
if (!rootDict) return xml; if (!rootDict) return {};
return parseDict(rootDict);
const entries: PlistEntry[] = []; }
const children = Array.from(rootDict.children);
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) { for (let i = 0; i < children.length; i += 2) {
const keyEl = children[i]; const keyEl = children[i];
const valueEl = children[i + 1]; const valueEl = children[i + 1];
if (keyEl?.tagName === "key" && valueEl) { if (keyEl?.tagName === "key" && valueEl) {
entries.push({ result[keyEl.textContent || ""] = parseValue(valueEl);
key: keyEl.textContent || "",
value: new XMLSerializer().serializeToString(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 = [ export function jsonToPlistXml(
'<?xml version="1.0" encoding="UTF-8"?>', obj: { [key: string]: PlistValue },
'<plist version="1.0">', indent = 0,
" <dict>", ): string {
...entries.map((e) => ` <key>${e.key}</key>\n ${indentValue(e.value)}`), const pad = " ".repeat(indent);
" </dict>", const keys = Object.keys(obj).sort();
"</plist>", 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"); return lines.join("\n");
} }
function indentValue(xml: string): string { function valueToXml(val: PlistValue, indent: number): string {
// Simple indentation for single-line values const pad = " ".repeat(indent);
if (!xml.includes("\n") && !xml.includes("><")) {
return xml; 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 > if (Array.isArray(val)) {
return xml if (val.length === 0) return `${pad}<array/>`;
.replace(/></g, ">\n <") const items = val.map((v) => valueToXml(v, indent + 1)).join("\n");
.split("\n") return `${pad}<array>\n${items}\n${pad}</array>`;
.map((line, i) => (i === 0 ? line : " " + line)) }
.join("\n"); 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 { export interface PlistDiff {
@@ -62,11 +117,11 @@ export interface PlistDiff {
} }
export function diffPlistKeys(oldXml: string, newXml: string): PlistDiff { export function diffPlistKeys(oldXml: string, newXml: string): PlistDiff {
const oldKeys = extractRootKeys(oldXml); const oldJson = plistToJson(oldXml);
const newKeys = extractRootKeys(newXml); const newJson = plistToJson(newXml);
const oldDoc = parsePlist(oldXml); const oldKeys = new Set(Object.keys(oldJson));
const newDoc = parsePlist(newXml); const newKeys = new Set(Object.keys(newJson));
const added: string[] = []; const added: string[] = [];
const removed: string[] = []; const removed: string[] = [];
@@ -84,9 +139,7 @@ export function diffPlistKeys(oldXml: string, newXml: string): PlistDiff {
} else if (inOld && !inNew) { } else if (inOld && !inNew) {
removed.push(key); removed.push(key);
} else { } else {
const oldValue = getKeyValue(oldDoc, key); if (JSON.stringify(oldJson[key]) === JSON.stringify(newJson[key])) {
const newValue = getKeyValue(newDoc, key);
if (oldValue === newValue) {
unchanged.push(key); unchanged.push(key);
} else { } else {
changed.push(key); changed.push(key);
@@ -96,61 +149,3 @@ export function diffPlistKeys(oldXml: string, newXml: string): PlistDiff {
return { added, removed, changed, unchanged }; 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;
}
}