composite-merge.ts9.2 KBView on GitHub
/**
 * Composite playbook merge / split — pure ProseMirror-JSON transforms.
 *
 * The composite editor shows ONE document built from two underlying playbooks
 * (user + org). `mergeComposite` interleaves them per section; `splitComposite`
 * is the inverse, used on save to write each scope back to its own row.
 *
 * Shape of an underlying playbook doc (from parse-playbook-xml.ts): the top-level
 * children are section nodes —
 *   - `alwaysLoadedSection`  → key `always-loaded`
 *   - `onDemandSection`      → key `on-demand`
 *   - `globalSection`        → key `global`
 *   - `stageSection {id,label}` → key `stage:{id}`
 *
 * Shape of the composite doc: the top-level children are `compositeSection`
 * groups (one per section key), each holding an org and/or user `scopeSection`.
 *
 * No I/O, no React, no TipTap runtime — safe to unit-test in isolation.
 */

/** Minimal ProseMirror-JSON node (a structural subset of TipTap's JSONContent). */
export interface PMNode {
  type: string;
  attrs?: Record<string, unknown>;
  content?: PMNode[];
  text?: string;
  marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
}

export const COMPOSITE_SECTION = 'compositeSection';
export const SCOPE_SECTION = 'scopeSection';

export type Scope = 'org' | 'user';

/** Section key for a top-level playbook section node, or null if not a section. */
function sectionKeyOf(node: PMNode): string | null {
  switch (node.type) {
    case 'alwaysLoadedSection':
      return 'always-loaded';
    case 'onDemandSection':
      return 'on-demand';
    case 'globalSection':
      return 'global';
    case 'stageSection':
      return `stage:${String(node.attrs?.id ?? '')}`;
    default:
      return null;
  }
}

/** Map of section key → section node, preserving document order. */
function topSections(doc: PMNode | null | undefined): Map<string, PMNode> {
  const map = new Map<string, PMNode>();
  for (const node of doc?.content ?? []) {
    const key=[redacted];
    if (key && !map.has(key)) map.set(key, node);
  }
  return map;
}

function nonEmpty(content: PMNode[]): PMNode[] {
  return content.length > 0 ? content : [{ type: 'paragraph' }];
}

/** True for the structural entry/exit criteria nodes hidden on user stage cards. */
function isStageCriteria(n: PMNode): boolean {
  return n.type === 'stageEntry' || n.type === 'stageExit';
}

/**
 * Recursively drop decorative `horizontalRule` nodes (markdown `---`). They have
 * no meaning in a playbook and otherwise render as a stray divider under a card's
 * badge (e.g. a cross-cutting block that is just `---`). A cross-cutting wrapper
 * left empty by the strip is dropped so the card reads as empty, not blank-ruled.
 */
function stripDecorativeRules(nodes: PMNode[]): PMNode[] {
  const out: PMNode[] = [];
  for (const n of nodes) {
    if (n.type === 'horizontalRule') continue;
    if (n.content) {
      const inner = stripDecorativeRules(n.content);
      if (n.type === 'crossCuttingSection' && inner.length === 0) continue;
      out.push({ ...n, content: inner });
    } else {
      out.push(n);
    }
  }
  return out;
}

/**
 * Build a scope sub-card. User stage cards HIDE entry/exit criteria (they are
 * not deleted — `splitComposite` restores them from the original user doc).
 */
function makeScope(scope: Scope, inner: PMNode[], hideStageCriteria: boolean): PMNode {
  let content = stripDecorativeRules(inner);
  if (hideStageCriteria) content = content.filter((n) => !isStageCriteria(n));
  return { type: SCOPE_SECTION, attrs: { scope }, content: nonEmpty(content) };
}

/** Map of stageId → its entry/exit nodes, in document order, from a playbook doc. */
function stageCriteriaByStage(doc: PMNode | null | undefined): Map<string, PMNode[]> {
  const map = new Map<string, PMNode[]>();
  for (const node of doc?.content ?? []) {
    if (node.type !== 'stageSection') continue;
    const criteria = (node.content ?? []).filter(isStageCriteria);
    if (criteria.length) map.set(String(node.attrs?.id ?? ''), criteria);
  }
  return map;
}

/**
 * Recursively drop empty text nodes (`{type:'text', text:''}`). ProseMirror's
 * schema forbids them — `setContent` throws `RangeError: Empty text nodes are
 * not allowed` and the whole editor renders blank. They creep in from parsing
 * empty markdown leaves (e.g. an empty ``` code fence in a cross-cutting block
 * becomes a codeBlock holding one empty text node), so sanitize before the doc
 * ever reaches the editor.
 */
export function stripEmptyTextNodes(node: PMNode): PMNode {
  if (!node.content) return node;
  const content: PMNode[] = [];
  for (const child of node.content) {
    if (child.type === 'text' && !(typeof child.text === 'string' && child.text.length > 0)) continue;
    content.push(stripEmptyTextNodes(child));
  }
  return { ...node, content };
}

/**
 * Merge the user + org playbook docs into one composite doc. Sections appear in
 * canonical order (always-loaded, on-demand, global, then stages — org stages
 * first, then any user-only stages). Each section group contains an org card
 * and/or a user card; the user stage card has Entry/Exit criteria removed.
 */
export function mergeComposite(userJson: PMNode | null, orgJson: PMNode | null): PMNode {
  const userSections = topSections(userJson);
  const orgSections = topSections(orgJson);

  const order: string[] = [];
  const pushKey = (k: string) => {
    if ((orgSections.has(k) || userSections.has(k)) && !order.includes(k)) order.push(k);
  };
  (['always-loaded', 'on-demand', 'global'] as const).forEach(pushKey);
  for (const k of orgSections.keys()) if (k.startsWith('stage:')) pushKey(k);
  for (const k of userSections.keys()) if (k.startsWith('stage:')) pushKey(k);

  const children: PMNode[] = [];
  for (const key of order) {
    const orgNode = orgSections.get(key);
    const userNode = userSections.get(key);
    const isStage = key.startsWith('stage:');
    const ref = orgNode ?? userNode;

    const attrs: Record<string, unknown> = { sectionKey=[redacted] ? 'stage' : key };
    if (isStage) {
      attrs.stageId = String(ref?.attrs?.id ?? key.slice('stage:'.length));
      attrs.label = String(ref?.attrs?.label ?? '');
    }

    const scopes: PMNode[] = [];
    if (orgNode) scopes.push(makeScope('org', orgNode.content ?? [], false));
    if (userNode) scopes.push(makeScope('user', userNode.content ?? [], isStage));
    // Mark every scope after the first so it can draw the divider above it (a
    // CSS `divide-y` can't be used — TipTap wraps node-view children in an
    // extra div, so adjacent-sibling selectors never match).
    scopes.forEach((s, i) => {
      s.attrs = { ...s.attrs, divider: i > 0 };
    });

    children.push({ type: COMPOSITE_SECTION, attrs, content: scopes });
  }

  // Sanitize away empty text nodes so `setContent` never rejects the doc.
  return stripEmptyTextNodes({ type: 'doc', content: nonEmpty(children) });
}

/** Rebuild an underlying section node from a composite section's attrs + scope content. */
function rebuildSection(
  sectionKey=[redacted],
  stageId: string,
  label: string,
  content: PMNode[],
): PMNode {
  switch (sectionKey) {
    case 'always-loaded':
      return { type: 'alwaysLoadedSection', content: nonEmpty(content) };
    case 'on-demand':
      return { type: 'onDemandSection', content: nonEmpty(content) };
    case 'stage':
      return { type: 'stageSection', attrs: { id: stageId, label }, content: nonEmpty(content) };
    case 'global':
    default:
      return { type: 'globalSection', content: nonEmpty(content) };
  }
}

/**
 * Split a composite doc back into separate user + org playbook docs. Inverse of
 * `mergeComposite`. Entry/exit criteria hidden from user stage cards are NOT
 * lost: pass the original user doc as `originalUserJson` and they are restored
 * to the front of each user stage, so the user playbook round-trips faithfully.
 */
export function splitComposite(
  composite: PMNode,
  originalUserJson?: PMNode | null,
): { userJson: PMNode; orgJson: PMNode } {
  const userContent: PMNode[] = [];
  const orgContent: PMNode[] = [];
  const preservedCriteria = stageCriteriaByStage(originalUserJson);

  for (const group of composite.content ?? []) {
    if (group.type !== COMPOSITE_SECTION) continue;
    const sectionKey=[redacted] ?? 'global');
    const stageId = String(group.attrs?.stageId ?? '');
    const label = String(group.attrs?.label ?? '');

    for (const scope of group.content ?? []) {
      if (scope.type !== SCOPE_SECTION) continue;
      const isUser = scope.attrs?.scope !== 'org';
      let content = scope.content ?? [];

      // Restore the user stage's hidden entry/exit criteria (front of the stage,
      // matching the parser's order) so they are preserved, not deleted.
      if (isUser && sectionKey === 'stage') {
        const preserved = preservedCriteria.get(stageId);
        if (preserved?.length) {
          const body = content.filter((n) => n.type !== 'paragraph' || (n.content?.length ?? 0) > 0);
          content = [...preserved, ...body];
        }
      }

      const section = rebuildSection(sectionKey, stageId, label, content);
      if (isUser) userContent.push(section);
      else orgContent.push(section);
    }
  }

  return {
    userJson: { type: 'doc', content: nonEmpty(userContent) },
    orgJson: { type: 'doc', content: nonEmpty(orgContent) },
  };
}