From 03286dca14071d767d2ff8a6aea7b5cfcfc830dc Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 14 Apr 2026 17:09:18 +0200 Subject: [PATCH] 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 --- src/app/os/bin/page.tsx | 6 +- src/components/diff-viewer.tsx | 46 +++++--- src/lib/plist.ts | 201 ++++++++++++++++----------------- 3 files changed, 130 insertions(+), 123 deletions(-) diff --git a/src/app/os/bin/page.tsx b/src/app/os/bin/page.tsx index b1b99e6..8d76b18 100644 --- a/src/app/os/bin/page.tsx +++ b/src/app/os/bin/page.tsx @@ -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); } diff --git a/src/components/diff-viewer.tsx b/src/components/diff-viewer.tsx index 6803b41..fc1422e 100644 --- a/src/components/diff-viewer.tsx +++ b/src/components/diff-viewer.tsx @@ -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 ( +
+ No changes between versions +
+ ); + } + return (
-
- +
+
); } function DiffSummary({ diff }: { diff: PlistDiff }) { - const hasChanges = - diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0; - - if (!hasChanges) { - return ( -
- No changes in root-level keys -
- ); - } - return (
{diff.added.length > 0 && ( diff --git a/src/lib/plist.ts b/src/lib/plist.ts index 94dd01e..8ff07fd 100644 --- a/src/lib/plist.ts +++ b/src/lib/plist.ts @@ -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 = [ - '', - '', - " ", - ...entries.map((e) => ` ${e.key}\n ${indentValue(e.value)}`), - " ", - "", - ]; +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}${escapeXml(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}`; + if (val === false) return `${pad}`; + if (typeof val === "string") return `${pad}${escapeXml(val)}`; + if (typeof val === "number") { + return Number.isInteger(val) + ? `${pad}${val}` + : `${pad}${val}`; } - // For complex values, add indentation after each closing > - return xml - .replace(/>\n <") - .split("\n") - .map((line, i) => (i === 0 ? line : " " + line)) - .join("\n"); + if (Array.isArray(val)) { + if (val.length === 0) return `${pad}`; + const items = val.map((v) => valueToXml(v, indent + 1)).join("\n"); + return `${pad}\n${items}\n${pad}`; + } + if (typeof val === "object") { + const inner = jsonToPlistXml(val, indent + 1); + if (!inner) return `${pad}`; + return `${pad}\n${inner}\n${pad}`; + } + return `${pad}${escapeXml(String(val))}`; +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">"); +} + +export function normalizePlist(xml: string): string { + const json = plistToJson(xml); + const body = jsonToPlistXml(json, 2); + return [ + '', + '', + " ", + body, + " ", + "", + ].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 { - const doc = parsePlist(xml); - const keys = new Set(); - 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(/]*>/i, ""); - - const xmlDoc = new DOMParser().parseFromString(cleanSrc, "application/xml"); - if (xmlDoc.querySelector("parsererror")) { - return src; - } - - const xsltDoc = new DOMParser().parseFromString( - ` - - - - - `, - "application/xml", - ); - - try { - const xsltProcessor = new XSLTProcessor(); - xsltProcessor.importStylesheet(xsltDoc); - const resultDoc = xsltProcessor.transformToDocument(xmlDoc); - return new XMLSerializer().serializeToString(resultDoc); - } catch { - return src; - } -}