SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
6.9 KB · 177 lines typescript
Raw Blame History
1import { diffLines, createTwoFilesPatch } from "diff";23/**4 * Diff engines. All produce a `DiffResult` with a compact, storable summary plus the5 * unified patch so the UI can render unified / side-by-side / semantic / raw views.6 */78export interface TextDiff {9  kind: "text";10  added: string[];11  removed: string[];12  /** Pairs of (removed → added) that look like modifications of the same line. */13  modified: { before: string; after: string }[];14  unified: string;15  stats: { added: number; removed: number; modified: number; unchangedRatio: number };16}1718export interface JsonDiff {19  kind: "json";20  changes: { path: string; op: "add" | "remove" | "replace"; before?: unknown; after?: unknown }[];21  unified: string;22}2324export interface ListDiff {25  kind: "list";26  added: ListItem[];27  removed: ListItem[];28  modified: { key: string; before: ListItem; after: ListItem; fields: string[] }[];29  unified: string;30}3132export interface ListItem {33  key: string;34  [k: string]: unknown;35}3637export type DiffResult = TextDiff | JsonDiff | ListDiff;3839function similarity(a: string, b: string): number {40  if (a === b) return 1;41  const la = a.length;42  const lb = b.length;43  if (!la || !lb) return 0;44  // cheap: common prefix + suffix ratio45  let p = 0;46  while (p < la && p < lb && a[p] === b[p]) p++;47  let s = 0;48  while (s < la - p && s < lb - p && a[la - 1 - s] === b[lb - 1 - s]) s++;49  return (p + s) / Math.max(la, lb);50}5152export function diffText(before: string, after: string, labelBefore = "before", labelAfter = "after"): TextDiff {53  const parts = diffLines(before, after, { newlineIsToken: false });54  const added: string[] = [];55  const removed: string[] = [];56  let unchanged = 0;57  let total = 0;58  for (const part of parts) {59    const lines = part.value.split("\n").filter((l) => l.length);60    total += lines.length;61    if (part.added) added.push(...lines);62    else if (part.removed) removed.push(...lines);63    else unchanged += lines.length;64  }65  // Pair removed/added lines that look like edits of the same line.66  const modified: { before: string; after: string }[] = [];67  const usedAdded = new Set<number>();68  const remainingRemoved: string[] = [];69  for (const r of removed) {70    let best = -1;71    let bestScore = 0.55;72    for (let i = 0; i < added.length; i++) {73      if (usedAdded.has(i)) continue;74      const sc = similarity(r, added[i]!);75      if (sc > bestScore) {76        bestScore = sc;77        best = i;78      }79    }80    if (best >= 0) {81      usedAdded.add(best);82      modified.push({ before: r, after: added[best]! });83    } else remainingRemoved.push(r);84  }85  const remainingAdded = added.filter((_, i) => !usedAdded.has(i));86  const unified = createTwoFilesPatch(labelBefore, labelAfter, before, after, "", "", { context: 2 });87  return {88    kind: "text",89    added: remainingAdded,90    removed: remainingRemoved,91    modified,92    unified,93    stats: { added: remainingAdded.length, removed: remainingRemoved.length, modified: modified.length, unchangedRatio: total ? unchanged / total : 1 },94  };95}9697export function diffJson(before: unknown, after: unknown): JsonDiff {98  const changes: JsonDiff["changes"] = [];99  const walk = (a: unknown, b: unknown, path: string): void => {100    if (JSON.stringify(a) === JSON.stringify(b)) return;101    const aObj = a && typeof a === "object" && !Array.isArray(a);102    const bObj = b && typeof b === "object" && !Array.isArray(b);103    if (aObj && bObj) {104      const keys = new Set([...Object.keys(a as object), ...Object.keys(b as object)]);105      for (const k of keys) {106        const p = path ? `${path}.${k}` : k;107        const av = (a as Record<string, unknown>)[k];108        const bv = (b as Record<string, unknown>)[k];109        if (av === undefined) changes.push({ path: p, op: "add", after: bv });110        else if (bv === undefined) changes.push({ path: p, op: "remove", before: av });111        else walk(av, bv, p);112      }113      return;114    }115    if (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.length <= 200) {116      for (let i = 0; i < a.length; i++) walk(a[i], b[i], `${path}[${i}]`);117      return;118    }119    changes.push({ path: path || "$", op: "replace", before: a, after: b });120  };121  walk(before, after, "");122  const unified = changes.map((c) => (c.op === "add" ? `+ ${c.path}: ${JSON.stringify(c.after)}` : c.op === "remove" ? `- ${c.path}: ${JSON.stringify(c.before)}` : `- ${c.path}: ${JSON.stringify(c.before)}\n+ ${c.path}: ${JSON.stringify(c.after)}`)).join("\n");123  return { kind: "json", changes, unified };124}125126/** Diff keyed lists (feed items, sitemap URLs, releases, incidents). */127export function diffList(before: ListItem[], after: ListItem[], compareFields: string[] = []): ListDiff {128  const bm = new Map(before.map((i) => [i.key, i]));129  const am = new Map(after.map((i) => [i.key, i]));130  const added: ListItem[] = [];131  const removed: ListItem[] = [];132  const modified: ListDiff["modified"] = [];133  for (const [k, item] of am) {134    const prev = bm.get(k);135    if (!prev) {136      added.push(item);137      continue;138    }139    const fields = compareFields.filter((f) => JSON.stringify(prev[f]) !== JSON.stringify(item[f]));140    if (fields.length) modified.push({ key: k, before: prev, after: item, fields });141  }142  for (const [k, item] of bm) if (!am.has(k)) removed.push(item);143  const label = (i: ListItem): string => String(i.title ?? i.url ?? i.name ?? i.key);144  const unified = [...added.map((i) => `+ ${label(i)}`), ...removed.map((i) => `- ${label(i)}`), ...modified.map((m) => `~ ${label(m.after)} (${m.fields.join(", ")})`)].join("\n");145  return { kind: "list", added, removed, modified, unified };146}147148/** Compact, storable version of a diff for the `changes` table (bounded size). */149export function summarizeDiff(d: DiffResult, maxItems = 40, maxLen = 400): Record<string, unknown> {150  const cut = (s: string): string => (s.length > maxLen ? s.slice(0, maxLen) + "…" : s);151  if (d.kind === "text") {152    return {153      kind: d.kind,154      added: d.added.slice(0, maxItems).map(cut),155      removed: d.removed.slice(0, maxItems).map(cut),156      modified: d.modified.slice(0, maxItems).map((m) => ({ before: cut(m.before), after: cut(m.after) })),157      stats: d.stats,158      truncated: d.added.length > maxItems || d.removed.length > maxItems || d.modified.length > maxItems,159    };160  }161  if (d.kind === "json") return { kind: d.kind, changes: d.changes.slice(0, maxItems), truncated: d.changes.length > maxItems };162  return {163    kind: d.kind,164    added: d.added.slice(0, maxItems),165    removed: d.removed.slice(0, maxItems),166    modified: d.modified.slice(0, maxItems),167    counts: { added: d.added.length, removed: d.removed.length, modified: d.modified.length },168    truncated: d.added.length > maxItems || d.removed.length > maxItems,169  };170}171172export function diffIsEmpty(d: DiffResult): boolean {173  if (d.kind === "text") return d.added.length === 0 && d.removed.length === 0 && d.modified.length === 0;174  if (d.kind === "json") return d.changes.length === 0;175  return d.added.length === 0 && d.removed.length === 0 && d.modified.length === 0;176}177