{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stickies-format",
  "title": "Stickies format",
  "author": "Stickies <https://stickies.live>",
  "description": "The Stickies format in TypeScript: types, palette, documents, validation, and the clipboard's HTML and plain text.",
  "files": [
    {
      "path": "registry/stickies/lib/stickies-format.ts",
      "content": "// The Stickies format (https://stickies.live/spec.md) in TypeScript: types, the palette,\n// conversion to and from documents, validation, and the clipboard's HTML and plain text.\n\nexport const STICKIES_VERSION = \"0.1\"\nexport const STICKIES_MIME = \"application/vnd.stickies+json\"\n\nexport const STICKY_COLORS = [\"yellow\", \"blue\", \"green\", \"pink\", \"purple\", \"grey\"] as const\nexport type StickyColor = (typeof STICKY_COLORS)[number]\n\nexport const STICKY_STATES = [\"open\", \"progress\", \"done\"] as const\nexport type StickyState = (typeof STICKY_STATES)[number]\n\n/** Each named colour's paper (the hex sent as `color`), ink and edge, for HTML and fallbacks. */\nexport const STICKY_PALETTE: Record<StickyColor, { paper: string; ink: string; edge: string }> = {\n  yellow: { paper: \"#fdf6c8\", ink: \"#3b3214\", edge: \"#e8d995\" },\n  blue: { paper: \"#ddedfb\", ink: \"#1d3049\", edge: \"#abc9ea\" },\n  green: { paper: \"#def3d7\", ink: \"#22391c\", edge: \"#a9d69c\" },\n  pink: { paper: \"#fbe1ed\", ink: \"#4a1f31\", edge: \"#e7abc6\" },\n  purple: { paper: \"#ede3fb\", ink: \"#33204d\", edge: \"#c5aee9\" },\n  grey: { paper: \"#f2f2f1\", ink: \"#2b2b2a\", edge: \"#cfcfcc\" },\n}\n\nexport const STICKY_COLOR_NAMES: Record<StickyColor, string> = {\n  yellow: \"Yellow\",\n  blue: \"Blue\",\n  green: \"Green\",\n  pink: \"Pink\",\n  purple: \"Purple\",\n  grey: \"Grey\",\n}\n\nexport const STICKY_STATE_NAMES: Record<StickyState, string> = { open: \"Open\", progress: \"In progress\", done: \"Done\" }\n\n/** With semantic notes on, the colour a note is drawn in comes from its state. */\nexport const STICKY_STATE_COLORS: Record<StickyState, StickyColor> = { open: \"yellow\", progress: \"blue\", done: \"green\" }\n\nexport const STICKY_DEFAULT_SIZE = { width: 240, height: 160 }\nexport const STICKY_MIN_SIZE = { width: 180, height: 120 }\n\nexport interface StickiesSource {\n  app?: string\n  url?: string\n  id?: string\n}\n\n/** A note: a JSON Canvas text node with the fields Stickies adds. */\nexport interface StickyNoteData {\n  id: string\n  type: \"text\"\n  text: string\n  x: number\n  y: number\n  width: number\n  height: number\n  color?: string\n  colorName?: string\n  state?: string\n  author?: string\n  createdAt?: string\n  updatedAt?: string\n  source?: StickiesSource\n  ext?: Record<string, Record<string, unknown>>\n}\n\n/** Any other JSON Canvas node (file, link, group): kept, never shown. */\nexport interface OtherCanvasNode {\n  id: string\n  type: string\n  x: number\n  y: number\n  width: number\n  height: number\n  [key: string]: unknown\n}\n\nexport interface StickiesEdge {\n  id: string\n  fromNode: string\n  toNode: string\n  fromSide?: \"top\" | \"right\" | \"bottom\" | \"left\"\n  toSide?: \"top\" | \"right\" | \"bottom\" | \"left\"\n  fromEnd?: \"none\" | \"arrow\"\n  toEnd?: \"none\" | \"arrow\"\n  color?: string\n  label?: string\n}\n\nexport interface StickiesDocument {\n  stickies: string\n  source?: StickiesSource\n  nodes: (StickyNoteData | OtherCanvasNode)[]\n  edges?: StickiesEdge[]\n}\n\nexport const isStickyColor = (v: unknown): v is StickyColor => STICKY_COLORS.some((c) => c === v)\n\n/** The note's state, read as the spec says: absent or unknown is open. */\nexport const stateOf = (note: Pick<StickyNoteData, \"state\">): StickyState =>\n  STICKY_STATES.find((s) => s === note.state) ?? \"open\"\n\n/** The note's named colour: its own, or yellow when it has none this palette knows. */\nexport const colorOf = (note: Pick<StickyNoteData, \"colorName\">): StickyColor =>\n  isStickyColor(note.colorName) ? note.colorName : \"yellow\"\n\n/** The colour to draw: its own, or with semantic notes on, its state's. */\nexport const shownColorOf = (note: Pick<StickyNoteData, \"colorName\" | \"state\">, semantic = false): StickyColor =>\n  semantic ? STICKY_STATE_COLORS[stateOf(note)] : colorOf(note)\n\nexport const nowIso = () => new Date().toISOString().replace(/\\.\\d{3}Z$/, \"Z\")\n\nconst newId = () =>\n  typeof crypto !== \"undefined\" && \"randomUUID\" in crypto ? crypto.randomUUID() : `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`\n\n/** A new note, with an ID, the default size, a colour and times filled in. */\nexport function createNote(fields: Partial<StickyNoteData> = {}): StickyNoteData {\n  const now = nowIso()\n  const colorName = fields.colorName ?? \"yellow\"\n  return {\n    id: newId(),\n    type: \"text\",\n    text: \"\",\n    x: 0,\n    y: 0,\n    ...STICKY_DEFAULT_SIZE,\n    colorName,\n    ...(isStickyColor(colorName) ? { color: STICKY_PALETTE[colorName].paper } : {}),\n    createdAt: now,\n    updatedAt: now,\n    ...fields,\n  }\n}\n\n/** A note with its fields changed and `updatedAt` set to now, keeping `color` in step with `colorName`. */\nexport function changeNote(note: StickyNoteData, fields: Partial<StickyNoteData>): StickyNoteData {\n  const next = { ...note, ...fields, updatedAt: nowIso() }\n  if (fields.colorName && isStickyColor(fields.colorName)) next.color = STICKY_PALETTE[fields.colorName].paper\n  if (next.state === \"open\") delete next.state\n  return next\n}\n\nexport function toDocument(notes: StickyNoteData[], options: { source?: StickiesSource; edges?: StickiesEdge[] } = {}): StickiesDocument {\n  const ids = new Set(notes.map((n) => n.id))\n  return {\n    stickies: STICKIES_VERSION,\n    ...(options.source ? { source: options.source } : {}),\n    nodes: notes,\n    edges: (options.edges ?? []).filter((e) => ids.has(e.fromNode) && ids.has(e.toNode)),\n  }\n}\n\nexport const isNote = (node: StickyNoteData | OtherCanvasNode): node is StickyNoteData =>\n  node.type === \"text\" && typeof node.text === \"string\"\n\n/** The notes in a document, skipping other kinds of node. */\nexport const notesOf = (doc: StickiesDocument): StickyNoteData[] => doc.nodes.filter(isNote)\n\n// ---------------------------------------------------------------- validation\n\nexport interface StickiesProblem {\n  level: \"error\" | \"warning\"\n  message: string\n}\n\nconst isObject = (v: unknown): v is Record<string, unknown> => typeof v === \"object\" && v !== null && !Array.isArray(v)\nconst isNumber = (v: unknown): v is number => typeof v === \"number\" && Number.isFinite(v)\nconst isTime = (v: unknown) => typeof v === \"string\" && /^\\d{4}-\\d{2}-\\d{2}T/.test(v) && !Number.isNaN(Date.parse(v))\nconst SIDES = [\"top\", \"right\", \"bottom\", \"left\"]\nconst ENDS = [\"none\", \"arrow\"]\n\n/** Checks a value against the spec. Errors make it invalid; warnings don't. */\nexport function validate(doc: unknown): StickiesProblem[] {\n  const out: StickiesProblem[] = []\n  const error = (message: string) => out.push({ level: \"error\", message })\n  const warn = (message: string) => out.push({ level: \"warning\", message })\n  const checkSource = (source: unknown, at: string) => {\n    if (!isObject(source)) return error(`\"${at}\" must be an object.`)\n    for (const key of [\"app\", \"url\", \"id\"]) if (source[key] !== undefined && typeof source[key] !== \"string\") error(`${at}.${key} must be a string.`)\n  }\n\n  if (!isObject(doc)) {\n    error(\"The document must be a JSON object.\")\n    return out\n  }\n  if (typeof doc.stickies !== \"string\") error('Missing \"stickies\": the version, as a string like \"0.1\".')\n  else if (!/^0\\.\\d+$/.test(doc.stickies)) warn(`Version \"${doc.stickies}\" isn't a 0.x version.`)\n  if (doc.source !== undefined) checkSource(doc.source, \"source\")\n\n  if (!Array.isArray(doc.nodes)) {\n    error('Missing \"nodes\": an array of notes.')\n    return out\n  }\n  const ids = new Set<string>()\n  doc.nodes.forEach((node: unknown, i: number) => {\n    const at = `nodes[${i}]`\n    if (!isObject(node)) return error(`${at} must be an object.`)\n    if (typeof node.id !== \"string\" || !node.id) error(`${at} needs an \"id\" string.`)\n    else if (ids.has(node.id)) error(`${at}: the id \"${node.id}\" is used twice.`)\n    else ids.add(node.id)\n    if (typeof node.type !== \"string\") error(`${at} needs a \"type\", \"text\" for a note.`)\n    for (const key of [\"x\", \"y\", \"width\", \"height\"]) if (!isNumber(node[key])) error(`${at} needs a number for \"${key}\".`)\n    if (isNumber(node.width) && node.width <= 0) error(`${at}: \"width\" must be more than 0.`)\n    if (isNumber(node.height) && node.height <= 0) error(`${at}: \"height\" must be more than 0.`)\n    if (node.color !== undefined && !(typeof node.color === \"string\" && /^(#[0-9A-Fa-f]{6}|[1-6])$/.test(node.color)))\n      error(`${at}: \"color\" must be a hex like \"#fdf6c8\", or \"1\" to \"6\".`)\n    if (node.type !== \"text\") return\n\n    if (typeof node.text !== \"string\") error(`${at} needs a \"text\" string.`)\n    if (node.colorName !== undefined) {\n      if (typeof node.colorName !== \"string\") error(`${at}: \"colorName\" must be a string.`)\n      else if (!isStickyColor(node.colorName)) warn(`${at}: \"${node.colorName}\" isn't a standard colour name.`)\n    }\n    if (node.state !== undefined && !STICKY_STATES.some((s) => s === node.state)) warn(`${at}: unknown state \"${String(node.state)}\" will be read as open.`)\n    if (node.author !== undefined && typeof node.author !== \"string\") error(`${at}: \"author\" must be a string.`)\n    for (const key of [\"createdAt\", \"updatedAt\"]) if (node[key] !== undefined && !isTime(node[key])) error(`${at}: \"${key}\" must be an ISO 8601 time.`)\n    if (node.source !== undefined) checkSource(node.source, `${at}.source`)\n    if (node.ext !== undefined) {\n      if (!isObject(node.ext)) error(`${at}: \"ext\" must be an object.`)\n      else for (const [key, value] of Object.entries(node.ext)) if (!isObject(value)) error(`${at}: ext.${key} must be an object.`)\n    }\n  })\n\n  if (doc.edges !== undefined) {\n    if (!Array.isArray(doc.edges)) error('\"edges\" must be an array.')\n    else\n      doc.edges.forEach((edge: unknown, i: number) => {\n        const at = `edges[${i}]`\n        if (!isObject(edge)) return error(`${at} must be an object.`)\n        if (typeof edge.id !== \"string\" || !edge.id) error(`${at} needs an \"id\" string.`)\n        for (const key of [\"fromNode\", \"toNode\"]) {\n          const ref = edge[key]\n          if (typeof ref !== \"string\") error(`${at} needs \"${key}\".`)\n          else if (!ids.has(ref)) error(`${at}: \"${key}\" points at \"${ref}\", which isn't a node.`)\n        }\n        for (const key of [\"fromSide\", \"toSide\"]) if (edge[key] !== undefined && !SIDES.includes(String(edge[key]))) error(`${at}: \"${key}\" must be top, right, bottom or left.`)\n        for (const key of [\"fromEnd\", \"toEnd\"]) if (edge[key] !== undefined && !ENDS.includes(String(edge[key]))) error(`${at}: \"${key}\" must be none or arrow.`)\n      })\n  }\n  return out\n}\n\n/** Reads a document from JSON text or a parsed value. Null when it isn't a valid Stickies document. */\nexport function parseDocument(input: unknown): StickiesDocument | null {\n  let value: unknown = input\n  if (typeof input === \"string\") {\n    if (input.length > 1_000_000) return null\n    try {\n      value = JSON.parse(input)\n    } catch {\n      return null\n    }\n  }\n  if (validate(value).some((p) => p.level === \"error\")) return null\n  return value as StickiesDocument\n}\n\n// ---------------------------------------------------------------- clipboard versions\n\n/** Top to bottom, then left to right; notes within half a note's height of each other share a row. */\nexport function readingOrder<T extends Pick<StickyNoteData, \"x\" | \"y\">>(notes: T[]): T[] {\n  const row = STICKY_DEFAULT_SIZE.height / 2\n  return [...notes].sort((a, b) => Math.round(a.y / row) - Math.round(b.y / row) || a.x - b.x)\n}\n\n/** The plain-text version: each note's text, separated by a blank line. */\nexport const toText = (notes: StickyNoteData[]) =>\n  readingOrder(notes)\n    .map((n) => n.text.trim())\n    .filter(Boolean)\n    .join(\"\\n\\n\")\n\nconst escapeHtml = (s: string) => s.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\").replace(/\"/g, \"&quot;\")\n\n/** The HTML version: coloured boxes with inline styles, carrying the whole document in `data-stickies`. */\nexport function toHtml(doc: StickiesDocument): string {\n  const boxes = readingOrder(notesOf(doc)).map((n) => {\n    const named = isStickyColor(n.colorName) ? STICKY_PALETTE[n.colorName] : null\n    const paper = named?.paper ?? (n.color?.startsWith(\"#\") ? n.color : STICKY_PALETTE.yellow.paper)\n    const { ink, edge } = named ?? STICKY_PALETTE.yellow\n    const mark = stateOf(n) === \"done\" ? \"✓ \" : \"\"\n    const style = `background:${paper};color:${ink};border:1px solid ${edge};padding:12px 14px;border-radius:8px;width:${Math.round(n.width)}px;max-width:100%;box-sizing:border-box;margin:0 0 8px;font:14px/1.45 -apple-system,system-ui,sans-serif`\n    return `<div style=\"${style}\">${mark}${escapeHtml(n.text.trim()).replace(/\\n/g, \"<br>\")}</div>`\n  })\n  return `<div data-stickies=\"${escapeHtml(JSON.stringify(doc))}\">${boxes.join(\"\")}</div>`\n}\n\n/** The document carried in pasted HTML's `data-stickies`, wherever the element sits. */\nexport function fromHtml(html: string): StickiesDocument | null {\n  if (!html.includes(\"data-stickies\")) return null\n  if (typeof DOMParser !== \"undefined\") {\n    const el = new DOMParser().parseFromString(html, \"text/html\").querySelector(\"[data-stickies]\")\n    return parseDocument(el?.getAttribute(\"data-stickies\") ?? \"\")\n  }\n  const match = /data-stickies=\"([^\"]*)\"/.exec(html)\n  if (!match) return null\n  const raw = match[1].replace(/&quot;/g, '\"').replace(/&lt;/g, \"<\").replace(/&gt;/g, \">\").replace(/&#39;/g, \"'\").replace(/&amp;/g, \"&\")\n  return parseDocument(raw)\n}\n\n// ---------------------------------------------------------------- Obsidian\n\n/**\n * Obsidian's canvas clipboard type. It holds plain JSON Canvas (`nodes`, `edges`, and the\n * selection's `center`), so a Stickies document fits as it is, and Obsidian keeps the fields\n * it doesn't know. Only Chromium-based apps can pass it between each other (spec §8.6).\n */\nexport const OBSIDIAN_CANVAS_MIME = \"obsidian/canvas\"\n\n/** Obsidian's numbered colours, as the nearest named colour. */\nconst OBSIDIAN_COLORS: Record<string, StickyColor> = { \"1\": \"pink\", \"2\": \"yellow\", \"3\": \"yellow\", \"4\": \"green\", \"5\": \"blue\", \"6\": \"purple\" }\n\n/** The document as Obsidian's canvas expects it on the clipboard. */\nexport function toObsidianCanvas(doc: StickiesDocument): string {\n  const { nodes, edges = [] } = doc\n  const left = Math.min(...nodes.map((n) => n.x))\n  const top = Math.min(...nodes.map((n) => n.y))\n  const right = Math.max(...nodes.map((n) => n.x + n.width))\n  const bottom = Math.max(...nodes.map((n) => n.y + n.height))\n  const center = nodes.length ? { x: (left + right) / 2, y: (top + bottom) / 2 } : { x: 0, y: 0 }\n  return JSON.stringify({ nodes, edges, center })\n}\n\n/**\n * Cards copied from Obsidian's canvas, as a Stickies document. Text cards with one of\n * Obsidian's numbered colours get the nearest named colour. Null when it isn't valid.\n */\nexport function fromObsidianCanvas(json: string): StickiesDocument | null {\n  if (!json || json.length > 1_000_000) return null\n  let value: unknown\n  try {\n    value = JSON.parse(json)\n  } catch {\n    return null\n  }\n  if (typeof value !== \"object\" || value === null || !(\"nodes\" in value) || !Array.isArray(value.nodes)) return null\n  const nodes = value.nodes.map((node: unknown) => {\n    if (typeof node !== \"object\" || node === null) return node\n    const { color, colorName, type } = node as Record<string, unknown>\n    const named = typeof color === \"string\" ? OBSIDIAN_COLORS[color] : undefined\n    if (type !== \"text\" || colorName !== undefined || !named) return node\n    return { ...node, colorName: named, color: STICKY_PALETTE[named].paper }\n  })\n  const edges = \"edges\" in value && Array.isArray(value.edges) ? value.edges : []\n  return parseDocument({ stickies: STICKIES_VERSION, nodes, edges })\n}\n\n/** Plain text as notes: one per paragraph, laid out in rows. */\nexport function fromText(text: string, columns = 4): StickyNoteData[] {\n  const gap = 40\n  return text\n    .split(/\\n\\s*\\n/)\n    .map((p) => p.trim())\n    .filter(Boolean)\n    .slice(0, 200)\n    .map((p, i) =>\n      createNote({\n        text: p,\n        x: (i % columns) * (STICKY_DEFAULT_SIZE.width + gap),\n        y: Math.floor(i / columns) * (STICKY_DEFAULT_SIZE.height + gap),\n      }),\n    )\n}\n\n/**\n * Notes ready to add to a board: new IDs (the old one kept in `source.id`), and moved\n * together so the group's top-left corner is at `at`.\n */\nexport function placeNotes(notes: StickyNoteData[], at: { x: number; y: number }, source?: StickiesSource): StickyNoteData[] {\n  if (notes.length === 0) return []\n  const left = Math.min(...notes.map((n) => n.x))\n  const top = Math.min(...notes.map((n) => n.y))\n  return notes.map(({ id, ...n }) =>\n    createNote({\n      ...n,\n      x: Math.round(at.x + n.x - left),\n      y: Math.round(at.y + n.y - top),\n      source: { ...source, ...n.source, id: n.source?.id ?? id },\n    }),\n  )\n}\n",
      "type": "registry:lib"
    }
  ],
  "docs": "See https://stickies.live/docs#stickies-format for usage.",
  "categories": [
    "stickies"
  ],
  "type": "registry:lib"
}