mirror of
https://github.com/ChiChou/entdb.git
synced 2026-08-05 15:38:36 +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 { 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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, "&")
|
||||||
|
.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 {
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user