mirror of
https://github.com/ChiChou/entdb.git
synced 2026-06-10 23:07:47 +02:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user