mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
Merge branch 'main' into updated-inbox
This commit is contained in:
commit
269aa923b4
|
|
@ -126,16 +126,13 @@ test.describe("search", () => {
|
|||
expect(branchId).toBeDefined()
|
||||
|
||||
const inserted = await client.callTool({
|
||||
name: "insert_block",
|
||||
name: "insert_blocks",
|
||||
arguments: {
|
||||
document_id: id,
|
||||
branch_id: branchId,
|
||||
position: "end",
|
||||
// a metric lives in a grid; the root refuses a bare one
|
||||
block: {
|
||||
type: "metric_grid",
|
||||
items: [{ type: "metric", attrs: { title: title } }],
|
||||
},
|
||||
content: `<metrics><metric>${JSON.stringify({ title })}</metric></metrics>`,
|
||||
},
|
||||
})
|
||||
expect(inserted.isError, JSON.stringify(inserted.content)).toBeFalsy()
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ organization and admin, created before listening),
|
|||
`logging.ts`, `headers.ts`, `auth.ts` (`createAuth` + better-auth
|
||||
callbacks), `hocuspocus.ts` (`createHocuspocus`, `createDocumentHooks`,
|
||||
`flushDocument`), `routes.ts` (`createRoutes` → Hono app), `operations.ts`
|
||||
(pure: assistant edit ops on a Y.Doc), `ydocument.ts` (pure: replacing Y.Doc
|
||||
(pure: assistant edit ops on a Y.Doc), `validate.ts` (pure: checks those ops
|
||||
against the editor schema), `ydocument.ts` (pure: replacing Y.Doc
|
||||
content), `schema/` (ProseMirror schema mirroring web's tiptap extensions),
|
||||
`sentry.ts` and `bundle.ts` (docker entry: sentry + index in one bundle).
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,38 +1,43 @@
|
|||
/**
|
||||
* Operation interpreter for the Go-driven edit RPC.
|
||||
*
|
||||
* The Go assistant builds operations against the canonical block
|
||||
* model, expands them into ProseMirror JSON, and posts them to the
|
||||
* The Go assistant builds operations as ProseMirror JSON and posts
|
||||
* them to the
|
||||
* /api/x/documents/:docId/branches/:branchId/operations route. This
|
||||
* module owns the Y.XmlFragment mutations that apply those
|
||||
* operations to a live Y.Doc. Every applied batch runs inside a
|
||||
* single `Y.Doc.transact` so subscribers see one update.
|
||||
*
|
||||
* The operations themselves are intentionally small (single-block
|
||||
* atomic) because the AI fires them in parallel within one turn —
|
||||
* see docs/assistant-rework.md §2.1 for the schema.
|
||||
* operations to a live Y.Doc. A batch applies whole or not at all,
|
||||
* inside a single `Y.Doc.transact` so subscribers see one update.
|
||||
*/
|
||||
import * as Y from "yjs"
|
||||
import { nanoid } from "nanoid"
|
||||
import { schema, transformer, cloneXmlElement } from "./ydocument.js"
|
||||
import { transformer, cloneXmlElement, cloneXmlFragment } from "./ydocument.js"
|
||||
import { checkBlock, checkChildren } from "./validate.js"
|
||||
|
||||
/** Canonical attribute name carried on every editor block. */
|
||||
const UID_ATTR = "uid"
|
||||
|
||||
// what a refused insert or move should try instead.
|
||||
const PLACE_ADVICE = "Choose a reference block in a container that takes it."
|
||||
|
||||
// what a refused append or prepend should try instead.
|
||||
const END_ADVICE =
|
||||
"Insert it before or after a block in a container that takes it."
|
||||
|
||||
/** The sides an insert or move may name, as a widened list for validation. */
|
||||
const INSERT_POSITIONS: string[] = ["before", "after"]
|
||||
|
||||
/**
|
||||
* One batched operation against a document. Shapes mirror the Go
|
||||
* canonical/op definitions; the Node side only validates the
|
||||
* minimum required to apply each op.
|
||||
* edit operations. An op that adds, replaces, moves or deletes a block
|
||||
* is checked against the editor schema (validate.ts). applyOperations
|
||||
* runs the batch on a trial copy first, so a refused op never reaches
|
||||
* the document.
|
||||
*/
|
||||
export type Operation =
|
||||
| InsertOp
|
||||
| AppendOp
|
||||
| PrependOp
|
||||
| ReplaceOp
|
||||
| UpdateTextOp
|
||||
| UpdateAttrsOp
|
||||
| DeleteOp
|
||||
| MoveOp
|
||||
|
|
@ -69,26 +74,9 @@ export interface ReplaceOp {
|
|||
block: PMNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the inline content of a text-bearing block in place,
|
||||
* preserving the block's type, attrs, and uid. A block holding its text
|
||||
* one level down (a callout or blockquote of one paragraph, a list
|
||||
* entry, a titled code block) is written through to that child; any
|
||||
* other block-carrying target, and a mark the target does not allow, is
|
||||
* refused rather than flattened.
|
||||
*/
|
||||
export interface UpdateTextOp {
|
||||
kind: "update_text"
|
||||
block_uid: string
|
||||
/** ProseMirror inline content (text nodes with marks). */
|
||||
content: PMInline[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets/overrides the named attributes on an existing block. Other
|
||||
* attributes are preserved. The uid attribute cannot be changed. A
|
||||
* titled code block takes only title and language, written to its
|
||||
* children.
|
||||
* attributes are preserved. The uid attribute cannot be changed.
|
||||
*/
|
||||
export interface UpdateAttrsOp {
|
||||
kind: "update_attrs"
|
||||
|
|
@ -128,22 +116,15 @@ export interface SetIconOp {
|
|||
icon: string
|
||||
}
|
||||
|
||||
/** A ProseMirror JSON node. Loose shape; transformer validates. */
|
||||
/** A ProseMirror JSON node. Loose shape; validate.ts checks it. */
|
||||
export interface PMNode {
|
||||
type: string
|
||||
attrs?: Record<string, unknown>
|
||||
content?: (PMNode | PMInline)[]
|
||||
content?: PMNode[]
|
||||
text?: string
|
||||
marks?: PMMark[]
|
||||
}
|
||||
|
||||
/** A ProseMirror inline text node fragment. */
|
||||
export interface PMInline {
|
||||
type: "text"
|
||||
text: string
|
||||
marks?: PMMark[]
|
||||
}
|
||||
|
||||
/** A ProseMirror mark (bold, italic, link, …). */
|
||||
export interface PMMark {
|
||||
type: string
|
||||
|
|
@ -160,39 +141,59 @@ export interface OperationError {
|
|||
|
||||
/** Result of applying a batch of operations. */
|
||||
export interface ApplyResult {
|
||||
/** Number of operations that were applied successfully. */
|
||||
applied: number
|
||||
/** Per-op errors, one entry per failed op (in input order). */
|
||||
/** The operation that failed, if any; a batch stops at the first. */
|
||||
errors: OperationError[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a batch of operations to the given Y.Doc inside a single
|
||||
* transaction. Failures on individual operations are collected but
|
||||
* do not abort the batch — applied state is committed for the ops
|
||||
* that succeeded. Subscribers see one consolidated update.
|
||||
* transaction, all of them or none. Subscribers see one consolidated
|
||||
* update.
|
||||
*/
|
||||
export function applyOperations(doc: Y.Doc, ops: Operation[]): ApplyResult {
|
||||
const result: ApplyResult = { applied: 0, errors: [] }
|
||||
// a Yjs transaction cannot be rolled back, so the batch first runs
|
||||
// on an exact copy that is then thrown away. Only a batch that
|
||||
// applies whole reaches doc.
|
||||
const trial = new Y.Doc()
|
||||
cloneXmlFragment(
|
||||
doc.getXmlFragment("content"),
|
||||
trial.getXmlFragment("content"),
|
||||
)
|
||||
|
||||
const error = runOperations(trial, ops)
|
||||
if (error) {
|
||||
return { errors: [error] }
|
||||
}
|
||||
|
||||
doc.transact(() => {
|
||||
ops.forEach((op, index) => {
|
||||
try {
|
||||
applyOperation(doc, op)
|
||||
result.applied++
|
||||
} catch (err) {
|
||||
result.errors.push({
|
||||
index,
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: String(err),
|
||||
})
|
||||
}
|
||||
})
|
||||
runOperations(doc, ops)
|
||||
})
|
||||
|
||||
return result
|
||||
return { errors: [] }
|
||||
}
|
||||
|
||||
// runOperations applies each operation in turn and returns the first
|
||||
// that fails. The ones after it build on blocks it never wrote, so
|
||||
// their errors would only mislead.
|
||||
function runOperations(
|
||||
doc: Y.Doc,
|
||||
ops: Operation[],
|
||||
): OperationError | undefined {
|
||||
for (const [index, op] of ops.entries()) {
|
||||
try {
|
||||
applyOperation(doc, op)
|
||||
} catch (err) {
|
||||
return {
|
||||
index,
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: String(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Dispatches one operation to its concrete handler. */
|
||||
|
|
@ -210,9 +211,6 @@ function applyOperation(doc: Y.Doc, op: Operation): void {
|
|||
case "replace":
|
||||
opReplace(doc, op)
|
||||
return
|
||||
case "update_text":
|
||||
opUpdateText(doc, op)
|
||||
return
|
||||
case "update_attrs":
|
||||
opUpdateAttrs(doc, op)
|
||||
return
|
||||
|
|
@ -255,15 +253,11 @@ function opInsert(doc: Y.Doc, op: InsertOp): void {
|
|||
)
|
||||
}
|
||||
|
||||
const found = findByUid(doc.getXmlFragment("content"), op.reference_uid)
|
||||
if (!found) {
|
||||
throw new Error(`reference_uid not found: ${op.reference_uid}`)
|
||||
}
|
||||
|
||||
const xml = pmBlockToY(op.block)
|
||||
const found = blockByUid(doc, op.reference_uid, "reference_uid")
|
||||
const insertAt =
|
||||
op.position === "before" ? found.index : found.index + 1
|
||||
found.parent.insert(insertAt, [xml])
|
||||
|
||||
insertBlock(found.parent, insertAt, op.block, PLACE_ADVICE)
|
||||
}
|
||||
|
||||
function opAppend(doc: Y.Doc, op: AppendOp): void {
|
||||
|
|
@ -276,171 +270,37 @@ function opAppend(doc: Y.Doc, op: AppendOp): void {
|
|||
// stray empty block. Insert before it instead so the trailing
|
||||
// paragraph stays last.
|
||||
let insertAt = frag.length
|
||||
if (insertAt > 0) {
|
||||
const last = frag.get(insertAt - 1)
|
||||
if (last instanceof Y.XmlElement && isEmptyParagraph(last)) {
|
||||
insertAt -= 1
|
||||
}
|
||||
const last = insertAt > 0 ? frag.get(insertAt - 1) : null
|
||||
if (
|
||||
last instanceof Y.XmlElement &&
|
||||
last.nodeName === "paragraph" &&
|
||||
last
|
||||
.toArray()
|
||||
.every(
|
||||
(child) =>
|
||||
child instanceof Y.XmlText &&
|
||||
child.length === 0,
|
||||
)
|
||||
) {
|
||||
insertAt -= 1
|
||||
}
|
||||
|
||||
frag.insert(insertAt, [pmBlockToY(op.block)])
|
||||
insertBlock(frag, insertAt, op.block, END_ADVICE)
|
||||
}
|
||||
|
||||
function opPrepend(doc: Y.Doc, op: PrependOp): void {
|
||||
const frag = doc.getXmlFragment("content")
|
||||
frag.insert(0, [pmBlockToY(op.block)])
|
||||
insertBlock(doc.getXmlFragment("content"), 0, op.block, END_ADVICE)
|
||||
}
|
||||
|
||||
function opReplace(doc: Y.Doc, op: ReplaceOp): void {
|
||||
const found = findByUid(doc.getXmlFragment("content"), op.block_uid)
|
||||
if (!found) {
|
||||
throw new Error(`block_uid not found: ${op.block_uid}`)
|
||||
}
|
||||
const found = blockByUid(doc, op.block_uid, "block_uid")
|
||||
|
||||
const xml = pmBlockToY(op.block)
|
||||
found.parent.delete(found.index, 1)
|
||||
found.parent.insert(found.index, [xml])
|
||||
}
|
||||
|
||||
// the blocks whose children are inline content, so replacing them
|
||||
// wholesale with a text run is the whole edit.
|
||||
const TEXT_LEAF_NODES = new Set([
|
||||
"paragraph",
|
||||
"heading",
|
||||
"codeBlock",
|
||||
"codeBlockTitle",
|
||||
"mermaidBlock",
|
||||
])
|
||||
|
||||
// the blocks that hold their text one level down, each with the child it
|
||||
// lives in. Their schemas admit block children only, so writing a text
|
||||
// run into them directly produces a node the editor cannot parse and
|
||||
// the canonical model cannot read back.
|
||||
const TEXT_HOLDER_NODES = new Map([
|
||||
["calloutBlock", "paragraph"],
|
||||
["blockquote", "paragraph"],
|
||||
["listItem", "paragraph"],
|
||||
["taskItem", "paragraph"],
|
||||
["titledCodeBlock", "codeBlock"],
|
||||
])
|
||||
|
||||
// the holders refused when they hold more than one block, since the
|
||||
// edit would show only the first. A list entry is not one of them: the
|
||||
// lists under its paragraph belong to it.
|
||||
const SINGLE_TEXT_HOLDERS = new Set(["calloutBlock", "blockquote"])
|
||||
|
||||
function opUpdateText(doc: Y.Doc, op: UpdateTextOp): void {
|
||||
const found = findByUid(doc.getXmlFragment("content"), op.block_uid)
|
||||
if (!found) {
|
||||
throw new Error(`block_uid not found: ${op.block_uid}`)
|
||||
}
|
||||
|
||||
const target = textTarget(found.element)
|
||||
|
||||
checkMarks(target.nodeName, op.content)
|
||||
|
||||
target.delete(0, target.length)
|
||||
|
||||
const text = buildInlineText(op.content)
|
||||
if (text) {
|
||||
target.insert(0, [text])
|
||||
}
|
||||
}
|
||||
|
||||
// textTarget returns the element whose inline content update_text
|
||||
// replaces: the block itself, or the child a holder keeps its text in.
|
||||
function textTarget(el: Y.XmlElement): Y.XmlElement {
|
||||
const nodeName = el.nodeName
|
||||
|
||||
if (TEXT_LEAF_NODES.has(nodeName)) {
|
||||
return el
|
||||
}
|
||||
|
||||
const holds = TEXT_HOLDER_NODES.get(nodeName)
|
||||
|
||||
// the edit replaces everything under the target, so a block holding
|
||||
// other blocks would lose them — and lose the uids comments and
|
||||
// hooks are anchored to — while reporting success. Only the caller
|
||||
// knows that was not meant, so refuse instead of guessing.
|
||||
if (holds === undefined) {
|
||||
throw new Error(
|
||||
`update_text does not apply to ${nodeName}: it carries ` +
|
||||
`blocks rather than text, and the edit would discard ` +
|
||||
`them. Use replace_block to rewrite it whole, or ` +
|
||||
`update_text on the block holding the text.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (SINGLE_TEXT_HOLDERS.has(nodeName) && el.length > 1) {
|
||||
throw new Error(
|
||||
`update_text does not apply to ${nodeName} holding ` +
|
||||
`${String(el.length)} blocks: its text is not one ` +
|
||||
`paragraph. Use update_text on the paragraph you ` +
|
||||
`mean, or replace_block to rewrite it whole.`,
|
||||
)
|
||||
}
|
||||
|
||||
const target = firstChild(el, holds)
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`update_text found no ${holds} to write in ${nodeName}`,
|
||||
)
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
// checkMarks refuses a mark the target's schema does not allow. The
|
||||
// editor cannot load such a node, so the edit would be lost while
|
||||
// reporting success.
|
||||
function checkMarks(nodeName: string, content: PMInline[]): void {
|
||||
const nodeType = schema.nodes[nodeName]
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- operations arrive as unvalidated JSON, so the declared type is a claim about the caller rather than a guarantee about the value
|
||||
for (const node of content ?? []) {
|
||||
for (const mark of node.marks ?? []) {
|
||||
const markType = schema.marks[mark.type]
|
||||
|
||||
if (
|
||||
!nodeType ||
|
||||
!markType ||
|
||||
!nodeType.allowsMarkType(markType)
|
||||
) {
|
||||
throw new Error(
|
||||
`update_text cannot put the ${mark.type} mark in ` +
|
||||
`${nodeName}: write the text without it`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// firstChild returns the element's first child of the named type.
|
||||
function firstChild(el: Y.XmlElement, nodeName: string): Y.XmlElement | null {
|
||||
for (let i = 0; i < el.length; i++) {
|
||||
const child = el.get(i)
|
||||
if (
|
||||
child instanceof Y.XmlElement &&
|
||||
child.nodeName === nodeName
|
||||
) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
insertBlock(found.parent, found.index, op.block)
|
||||
}
|
||||
|
||||
function opUpdateAttrs(doc: Y.Doc, op: UpdateAttrsOp): void {
|
||||
const found = findByUid(doc.getXmlFragment("content"), op.block_uid)
|
||||
if (!found) {
|
||||
throw new Error(`block_uid not found: ${op.block_uid}`)
|
||||
}
|
||||
|
||||
if (found.element.nodeName === "titledCodeBlock") {
|
||||
updateTitledCodeAttrs(found.element, op.attrs)
|
||||
|
||||
return
|
||||
}
|
||||
const found = blockByUid(doc, op.block_uid, "block_uid")
|
||||
|
||||
for (const [key, value] of Object.entries(op.attrs)) {
|
||||
if (key === UID_ATTR) {
|
||||
|
|
@ -449,86 +309,18 @@ function opUpdateAttrs(doc: Y.Doc, op: UpdateAttrsOp): void {
|
|||
continue
|
||||
}
|
||||
|
||||
// Y.XmlElement.setAttribute is typed as accepting strings
|
||||
// only, but the runtime stores arbitrary values, which is
|
||||
// what the editor schema relies on for nested metric
|
||||
// configuration. Cast through unknown to bypass the
|
||||
// declaration's stricter type.
|
||||
// the default attribute type is string, but attrs hold any JSON.
|
||||
;(
|
||||
found.element as unknown as {
|
||||
setAttribute(key: string, value: unknown): void
|
||||
}
|
||||
found.element as Y.XmlElement<Record<string, any>>
|
||||
).setAttribute(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// updateTitledCodeAttrs writes the title and language where the editor
|
||||
// reads them: the title row's text and the code block's attribute. On
|
||||
// the block itself, both would be ignored.
|
||||
function updateTitledCodeAttrs(
|
||||
el: Y.XmlElement,
|
||||
attrs: Record<string, unknown>,
|
||||
): void {
|
||||
const keys = Object.keys(attrs).filter((key) => key !== UID_ATTR)
|
||||
|
||||
const other = keys.find((key) => key !== "title" && key !== "language")
|
||||
if (other !== undefined) {
|
||||
throw new Error(
|
||||
`titledCodeBlock takes only title and language, not ` +
|
||||
`${other}. Use replace_block to change anything else.`,
|
||||
)
|
||||
}
|
||||
|
||||
// the editor's highlighter fails on a language that is not a string,
|
||||
// and the document stops rendering.
|
||||
const language = attrs.language
|
||||
if (
|
||||
keys.includes("language") &&
|
||||
language !== null &&
|
||||
typeof language !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"titledCodeBlock language must be a string or null",
|
||||
)
|
||||
}
|
||||
|
||||
// both are looked up first, so a refusal changes nothing.
|
||||
const title = firstChild(el, "codeBlockTitle")
|
||||
const code = firstChild(el, "codeBlock")
|
||||
if (!title || !code) {
|
||||
throw new Error(
|
||||
"titledCodeBlock is missing its title or code. Use " +
|
||||
"replace_block to rewrite it whole.",
|
||||
)
|
||||
}
|
||||
|
||||
if (keys.includes("title")) {
|
||||
title.delete(0, title.length)
|
||||
|
||||
const text = typeof attrs.title === "string" ? attrs.title : ""
|
||||
if (text) {
|
||||
const run = new Y.XmlText()
|
||||
run.insert(0, text)
|
||||
title.insert(0, [run])
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.includes("language")) {
|
||||
;(
|
||||
code as unknown as {
|
||||
setAttribute(key: string, value: unknown): void
|
||||
}
|
||||
).setAttribute("language", language)
|
||||
}
|
||||
}
|
||||
|
||||
function opDelete(doc: Y.Doc, op: DeleteOp): void {
|
||||
const found = findByUid(doc.getXmlFragment("content"), op.block_uid)
|
||||
if (!found) {
|
||||
throw new Error(`block_uid not found: ${op.block_uid}`)
|
||||
}
|
||||
const found = blockByUid(doc, op.block_uid, "block_uid")
|
||||
|
||||
found.parent.delete(found.index, 1)
|
||||
checkChildrenOf(found.parent)
|
||||
}
|
||||
|
||||
function opMove(doc: Y.Doc, op: MoveOp): void {
|
||||
|
|
@ -545,21 +337,12 @@ function opMove(doc: Y.Doc, op: MoveOp): void {
|
|||
)
|
||||
}
|
||||
|
||||
const frag = doc.getXmlFragment("content")
|
||||
|
||||
const found = findByUid(frag, op.block_uid)
|
||||
if (!found) {
|
||||
throw new Error(`block_uid not found: ${op.block_uid}`)
|
||||
}
|
||||
|
||||
const reference = findByUid(frag, op.reference_uid)
|
||||
if (!reference) {
|
||||
throw new Error(`reference_uid not found: ${op.reference_uid}`)
|
||||
}
|
||||
const found = blockByUid(doc, op.block_uid, "block_uid")
|
||||
const reference = blockByUid(doc, op.reference_uid, "reference_uid")
|
||||
|
||||
// a reference nested inside the moved block is destroyed by the
|
||||
// removal below, leaving the move nowhere to land.
|
||||
if (isInside(reference.element, found.element)) {
|
||||
if (Y.isParentOf(found.element, reference.element._item)) {
|
||||
throw new Error(
|
||||
`reference_uid is inside the moved block: ${op.reference_uid}`,
|
||||
)
|
||||
|
|
@ -573,7 +356,7 @@ function opMove(doc: Y.Doc, op: MoveOp): void {
|
|||
|
||||
// removing the block shifts its later siblings down by one, so a
|
||||
// reference behind it in the same parent is re-indexed before the
|
||||
// removal invalidates the index findByUid reported.
|
||||
// removal invalidates the index blockByUid reported.
|
||||
let insertAt = reference.index
|
||||
if (reference.parent === found.parent && found.index < insertAt) {
|
||||
insertAt -= 1
|
||||
|
|
@ -585,6 +368,12 @@ function opMove(doc: Y.Doc, op: MoveOp): void {
|
|||
|
||||
found.parent.delete(found.index, 1)
|
||||
reference.parent.insert(insertAt, [clone])
|
||||
|
||||
if (reference.parent !== found.parent) {
|
||||
checkChildrenOf(found.parent)
|
||||
}
|
||||
|
||||
checkChildrenOf(reference.parent, PLACE_ADVICE)
|
||||
}
|
||||
|
||||
function opSetName(doc: Y.Doc, op: SetNameOp): void {
|
||||
|
|
@ -613,152 +402,68 @@ function opSetIcon(doc: Y.Doc, op: SetIconOp): void {
|
|||
|
||||
/* ---------------- helpers ---------------- */
|
||||
|
||||
/**
|
||||
* Locates the first descendant XmlElement whose uid attribute
|
||||
* matches the target, returning the element along with its
|
||||
* immediate parent fragment and index in that parent. Used by
|
||||
* operations that need to mutate a block in place or insert
|
||||
* adjacent to it.
|
||||
*/
|
||||
export function findByUid(
|
||||
fragment: Y.XmlFragment,
|
||||
uid: string,
|
||||
): {
|
||||
parent: Y.XmlFragment | Y.XmlElement
|
||||
index: number
|
||||
element: Y.XmlElement
|
||||
} | null {
|
||||
for (let i = 0; i < fragment.length; i++) {
|
||||
const child = fragment.get(i)
|
||||
if (!(child instanceof Y.XmlElement)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.getAttribute(UID_ATTR) === uid) {
|
||||
return { parent: fragment, index: i, element: child }
|
||||
}
|
||||
|
||||
const inside = findByUid(child, uid)
|
||||
if (inside) {
|
||||
return inside
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether el sits anywhere inside ancestor's subtree. Used by
|
||||
* opMove to refuse a reference the removal of the moved block would
|
||||
* destroy.
|
||||
*/
|
||||
function isInside(el: Y.XmlElement, ancestor: Y.XmlElement): boolean {
|
||||
let parent = el.parent
|
||||
|
||||
while (parent !== null) {
|
||||
if (parent === ancestor) {
|
||||
return true
|
||||
}
|
||||
|
||||
parent = parent.parent
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether el is a paragraph node with no visible inline
|
||||
* content. Used by opAppend to detect TipTap's trailing-paragraph
|
||||
* affordance so the new block is inserted before it instead of
|
||||
* after.
|
||||
*/
|
||||
function isEmptyParagraph(el: Y.XmlElement): boolean {
|
||||
if (el.nodeName !== "paragraph") {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < el.length; i++) {
|
||||
const child = el.get(i)
|
||||
if (child instanceof Y.XmlText && child.length > 0) {
|
||||
return false
|
||||
}
|
||||
if (child instanceof Y.XmlElement) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a single ProseMirror block (as JSON) into a Y.XmlElement
|
||||
* detached from any document so the caller can insert it into a
|
||||
* live fragment. Uses the existing TiptapTransformer for the
|
||||
* schema-aware conversion and clones the result so we don't leak
|
||||
* the temporary Y.Doc.
|
||||
*/
|
||||
export function pmBlockToY(block: PMNode): Y.XmlElement {
|
||||
const tempDoc = transformer.toYdoc(
|
||||
{ type: "doc", content: [block] },
|
||||
"content",
|
||||
)
|
||||
|
||||
const frag = tempDoc.getXmlFragment("content")
|
||||
const first = frag.get(0)
|
||||
if (!(first instanceof Y.XmlElement)) {
|
||||
throw new Error(
|
||||
"pmBlockToY: transformer produced no XmlElement",
|
||||
// blockByUid returns the block carrying uid, with its parent and its
|
||||
// index there. field names the op field that held uid, for the error.
|
||||
function blockByUid(doc: Y.Doc, uid: string, field: string) {
|
||||
const [element] = doc
|
||||
.getXmlFragment("content")
|
||||
.createTreeWalker(
|
||||
(node) =>
|
||||
node instanceof Y.XmlElement &&
|
||||
node.getAttribute(UID_ATTR) === uid,
|
||||
)
|
||||
|
||||
if (!(element instanceof Y.XmlElement)) {
|
||||
throw new Error(`${field} not found: ${uid}`)
|
||||
}
|
||||
|
||||
return cloneXmlElement(first)
|
||||
// the walker only yields nodes inside the fragment.
|
||||
const parent = element.parent as Y.XmlFragment
|
||||
return { parent, index: parent.toArray().indexOf(element), element }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Y.XmlText carrying the supplied ProseMirror inline
|
||||
* content (text nodes with optional marks). Returns null when the
|
||||
* content is empty so callers can skip the insert.
|
||||
*/
|
||||
function buildInlineText(content: PMInline[]): Y.XmlText | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- operations arrive as unvalidated JSON, so the declared type is a claim about the caller rather than a guarantee about the value
|
||||
if (!content || content.length === 0) {
|
||||
return null
|
||||
// insertBlock inserts block into parent at index, after checking the
|
||||
// block and then the parent's children against the schema. advice is
|
||||
// passed to checkChildren.
|
||||
function insertBlock(
|
||||
parent: Y.XmlFragment,
|
||||
index: number,
|
||||
block: PMNode,
|
||||
advice = "",
|
||||
): void {
|
||||
checkBlock(block)
|
||||
|
||||
const first = transformer
|
||||
.toYdoc({ type: "doc", content: [block] }, "content")
|
||||
.getXmlFragment("content")
|
||||
.get(0)
|
||||
|
||||
// a bare text node passes checkBlock but becomes a Y.XmlText.
|
||||
if (!(first instanceof Y.XmlElement)) {
|
||||
throw new Error(`${block.type} is not a block`)
|
||||
}
|
||||
|
||||
const text = new Y.XmlText()
|
||||
let cursor = 0
|
||||
|
||||
for (const node of content) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- same as above: the node kind is whatever the JSON payload carried, not what the type says
|
||||
if (node.type !== "text" || !node.text) {
|
||||
continue
|
||||
}
|
||||
|
||||
const attrs = marksToAttrs(node.marks)
|
||||
text.insert(cursor, node.text, attrs)
|
||||
cursor += node.text.length
|
||||
}
|
||||
|
||||
if (cursor === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return text
|
||||
// the element belongs to the transformer's own Y.Doc.
|
||||
parent.insert(index, [cloneXmlElement(first)])
|
||||
checkChildrenOf(parent, advice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a ProseMirror mark array into the format-attribute
|
||||
* shape Y.XmlText expects: { [markType]: markAttrs }. Unmarked text
|
||||
* gets an empty object rather than nothing, since Y.Text.insert without
|
||||
* attributes carries over the formatting of the text before it.
|
||||
*/
|
||||
function marksToAttrs(marks: PMMark[] | undefined): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const mark of marks ?? []) {
|
||||
// an object even without attrs, as y-prosemirror writes it.
|
||||
// Core cannot decode `true`, so the document would not persist.
|
||||
out[mark.type] = mark.attrs ?? {}
|
||||
}
|
||||
|
||||
return out
|
||||
// checkChildrenOf refuses parent holding children the schema does not
|
||||
// accept. advice is passed to checkChildren.
|
||||
function checkChildrenOf(
|
||||
parent: Y.XmlFragment | Y.XmlElement,
|
||||
advice = "",
|
||||
): void {
|
||||
// the content fragment is the document root.
|
||||
checkChildren(
|
||||
parent instanceof Y.XmlElement ? parent.nodeName : "doc",
|
||||
parent
|
||||
.toArray()
|
||||
.flatMap((child) =>
|
||||
child instanceof Y.XmlElement
|
||||
? [child.nodeName]
|
||||
: [],
|
||||
),
|
||||
advice,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1554,10 +1554,7 @@ describe("createRoutes", () => {
|
|||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
applied: 1,
|
||||
errors: [],
|
||||
})
|
||||
expect(await res.json()).toEqual({ errors: [] })
|
||||
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||
"doc1-b1",
|
||||
{},
|
||||
|
|
@ -1763,10 +1760,8 @@ describe("createRoutes", () => {
|
|||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
applied: number
|
||||
errors: { index: number }[]
|
||||
}
|
||||
expect(body.applied).toBe(0)
|
||||
expect(body.errors).toHaveLength(1)
|
||||
expect(body.errors[0]?.index).toBe(0)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ import { bootstrapAdmin, type DefaultAdmin } from "./bootstrap.js"
|
|||
import { toAxiosHeaders } from "./headers.js"
|
||||
import { bestEffort } from "./reporting.js"
|
||||
import { replaceYdocContent, systemOrigin } from "./ydocument.js"
|
||||
import { applyOperations, type Operation } from "./operations.js"
|
||||
import {
|
||||
applyOperations,
|
||||
type ApplyResult,
|
||||
type Operation,
|
||||
} from "./operations.js"
|
||||
|
||||
// the two things the routes ask of better-auth: the request handler
|
||||
// mounted under /auth/**, and the local JWKS the MCP session endpoint
|
||||
|
|
@ -769,16 +773,7 @@ export function createRoutes({
|
|||
}
|
||||
|
||||
try {
|
||||
let result: {
|
||||
applied: number
|
||||
errors: {
|
||||
index: number
|
||||
message: string
|
||||
}[]
|
||||
} = {
|
||||
applied: 0,
|
||||
errors: [],
|
||||
}
|
||||
let result: ApplyResult = { errors: [] }
|
||||
|
||||
await connection.transact((doc) => {
|
||||
result = applyOperations(doc, ops)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export const CodeBlockTitle = Node.create({
|
|||
name: "codeBlockTitle",
|
||||
content: "text*",
|
||||
marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`,
|
||||
group: "block",
|
||||
group: "codeBlockTitle",
|
||||
selectable: true,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
|
@ -93,7 +93,7 @@ export const CodeBlock = CodeBlockLowlight.extend<CodeBlockOptions>({
|
|||
|
||||
export const TitledCodeBlock = Node.create({
|
||||
name: "titledCodeBlock",
|
||||
group: "block",
|
||||
group: "titledCodeBlock",
|
||||
content: `${CodeBlockTitle.name} ${CodeBlock.name}`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { ImageBlock } from "./image.js"
|
|||
import { FileBlock } from "./file.js"
|
||||
import { MermaidBlock } from "./mermaid.js"
|
||||
import { MetricBlock, MetricGrid } from "./metric.js"
|
||||
import { transformer } from "../ydocument.js"
|
||||
import { schema, transformer } from "../ydocument.js"
|
||||
|
||||
interface PMNode {
|
||||
type: string
|
||||
|
|
@ -163,6 +163,31 @@ describe("getEditorExtensions", () => {
|
|||
|
||||
expect(types.filter((t) => !names.has(t))).toEqual([])
|
||||
})
|
||||
|
||||
// the editor hangs block comments off nodeCommentId, so a write
|
||||
// through this schema has to carry it
|
||||
it("keeps a block's comment through a round trip", ({ expect }) => {
|
||||
const json = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
uid: "p",
|
||||
nodeCommentId: "c1",
|
||||
},
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const back = transformer.fromYdoc(
|
||||
transformer.toYdoc(json, "content"),
|
||||
"content",
|
||||
) as { content: PMNode[] }
|
||||
|
||||
expect(back.content[0]?.attrs?.nodeCommentId).toBe("c1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("editor schema", () => {
|
||||
|
|
@ -623,6 +648,88 @@ function attributesOf(node: {
|
|||
return specOf(node).addAttributes?.call({ options: {} }) ?? {}
|
||||
}
|
||||
|
||||
describe("editor schema placement", () => {
|
||||
// the web editor gives every node that lives only inside a macro a
|
||||
// group of its own, so the document root refuses it. Edits are
|
||||
// checked against this schema, so it has to refuse the same.
|
||||
it.for([
|
||||
{ name: "metricBlock", input: "metricBlock", expected: false },
|
||||
{
|
||||
name: "titledCodeBlock",
|
||||
input: "titledCodeBlock",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "codeBlockTitle",
|
||||
input: "codeBlockTitle",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "splitDocumentationLeftSide",
|
||||
input: "splitDocumentationLeftSide",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "splitDocumentationRightSide",
|
||||
input: "splitDocumentationRightSide",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "splitDocumentationParameterList",
|
||||
input: "splitDocumentationParameterList",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "splitDocumentationParameterListHeader",
|
||||
input: "splitDocumentationParameterListHeader",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "splitDocumentationParameterListItem",
|
||||
input: "splitDocumentationParameterListItem",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "splitDocumentationParameterListItemHeader",
|
||||
input: "splitDocumentationParameterListItemHeader",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "splitDocumentationParameterListItemHeaderTitle",
|
||||
input: "splitDocumentationParameterListItemHeaderTitle",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "splitDocumentationParameterListItemHeaderType",
|
||||
input: "splitDocumentationParameterListItemHeaderType",
|
||||
expected: false,
|
||||
},
|
||||
{ name: "paragraph", input: "paragraph", expected: true },
|
||||
{ name: "metricGrid", input: "metricGrid", expected: true },
|
||||
{
|
||||
name: "splitDocumentation",
|
||||
input: "splitDocumentation",
|
||||
expected: true,
|
||||
},
|
||||
{ name: "mermaidBlock", input: "mermaidBlock", expected: true },
|
||||
])(
|
||||
"decides whether $name sits at the document root",
|
||||
({ input, expected }, { expect }) => {
|
||||
const type = schema.nodes[input]
|
||||
|
||||
expect(type).toBeDefined()
|
||||
expect(
|
||||
Boolean(
|
||||
type &&
|
||||
schema.topNodeType.contentMatch.matchType(
|
||||
type,
|
||||
),
|
||||
),
|
||||
).toBe(expected)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("schema html contract", () => {
|
||||
it.for([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
|
||||
// Custom marks
|
||||
import { AddedMark, DeletedMark, CommentMark } from "./marks.js"
|
||||
import { NodeComment } from "./node-comment.js"
|
||||
|
||||
const contentExtensionsWithIDs: Extensions = [
|
||||
Paragraph,
|
||||
|
|
@ -124,6 +125,9 @@ export function getEditorExtensions(): Extensions {
|
|||
// returning any, which propagates to the arrow.
|
||||
generateID: (): string => nanoid(),
|
||||
}),
|
||||
NodeComment.configure({
|
||||
types: contentExtensionsWithIDs.map((v) => v.name),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const MetricGrid = Node.create({
|
|||
|
||||
export const MetricBlock = Node.create({
|
||||
name: "metricBlock",
|
||||
group: "block",
|
||||
group: "metricBlock",
|
||||
atom: true,
|
||||
defining: true,
|
||||
selectable: false,
|
||||
|
|
|
|||
20
server/auth-realtime/src/schema/node-comment.ts
Normal file
20
server/auth-realtime/src/schema/node-comment.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Extension } from "@tiptap/core"
|
||||
|
||||
// the editor hangs block comments off this attribute. Without it here,
|
||||
// every block this service writes would lose its comment.
|
||||
export const NodeComment = Extension.create<{ types: string[] }>({
|
||||
name: "nodeComment",
|
||||
addOptions() {
|
||||
return { types: [] }
|
||||
},
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
nodeCommentId: { default: null },
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
|
|
@ -4,7 +4,7 @@ import { CommentMark, AddedMark, DeletedMark } from "./marks.js"
|
|||
|
||||
export const ParameterListItemHeaderType = Node.create({
|
||||
name: "splitDocumentationParameterListItemHeaderType",
|
||||
group: "block",
|
||||
group: "splitDocumentationParameterListItemHeaderType",
|
||||
content: `text*`,
|
||||
marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`,
|
||||
defining: true,
|
||||
|
|
@ -31,7 +31,7 @@ export const ParameterListItemHeaderType = Node.create({
|
|||
|
||||
export const ParameterListItemHeaderTitle = Node.create({
|
||||
name: "splitDocumentationParameterListItemHeaderTitle",
|
||||
group: "block",
|
||||
group: "splitDocumentationParameterListItemHeaderTitle",
|
||||
content: `text*`,
|
||||
marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`,
|
||||
defining: true,
|
||||
|
|
@ -58,7 +58,7 @@ export const ParameterListItemHeaderTitle = Node.create({
|
|||
|
||||
export const ParameterListItemHeader = Node.create({
|
||||
name: "splitDocumentationParameterListItemHeader",
|
||||
group: "block",
|
||||
group: "splitDocumentationParameterListItemHeader",
|
||||
content: `${ParameterListItemHeaderTitle.name} ${ParameterListItemHeaderType.name}`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
|
@ -84,7 +84,7 @@ export const ParameterListItemHeader = Node.create({
|
|||
|
||||
export const ParameterListItem = Node.create({
|
||||
name: "splitDocumentationParameterListItem",
|
||||
group: "block",
|
||||
group: "splitDocumentationParameterListItem",
|
||||
content: `${ParameterListItemHeader.name} ${Paragraph.name}`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
|
@ -111,7 +111,7 @@ export const ParameterListItem = Node.create({
|
|||
export const ParameterListHeader = Node.create({
|
||||
name: "splitDocumentationParameterListHeader",
|
||||
content: `text*`,
|
||||
group: "block",
|
||||
group: "splitDocumentationParameterListHeader",
|
||||
marks: `${CommentMark.name} ${AddedMark.name} ${DeletedMark.name}`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
|
@ -137,7 +137,7 @@ export const ParameterListHeader = Node.create({
|
|||
|
||||
export const ParameterList = Node.create({
|
||||
name: "splitDocumentationParameterList",
|
||||
group: "block",
|
||||
group: "splitDocumentationParameterList",
|
||||
content: `${ParameterListHeader.name} ${ParameterListItem.name}+`,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const extraLeftSideContent = [ParameterList.name]
|
|||
|
||||
export const SplitDocumentationLeftSide = Node.create({
|
||||
name: "splitDocumentationLeftSide",
|
||||
group: "block",
|
||||
group: "splitDocumentationLeftSide",
|
||||
isolating: false,
|
||||
defining: true,
|
||||
selectable: false,
|
||||
|
|
@ -45,7 +45,7 @@ export const SplitDocumentationLeftSide = Node.create({
|
|||
|
||||
export const SplitDocumentationRightSide = Node.create({
|
||||
name: "splitDocumentationRightSide",
|
||||
group: "block",
|
||||
group: "splitDocumentationRightSide",
|
||||
isolating: false,
|
||||
defining: true,
|
||||
selectable: false,
|
||||
|
|
|
|||
217
server/auth-realtime/src/validate.test.ts
Normal file
217
server/auth-realtime/src/validate.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { describe, it } from "vitest"
|
||||
import { checkBlock, checkChildren } from "./validate.js"
|
||||
import type { PMNode } from "./operations.js"
|
||||
|
||||
function paragraph(text: string): PMNode {
|
||||
return { type: "paragraph", content: [{ type: "text", text }] }
|
||||
}
|
||||
|
||||
function titledCode(): PMNode {
|
||||
return {
|
||||
type: "titledCodeBlock",
|
||||
content: [
|
||||
{
|
||||
type: "codeBlockTitle",
|
||||
content: [{ type: "text", text: "GET /x" }],
|
||||
},
|
||||
{
|
||||
type: "codeBlock",
|
||||
content: [{ type: "text", text: "x" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe("checkBlock", () => {
|
||||
it("accepts a split documentation block of both sides", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(() => {
|
||||
checkBlock({
|
||||
type: "splitDocumentation",
|
||||
content: [
|
||||
{
|
||||
type: "splitDocumentationLeftSide",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: {
|
||||
level: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "API",
|
||||
},
|
||||
],
|
||||
},
|
||||
paragraph(
|
||||
"Does a thing",
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "splitDocumentationRightSide",
|
||||
content: [titledCode()],
|
||||
},
|
||||
],
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("refuses an unknown block type", ({ expect }) => {
|
||||
expect(() => {
|
||||
checkBlock({ type: "wibble" })
|
||||
}).toThrow("wibble is not a known block type")
|
||||
})
|
||||
|
||||
it("refuses a nested block its container does not take", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(() => {
|
||||
checkBlock({
|
||||
type: "calloutBlock",
|
||||
content: [paragraph("a"), titledCode()],
|
||||
})
|
||||
}).toThrow(
|
||||
"titledCodeBlock is not allowed there in calloutBlock",
|
||||
)
|
||||
})
|
||||
|
||||
it("refuses a mark the text's node does not allow", ({ expect }) => {
|
||||
expect(() => {
|
||||
checkBlock({
|
||||
type: "codeBlock",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "x",
|
||||
marks: [{ type: "bold" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}).toThrow(
|
||||
"the bold mark is not allowed in codeBlock: write the text without it",
|
||||
)
|
||||
})
|
||||
|
||||
it("accepts marks the text's node allows", ({ expect }) => {
|
||||
expect(() => {
|
||||
checkBlock({
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "x",
|
||||
marks: [
|
||||
{ type: "bold" },
|
||||
{ type: "italic" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("refuses a mark the schema does not know", ({ expect }) => {
|
||||
expect(() => {
|
||||
checkBlock({
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "x",
|
||||
marks: [{ type: "sparkle" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}).toThrow("the sparkle mark is not allowed in paragraph")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkChildren", () => {
|
||||
it("accepts children the parent takes in that order", ({ expect }) => {
|
||||
expect(() => {
|
||||
checkChildren("doc", [
|
||||
"paragraph",
|
||||
"metricGrid",
|
||||
"heading",
|
||||
])
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("refuses a block at the document root that belongs in a container", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(() => {
|
||||
checkChildren("doc", ["paragraph", "metricBlock"])
|
||||
}).toThrow(
|
||||
/^metricBlock is not allowed there in the document root, which takes .+ or another block at that point\.$/,
|
||||
)
|
||||
})
|
||||
|
||||
it("ends the refusal with the caller's advice", ({ expect }) => {
|
||||
expect(() => {
|
||||
checkChildren("doc", ["metricBlock"], "Try elsewhere.")
|
||||
}).toThrow(/at that point\. Try elsewhere\.$/)
|
||||
})
|
||||
|
||||
it("refuses a block out of the order its parent takes", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(() => {
|
||||
checkChildren("splitDocumentationLeftSide", [
|
||||
"heading",
|
||||
"paragraph",
|
||||
"splitDocumentationParameterList",
|
||||
"paragraph",
|
||||
])
|
||||
}).toThrow(
|
||||
"paragraph is not allowed there in splitDocumentationLeftSide, which takes splitDocumentationParameterList at that point",
|
||||
)
|
||||
})
|
||||
|
||||
it("refuses a parent left without the blocks it needs", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(() => {
|
||||
checkChildren("splitDocumentationRightSide", [])
|
||||
}).toThrow(
|
||||
"splitDocumentationRightSide would end without titledCodeBlock, metricBlock, or mermaidBlock. Add one there first, or delete the block that holds it.",
|
||||
)
|
||||
})
|
||||
|
||||
it("refuses emptying the document root without naming a holder", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(() => {
|
||||
checkChildren("doc", [])
|
||||
}).toThrow(
|
||||
/^the document root would end without .+\. Add one there first\.$/,
|
||||
)
|
||||
})
|
||||
|
||||
it("refuses a block after a parent's content is complete", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(() => {
|
||||
checkChildren("titledCodeBlock", [
|
||||
"codeBlockTitle",
|
||||
"codeBlock",
|
||||
"codeBlock",
|
||||
])
|
||||
}).toThrow("which takes nothing more at that point")
|
||||
})
|
||||
|
||||
it("refuses an unknown child type", ({ expect }) => {
|
||||
expect(() => {
|
||||
checkChildren("doc", ["wibble"])
|
||||
}).toThrow("wibble is not allowed there in the document root")
|
||||
})
|
||||
|
||||
it("refuses an unknown parent type", ({ expect }) => {
|
||||
expect(() => {
|
||||
checkChildren("wibble", [])
|
||||
}).toThrow("wibble is not a known block type")
|
||||
})
|
||||
})
|
||||
113
server/auth-realtime/src/validate.ts
Normal file
113
server/auth-realtime/src/validate.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Checks edits against the editor's ProseMirror schema before they are
|
||||
// applied. The schema is the one source of what may sit where, so core
|
||||
// sends blocks without checking their placement, and a refusal here
|
||||
// leaves the document untouched.
|
||||
import { schema } from "./ydocument.js"
|
||||
import type { PMNode } from "./operations.js"
|
||||
|
||||
type ContentMatch = (typeof schema.topNodeType)["contentMatch"]
|
||||
|
||||
// how many node types a message lists before it stops.
|
||||
const LISTED_TYPES = 5
|
||||
|
||||
// checkBlock refuses a block whose own content the schema does not
|
||||
// accept: an unknown node type, children of the wrong kind or order, or
|
||||
// a mark its node does not allow.
|
||||
export function checkBlock(block: PMNode): void {
|
||||
const content = block.content ?? []
|
||||
|
||||
checkChildren(
|
||||
block.type,
|
||||
content.map((child) => child.type),
|
||||
)
|
||||
|
||||
for (const child of content) {
|
||||
if (child.type !== "text") {
|
||||
checkBlock(child)
|
||||
continue
|
||||
}
|
||||
|
||||
// the editor cannot load a mark its node does not allow, so the
|
||||
// edit would be lost while reporting success.
|
||||
for (const mark of child.marks ?? []) {
|
||||
const markType = schema.marks[mark.type]
|
||||
|
||||
if (
|
||||
!markType ||
|
||||
!schema.nodes[block.type]?.allowsMarkType(
|
||||
markType,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`the ${mark.type} mark is not allowed in ` +
|
||||
`${block.type}: write the text without it`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkChildren refuses a parent holding the named children, in that
|
||||
// order, when the schema does not accept them. parent is a node type
|
||||
// name, "doc" for the document root. advice ends the message when a
|
||||
// child does not fit, for a caller that knows how to place it instead.
|
||||
export function checkChildren(
|
||||
parent: string,
|
||||
children: string[],
|
||||
advice = "",
|
||||
): void {
|
||||
const parentType = schema.nodes[parent]
|
||||
if (!parentType) {
|
||||
throw new Error(`${parent} is not a known block type`)
|
||||
}
|
||||
|
||||
const isRoot = parent === schema.topNodeType.name
|
||||
const place = isRoot ? "the document root" : parent
|
||||
let match = parentType.contentMatch
|
||||
|
||||
for (const child of children) {
|
||||
const childType = schema.nodes[child]
|
||||
const next = childType ? match.matchType(childType) : null
|
||||
|
||||
if (!next) {
|
||||
const message =
|
||||
`${child} is not allowed there in ${place}, ` +
|
||||
`which takes ${acceptedTypes(match)} at that point.`
|
||||
|
||||
throw new Error(
|
||||
advice ? `${message} ${advice}` : message,
|
||||
)
|
||||
}
|
||||
|
||||
match = next
|
||||
}
|
||||
|
||||
if (!match.validEnd) {
|
||||
const next = isRoot
|
||||
? "Add one there first."
|
||||
: "Add one there first, or delete the block that holds it."
|
||||
|
||||
throw new Error(
|
||||
`${place} would end without ${acceptedTypes(match)}. ${next}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// acceptedTypes lists the node types match takes next.
|
||||
function acceptedTypes(match: ContentMatch): string {
|
||||
const names: string[] = []
|
||||
|
||||
for (let i = 0; i < match.edgeCount; i++) {
|
||||
names.push(match.edge(i).type.name)
|
||||
}
|
||||
|
||||
if (names.length === 0) {
|
||||
return "nothing more"
|
||||
}
|
||||
|
||||
if (names.length > LISTED_TYPES) {
|
||||
names.splice(LISTED_TYPES, Infinity, "another block")
|
||||
}
|
||||
|
||||
return new Intl.ListFormat("en", { type: "disjunction" }).format(names)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import * as Y from "yjs"
|
||||
import { TiptapTransformer } from "@hocuspocus/transformer"
|
||||
import { ProsemirrorTransformer } from "@hocuspocus/transformer"
|
||||
import { getSchema } from "@tiptap/core"
|
||||
import { getEditorExtensions } from "./schema/index.js"
|
||||
|
||||
|
|
@ -9,13 +9,8 @@ export interface DocumentData {
|
|||
icon: string
|
||||
}
|
||||
|
||||
// Create a TiptapTransformer instance with the web editor schema
|
||||
const extensions = getEditorExtensions()
|
||||
const transformer = TiptapTransformer.extensions(extensions)
|
||||
|
||||
// the same schema as a ProseMirror schema, for checks the transformer
|
||||
// does not make.
|
||||
const schema = getSchema(extensions)
|
||||
const schema = getSchema(getEditorExtensions())
|
||||
const transformer = ProsemirrorTransformer.schema(schema)
|
||||
|
||||
/**
|
||||
* Deep-clones a Y.XmlElement, preserving all attribute types (including arrays
|
||||
|
|
@ -24,17 +19,6 @@ const schema = getSchema(extensions)
|
|||
*/
|
||||
export function cloneXmlElement(source: Y.XmlElement): Y.XmlElement {
|
||||
const el = new Y.XmlElement(source.nodeName)
|
||||
// Y.XmlElement.setAttribute is declared as accepting `string`,
|
||||
// but the runtime stores arbitrary values which we rely on for
|
||||
// complex attributes (e.g. the queries array on metricBlock).
|
||||
// Cast through unknown to bypass the type declaration without
|
||||
// dropping non-string values.
|
||||
const setAttr = (
|
||||
el as unknown as {
|
||||
setAttribute(key: string, value: unknown): void
|
||||
}
|
||||
).setAttribute.bind(el)
|
||||
|
||||
const attrs = source.getAttributes()
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (value === undefined) {
|
||||
|
|
@ -43,8 +27,13 @@ export function cloneXmlElement(source: Y.XmlElement): Y.XmlElement {
|
|||
continue
|
||||
}
|
||||
|
||||
setAttr(key, value)
|
||||
// the default attribute type is string, but attrs hold any JSON.
|
||||
;(el as Y.XmlElement<Record<string, any>>).setAttribute(
|
||||
key,
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
const children = source.toArray()
|
||||
if (children.length > 0) {
|
||||
el.insert(
|
||||
|
|
@ -66,16 +55,18 @@ export function cloneXmlElement(source: Y.XmlElement): Y.XmlElement {
|
|||
return el
|
||||
}
|
||||
|
||||
function cloneXmlFragment(source: Y.XmlFragment, target: Y.XmlFragment): void {
|
||||
// cloneXmlFragment replaces target's children with deep clones of
|
||||
// source's.
|
||||
export function cloneXmlFragment(
|
||||
source: Y.XmlFragment,
|
||||
target: Y.XmlFragment,
|
||||
): void {
|
||||
target.delete(0, target.length)
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const item = source.get(i)
|
||||
if (item instanceof Y.XmlElement) {
|
||||
target.insert(i, [cloneXmlElement(item)])
|
||||
} else if (item instanceof Y.XmlText) {
|
||||
// NOCOV: both fragments this runs over come from the tiptap
|
||||
// transformer, whose schema only ever puts block elements at
|
||||
// the top level — bare text there is unreachable today.
|
||||
const newText = new Y.XmlText()
|
||||
newText.applyDelta(item.toDelta() as any[])
|
||||
target.insert(i, [newText])
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
|
||||
|
|
@ -23,57 +22,14 @@ var (
|
|||
ErrNotFound = errutil.New(http.StatusNotFound, "registry.not_found", "container image not found")
|
||||
)
|
||||
|
||||
// digestOptions holds options for configuring the Digest function.
|
||||
type digestOptions struct {
|
||||
username string
|
||||
password string
|
||||
token string
|
||||
}
|
||||
|
||||
// DigestOption is an option for configuring the Digest function.
|
||||
type DigestOption func(*digestOptions)
|
||||
|
||||
// Digest retrieves the digest of the specified container image.
|
||||
func Digest(
|
||||
ctx context.Context,
|
||||
image string,
|
||||
digOpts ...DigestOption,
|
||||
) (string, error) {
|
||||
func Digest(ctx context.Context, image string) (string, error) {
|
||||
ref, err := parseReference(image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
remoteOpts := []remote.Option{
|
||||
remote.WithContext(ctx),
|
||||
}
|
||||
|
||||
baseOpts := &digestOptions{}
|
||||
|
||||
for _, optFn := range digOpts {
|
||||
optFn(baseOpts)
|
||||
}
|
||||
|
||||
if baseOpts.username != "" && baseOpts.password != "" {
|
||||
remoteOpts = append(remoteOpts,
|
||||
remote.WithAuth(
|
||||
&authn.Basic{
|
||||
Username: baseOpts.username,
|
||||
Password: baseOpts.password,
|
||||
},
|
||||
),
|
||||
)
|
||||
} else if baseOpts.token != "" {
|
||||
remoteOpts = append(remoteOpts,
|
||||
remote.WithAuth(
|
||||
&authn.Bearer{
|
||||
Token: baseOpts.token,
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
desc, err := remote.Head(ref, remoteOpts...)
|
||||
desc, err := remote.Head(ref, remote.WithContext(ctx))
|
||||
if err != nil {
|
||||
if terr, ok := errors.AsType[*transport.Error](err); ok {
|
||||
switch {
|
||||
|
|
@ -103,21 +59,6 @@ func ValidateReference(image string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// WithBasicAuth sets the username and password for basic authentication.
|
||||
func WithBasicAuth(username, password string) DigestOption {
|
||||
return func(o *digestOptions) {
|
||||
o.username = username
|
||||
o.password = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithBearerToken sets the token for bearer authentication.
|
||||
func WithBearerToken(token string) DigestOption {
|
||||
return func(o *digestOptions) {
|
||||
o.token = token
|
||||
}
|
||||
}
|
||||
|
||||
// parseReference parses an image reference, defaulting to Docker Hub and the
|
||||
// latest tag.
|
||||
func parseReference(image string) (name.Reference, error) {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ func Test_Digest(t *testing.T) {
|
|||
Authorization string
|
||||
Reference string
|
||||
Path string
|
||||
Opts []DigestOption
|
||||
ExpectedErr string
|
||||
ErrContains string
|
||||
}{
|
||||
|
|
@ -98,28 +97,10 @@ func Test_Digest(t *testing.T) {
|
|||
Path: "/test/other:v1",
|
||||
ExpectedErr: ErrNotFound.Error(),
|
||||
},
|
||||
"Basic auth credentials are attached": {
|
||||
Authorization: basicAuthorization,
|
||||
Opts: []DigestOption{WithBasicAuth("user", "pass")},
|
||||
},
|
||||
"Bearer token is attached": {
|
||||
Authorization: "Bearer tok-1",
|
||||
Opts: []DigestOption{WithBearerToken("tok-1")},
|
||||
},
|
||||
"Missing credentials map to ErrUnauthorized": {
|
||||
Authorization: basicAuthorization,
|
||||
ExpectedErr: ErrUnauthorized.Error(),
|
||||
},
|
||||
"Wrong credentials map to ErrUnauthorized": {
|
||||
Authorization: basicAuthorization,
|
||||
Opts: []DigestOption{WithBasicAuth("user", "wrong")},
|
||||
ExpectedErr: ErrUnauthorized.Error(),
|
||||
},
|
||||
"Basic auth without password falls back to anonymous": {
|
||||
Authorization: basicAuthorization,
|
||||
Opts: []DigestOption{WithBasicAuth("user", "")},
|
||||
ExpectedErr: ErrUnauthorized.Error(),
|
||||
},
|
||||
}
|
||||
|
||||
for tname, tc := range tests {
|
||||
|
|
@ -138,7 +119,7 @@ func Test_Digest(t *testing.T) {
|
|||
reference = host + path
|
||||
}
|
||||
|
||||
digest, err := Digest(context.Background(), reference, tc.Opts...)
|
||||
digest, err := Digest(context.Background(), reference)
|
||||
|
||||
if tc.ErrContains != "" {
|
||||
require.Error(t, err)
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ The assistant: system prompt and tools. Go standards live in
|
|||
|
||||
## Prompt
|
||||
|
||||
`prompt.go` assembles the system prompt from section constants. Block model,
|
||||
edit etiquette and aesthetics ship to both the chat model and MCP clients;
|
||||
`prompt.go` assembles the system prompt from section constants. Workflow,
|
||||
content, editing and style ship to both the chat model and MCP clients;
|
||||
surface-specific rules (confirmation flow, persona) live in that surface's
|
||||
section. Behaviour rules come first and are restated at the end.
|
||||
section. `MCPInstructions` stays under 2 KB, the part Claude Code shows.
|
||||
|
||||
Writing a rule: state the principle and its reason in one or two sentences,
|
||||
no edge-case lists or numbered steps; say what to do rather than what to
|
||||
|
|
@ -26,22 +26,20 @@ adapter and `read_tool_output`. `tools.Set` is the only list of tools;
|
|||
adding one means its type plus a line there, and the MCP server picks it up
|
||||
for free.
|
||||
|
||||
- **Tools implement this package's `Tool`** (`Info`, `Traits`, `Title`,
|
||||
`Summary`, `Execute`), never eino's interfaces; only `eino.go` imports
|
||||
eino. Other surfaces use `Entry.Info` to describe and `Entry.Tool.Run` to
|
||||
run.
|
||||
- **A call reports what it changed** in `Result.Documents`; every write goes
|
||||
through an `Input` method that records the document as it mutates (a
|
||||
document delete records nothing, a hook delete records the branch,
|
||||
tag-only writes record none).
|
||||
- **Tag and hook writes notify**: tag tools end with
|
||||
`Input.NotifyTagTreeChange` (`assign_tag`/`unassign_tag` also
|
||||
`NotifyBranchTagsChange`). Hook writes go through `HookManager`, which
|
||||
records history and announces the change, as for the HTTP handler;
|
||||
`decodeHookSettings` switches on type into the processor's own struct.
|
||||
- **A block write reports from the operation, never a re-read** (the
|
||||
realtime service persists on a debounce): `sanitizeBlock` once, ship the
|
||||
canonical uids, return `blockRows` of the expanded tree.
|
||||
- **Tools implement this package's `Tool`** (`Info`, `Execute`), never
|
||||
eino's interfaces; only `eino.go` imports eino. A read may add `Title`
|
||||
for its status line; every write adds `Summary`, which is also its
|
||||
status line. Other surfaces use `Entry.Info` to describe and
|
||||
`Entry.Tool.Run` to run.
|
||||
- **Writes notify on `input`**: the method that changes the tree or a tag
|
||||
announces it, so a tool never does. Hook writes go through
|
||||
`HookManager`, which records history and announces the change, as for
|
||||
the HTTP handler; `decodeHookSettings` switches on type into the
|
||||
processor's own struct.
|
||||
- **Content is markup** (`markup/`): `get_document` renders XML with ids,
|
||||
writes `markup.Build` it against the stored tree (a kept id keeps its
|
||||
uid, unchanged content keeps the stored node) and report from the build,
|
||||
never a re-read.
|
||||
- **Branches are addressed by id, always.** Every content tool requires
|
||||
`branch_id`; listings carry the ids; `FetchDocumentByBranchID` refuses a
|
||||
branch of another document; an unknown id is refused naming the
|
||||
|
|
@ -50,32 +48,33 @@ for free.
|
|||
deletes or merges a branch. MCP resources are
|
||||
`oxynote://documents/{id}/branches/{branch_id}`.
|
||||
- **Every error names the next step**: what was wrong, then the tool or
|
||||
argument that fixes it (`describeOpError` rewrites the realtime service's
|
||||
errors; `errUnknownDocument`, `errUnknownBlock`, `errBranchProtected`,
|
||||
`errUnknownParent` follow suit).
|
||||
argument that fixes it (`edit.Result.Err` rewrites the realtime
|
||||
service's errors; `ErrUnknownDocument`, `errUnknownBlock`,
|
||||
`errBranchProtected`, `errUnknownParent` follow suit).
|
||||
- **`Decode` is the only way into arguments.** Every tool has a `<tool>Args`
|
||||
type with `Validate() error` (`errRequired(key)`); ids are `xid.ID`,
|
||||
timestamps `time.Time`, enums self-validating; `encoding/json/v2` reports
|
||||
the JSON path. An empty string is invalid, never absent.
|
||||
- `Title`/`Summary` fetch what they name and use the row's name; an
|
||||
unresolvable target is an error. `Input` is built per call, scoped to the
|
||||
session's (organisation, user), with `Fetch*`-prefixed reads;
|
||||
unresolvable target is no title, or an error from `Summary`. `*input`
|
||||
is built per call, scoped to the session's (organisation, user), with
|
||||
`Fetch*`-prefixed reads; one call's steps share a branch fetch.
|
||||
`DescribeInput` is its read-only half.
|
||||
- **A write owns its invariants** on the `Input` method that performs it
|
||||
- **A write owns its invariants** on the `input` method that performs it
|
||||
(`MoveDocument` refuses a missing parent or a move under its own subtree),
|
||||
not in `Execute`. Block writes go through `ApplyEdit`, so their per-type
|
||||
rules sit in `sanitizeBlock` and `sanitizeBlockAttrs` (`block.go`).
|
||||
- **`Traits()` says what a tool is**; the mixins
|
||||
`plainSummary`/`plainTraits`/`plainTitle` cover the defaults. `Write`
|
||||
gates behind confirmation and protects the result from context
|
||||
not in `Execute`. Block writes go through `ApplyEdit`. What core cannot
|
||||
check, placement and content order, is auth-realtime's, against the
|
||||
editor schema (`validate.ts`).
|
||||
- **`Info.Traits` says what a tool is**; the zero value is a plain read.
|
||||
`Write` gates behind confirmation and protects the result from context
|
||||
middlewares (only writes have a `Summary`; `Test_New` checks).
|
||||
`Destructive` keeps it outside "approve all" (the four `delete_*`).
|
||||
`Overwrites` means content the caller did not name is replaced
|
||||
(`update_block_text`, `replace_block`). `DataSource` reads an outbound
|
||||
(`replace_blocks`). `DataSource` reads an outbound
|
||||
connection (own MCP scope, open-world). `Internal` keeps it off other
|
||||
surfaces (`read_tool_output`). The registry applies the confirmation gate
|
||||
from traits; MCP serves the registry ungated and maps traits to
|
||||
annotations.
|
||||
from traits; MCP serves the registry ungated and maps traits to scopes
|
||||
and annotations.
|
||||
|
||||
Tool descriptions stand alone (an MCP client may never show the prompt):
|
||||
what it does and returns, when to use it and which sibling instead, the
|
||||
|
|
|
|||
|
|
@ -1,303 +0,0 @@
|
|||
// Package block defines the AI-facing block model that maps to the
|
||||
// ProseMirror document schema used by the editor. The AI authors and
|
||||
// reads documents in this form; the package's Expand function converts
|
||||
// a canonical Block into a document.Block tree (with uids assigned)
|
||||
// that hocuspocus can apply to a Y.Doc, and the Compact function does
|
||||
// the reverse for cheap AI reads.
|
||||
//
|
||||
// The canonical surface intentionally hides nested wrapper nodes that
|
||||
// the TipTap schema requires (list item wrappers, the left/right
|
||||
// sides of splitDocumentation, the four-deep nesting of parameter
|
||||
// list items). Compound types expose their structural children as
|
||||
// typed top-level fields on Block (Items, Left, Right, Params, …)
|
||||
// rather than smuggling them through Attrs — Attrs is reserved for
|
||||
// configuration like a heading's level or a callout's icon.
|
||||
package block
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// Type is the canonical name of a block as the AI sees it.
|
||||
// Canonical names use snake_case to match the rest of the AI tool
|
||||
// surface; they are distinct from the camelCase node names used by
|
||||
// the underlying ProseMirror schema.
|
||||
type Type string
|
||||
|
||||
// Canonical block types. The list mirrors what the editor renders;
|
||||
// any TipTap node type not listed here is either a macro internal
|
||||
// (e.g. splitDocumentationLeftSide) or an inline node not exposed
|
||||
// at the block level (e.g. text nodes).
|
||||
const (
|
||||
// BlockParagraph is a standard paragraph. Carries inline text.
|
||||
BlockParagraph Type = "paragraph"
|
||||
|
||||
// BlockHeading is a heading block. Carries inline text. Required
|
||||
// attr "level" must be 1, 2 or 3.
|
||||
BlockHeading Type = "heading"
|
||||
|
||||
// BlockBlockquote is a quoted paragraph. Carries inline text.
|
||||
BlockBlockquote Type = "blockquote"
|
||||
|
||||
// BlockBulletList is an unordered list. Items hold a Block each
|
||||
// (typically a paragraph).
|
||||
BlockBulletList Type = "bullet_list"
|
||||
|
||||
// BlockOrderedList is an ordered list. Items hold a Block each
|
||||
// (typically a paragraph).
|
||||
BlockOrderedList Type = "ordered_list"
|
||||
|
||||
// BlockTaskList is a task/checklist. TaskItems hold a Block plus
|
||||
// a Checked flag per row.
|
||||
BlockTaskList Type = "task_list"
|
||||
|
||||
// BlockCallout is a callout block. Either Text (shorthand for a
|
||||
// single-paragraph callout) or Items (paragraphs and/or lists)
|
||||
// carry the content. Optional attr "icon" (defaults to
|
||||
// "lucide:text").
|
||||
BlockCallout Type = "callout"
|
||||
|
||||
// BlockCode is a fenced code block. Text holds raw code (no
|
||||
// markdown parsing). Optional attr "language".
|
||||
BlockCode Type = "code"
|
||||
|
||||
// BlockTitledCode is a titled code block used inside the right
|
||||
// side of split_doc. Text holds raw code; attrs "title" (plain
|
||||
// text, required) and "language" (optional) name the block.
|
||||
BlockTitledCode Type = "titled_code"
|
||||
|
||||
// BlockMermaid is a mermaid diagram block. Text holds raw
|
||||
// diagram source (no markdown).
|
||||
BlockMermaid Type = "mermaid"
|
||||
|
||||
// BlockHorizontalRule is a horizontal divider.
|
||||
BlockHorizontalRule Type = "horizontal_rule"
|
||||
|
||||
// BlockImage is an image block. Required attr "src"; optional
|
||||
// "alt", "title", "width".
|
||||
BlockImage Type = "image"
|
||||
|
||||
// BlockFile is a file attachment. Attrs "src", "name",
|
||||
// "contentType" and "size" describe the uploaded file. It is only
|
||||
// ever created by uploading in the editor: the AI reads and moves
|
||||
// one but never writes it.
|
||||
BlockFile Type = "file"
|
||||
|
||||
// BlockFigma is an embedded Figma frame. Required attr "src";
|
||||
// optional "width", "height".
|
||||
BlockFigma Type = "figma"
|
||||
|
||||
// BlockMetric is a metric visualization. Attrs carry the chart's
|
||||
// configuration — the data source, the queries, the window, the
|
||||
// display settings — validated by metric.go; attrs that layer does
|
||||
// not name are preserved verbatim.
|
||||
BlockMetric Type = "metric"
|
||||
|
||||
// BlockMetricGrid wraps one or more metric blocks at the top
|
||||
// level of a document. Items hold metric blocks.
|
||||
BlockMetricGrid Type = "metric_grid"
|
||||
|
||||
// BlockSplitDoc is the split-documentation macro. Left carries
|
||||
// the heading-led prose (heading + paragraphs/lists/callouts +
|
||||
// optional param_lists); Right carries the illustrative blocks
|
||||
// (titled_code, metric). Optional attr "inversed" flips the
|
||||
// visual order.
|
||||
BlockSplitDoc Type = "split_doc"
|
||||
|
||||
// BlockParamList is the parameter-list macro. Header holds the
|
||||
// section header; Params holds the rows.
|
||||
BlockParamList Type = "split_doc_param_list"
|
||||
)
|
||||
|
||||
// Label turns the type into a friendlier label for the confirm UI.
|
||||
// Unknown types fall through verbatim.
|
||||
func (t Type) Label() string {
|
||||
switch t {
|
||||
case BlockParagraph:
|
||||
return "a paragraph"
|
||||
case BlockHeading:
|
||||
return "a heading"
|
||||
case BlockBlockquote:
|
||||
return "a blockquote"
|
||||
case BlockBulletList:
|
||||
return "a bullet list"
|
||||
case BlockOrderedList:
|
||||
return "an ordered list"
|
||||
case BlockTaskList:
|
||||
return "a task list"
|
||||
case BlockCallout:
|
||||
return "a callout"
|
||||
case BlockCode:
|
||||
return "a code block"
|
||||
case BlockTitledCode:
|
||||
return "a titled code block"
|
||||
case BlockMermaid:
|
||||
return "a mermaid diagram"
|
||||
case BlockHorizontalRule:
|
||||
return "a divider"
|
||||
case BlockImage:
|
||||
return "an image"
|
||||
case BlockFile:
|
||||
return "a file attachment"
|
||||
case BlockFigma:
|
||||
return "a figma embed"
|
||||
case BlockMetric:
|
||||
return "a metric"
|
||||
case BlockMetricGrid:
|
||||
return "a metric grid"
|
||||
case BlockSplitDoc:
|
||||
return "a split documentation block"
|
||||
case BlockParamList:
|
||||
return "a parameter list"
|
||||
}
|
||||
|
||||
if t == "" {
|
||||
return "a block"
|
||||
}
|
||||
|
||||
return "a " + string(t) + " block"
|
||||
}
|
||||
|
||||
// Block is the canonical representation of a single block as the AI
|
||||
// reads and writes it. Not every field is meaningful for every block
|
||||
// type:
|
||||
//
|
||||
// - Text — paragraph, heading, blockquote, callout (shorthand),
|
||||
// code, titled_code (the code body), mermaid.
|
||||
// - Attrs — per-type configuration: heading.level, image.src/alt,
|
||||
// callout.icon, code.language, split_doc.inversed,
|
||||
// titled_code.title/language, etc.
|
||||
// - Items — symmetric compound containers: bullet_list,
|
||||
// ordered_list, callout (when not using Text shorthand),
|
||||
// metric_grid.
|
||||
// - TaskItems — task_list.
|
||||
// - Left, Right — split_doc.
|
||||
// - Header, Params — param_list.
|
||||
//
|
||||
// Validate enforces these constraints before Expand runs.
|
||||
type Block struct {
|
||||
// Type is the canonical block kind.
|
||||
Type Type `json:"type"`
|
||||
|
||||
// UID is the block's stable identifier. Optional on input: when
|
||||
// empty, Expand generates a fresh nanoid; when set, Expand
|
||||
// preserves it so the AI can reference the same block across
|
||||
// turns. Compact always populates UID from the underlying
|
||||
// document.Block.
|
||||
UID string `json:"uid,omitempty"`
|
||||
|
||||
// Text is the inline content of text-bearing blocks, expressed
|
||||
// in the canonical minimal-markdown subset (see markdown.go).
|
||||
// For code, titled_code, and mermaid, Text is treated as raw
|
||||
// (no markdown parsing).
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
// Attrs holds per-type configuration attributes. Legal keys and
|
||||
// value types are defined by each Type's Schema entry; Validate
|
||||
// rejects unknown or wrong-typed attrs before Expand runs.
|
||||
Attrs document.Attributes `json:"attrs,omitempty"`
|
||||
|
||||
// Items holds what a container block holds: a list's entries, a
|
||||
// blockquote's or callout's contents, a metric_grid's metrics.
|
||||
Items []Block `json:"items,omitempty"`
|
||||
|
||||
// Children holds the tail of a single list or task-list item. The
|
||||
// editor's item wrapper is a paragraph followed by any number of
|
||||
// further blocks — everything indented under the entry — and Children
|
||||
// is that second half.
|
||||
//
|
||||
// It describes a different relationship than Items: Items is what a
|
||||
// container holds, Children is what hangs under one of its entries.
|
||||
// Both can appear on one block, when an entry's own content is a list.
|
||||
// Every position other than a list item rejects Children.
|
||||
Children []Block `json:"children,omitempty"`
|
||||
|
||||
// TaskItems holds the rows of a task_list. Each row pairs a
|
||||
// content Block with a Checked flag.
|
||||
TaskItems []TaskItem `json:"task_items,omitempty"`
|
||||
|
||||
// Left is the left-side content of a split_doc. The first block
|
||||
// must be a heading; subsequent blocks may be paragraphs, lists,
|
||||
// callouts, or param_lists.
|
||||
Left []Block `json:"left,omitempty"`
|
||||
|
||||
// Right is the right-side content of a split_doc. Each block
|
||||
// must be a titled_code, a metric or a mermaid.
|
||||
Right []Block `json:"right,omitempty"`
|
||||
|
||||
// Header is the section header of a param_list. Plain text (no
|
||||
// markdown).
|
||||
Header string `json:"header,omitempty"`
|
||||
|
||||
// Params is the ordered list of parameter rows in a param_list.
|
||||
Params []ParamItem `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// CollectAttributeValues returns every distinct string value the named
|
||||
// attribute takes in this block and everything under it, in the order
|
||||
// they were found.
|
||||
//
|
||||
// A block's children hang off whichever typed field its kind uses, so a
|
||||
// caller that has to see all of them — checking what a whole subtree
|
||||
// refers to before it is written, say — would otherwise walk five
|
||||
// fields itself.
|
||||
func (b Block) CollectAttributeValues(name string) []string {
|
||||
var out []string
|
||||
|
||||
b.collectAttributeValues(name, &out)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// collectAttributeValues appends this block's value, then those of
|
||||
// everything it contains, skipping values already collected.
|
||||
func (b Block) collectAttributeValues(name string, out *[]string) {
|
||||
if a, ok := b.Attrs.Value(name); ok {
|
||||
if v, isString := a.(string); isString && v != "" && !slices.Contains(*out, v) {
|
||||
*out = append(*out, v)
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range [][]Block{b.Items, b.Children, b.Left, b.Right} {
|
||||
for _, child := range group {
|
||||
child.collectAttributeValues(name, out)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range b.TaskItems {
|
||||
item.Block.collectAttributeValues(name, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TaskItem is one row of a task_list, carrying an explicit checked
|
||||
// state alongside a content Block (typically a paragraph).
|
||||
type TaskItem struct {
|
||||
// UID is the row's stable identifier. Same semantics as
|
||||
// Block.UID.
|
||||
UID string `json:"uid,omitempty"`
|
||||
|
||||
// Checked is whether the task is marked done.
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
|
||||
// Block is the row's content block.
|
||||
Block Block `json:"block"`
|
||||
}
|
||||
|
||||
// ParamItem is one row in a parameter list.
|
||||
type ParamItem struct {
|
||||
// UID is the row's stable identifier. Same semantics as
|
||||
// Block.UID.
|
||||
UID string `json:"uid,omitempty"`
|
||||
|
||||
// Name is the parameter name (e.g. "size_limit"). Plain text.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Type is the parameter type (e.g. "number", "string?", "User").
|
||||
// Plain text — free-form per the language being described.
|
||||
Type string `json:"type,omitempty"`
|
||||
|
||||
// Description is the parameter description. Minimal-markdown.
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Block_CollectAttributeValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metric := func(id string) Block {
|
||||
return Block{
|
||||
Type: BlockMetric,
|
||||
Attrs: document.Attributes{document.AttrDataSourceID: id},
|
||||
}
|
||||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
Input Block
|
||||
Expected []string
|
||||
}{
|
||||
"A block that does not carry the attribute": {
|
||||
Input: Block{Type: BlockParagraph, Text: "hello"},
|
||||
},
|
||||
"A block carrying it unset": {
|
||||
Input: Block{
|
||||
Type: BlockMetric,
|
||||
Attrs: document.Attributes{document.AttrDataSourceID: nil},
|
||||
},
|
||||
},
|
||||
"A block carrying it empty": {
|
||||
Input: metric(""),
|
||||
},
|
||||
"A block carrying it": {
|
||||
Input: metric("a"),
|
||||
Expected: []string{"a"},
|
||||
},
|
||||
"A value that is not a string": {
|
||||
Input: Block{
|
||||
Type: BlockMetric,
|
||||
Attrs: document.Attributes{document.AttrDataSourceID: 1},
|
||||
},
|
||||
},
|
||||
"Items report each distinct value once": {
|
||||
Input: Block{
|
||||
Type: BlockMetricGrid,
|
||||
Items: []Block{metric("a"), metric("b"), metric("a")},
|
||||
},
|
||||
Expected: []string{"a", "b"},
|
||||
},
|
||||
"A split_doc reports both sides": {
|
||||
Input: Block{
|
||||
Type: BlockSplitDoc,
|
||||
Left: []Block{{Type: BlockHeading, Text: "x"}},
|
||||
Right: []Block{metric("a")},
|
||||
},
|
||||
Expected: []string{"a"},
|
||||
},
|
||||
"A list item reports what is nested under it": {
|
||||
Input: Block{
|
||||
Type: BlockBulletList,
|
||||
Items: []Block{{
|
||||
Type: BlockParagraph,
|
||||
Text: "x",
|
||||
Children: []Block{metric("a")},
|
||||
}},
|
||||
},
|
||||
Expected: []string{"a"},
|
||||
},
|
||||
"A task item reports its content block": {
|
||||
Input: Block{
|
||||
Type: BlockTaskList,
|
||||
TaskItems: []TaskItem{{
|
||||
Block: Block{
|
||||
Type: BlockParagraph,
|
||||
Text: "x",
|
||||
Children: []Block{metric("a")},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Expected: []string{"a"},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Expected, c.Input.CollectAttributeValues(document.AttrDataSourceID))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Type_Label(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Kind Type
|
||||
Result string
|
||||
}{
|
||||
"Paragraph": {Kind: "paragraph", Result: "a paragraph"},
|
||||
"Heading": {Kind: "heading", Result: "a heading"},
|
||||
"Blockquote": {Kind: "blockquote", Result: "a blockquote"},
|
||||
"Bullet list": {Kind: "bullet_list", Result: "a bullet list"},
|
||||
"Ordered list": {Kind: "ordered_list", Result: "an ordered list"},
|
||||
"Task list": {Kind: "task_list", Result: "a task list"},
|
||||
"Callout": {Kind: "callout", Result: "a callout"},
|
||||
"Code": {Kind: "code", Result: "a code block"},
|
||||
"Titled code": {Kind: "titled_code", Result: "a titled code block"},
|
||||
"Mermaid": {Kind: "mermaid", Result: "a mermaid diagram"},
|
||||
"Horizontal rule": {Kind: "horizontal_rule", Result: "a divider"},
|
||||
"Image": {Kind: "image", Result: "an image"},
|
||||
"File": {Kind: "file", Result: "a file attachment"},
|
||||
"Figma": {Kind: "figma", Result: "a figma embed"},
|
||||
"Metric": {Kind: "metric", Result: "a metric"},
|
||||
"Metric grid": {Kind: "metric_grid", Result: "a metric grid"},
|
||||
"Split doc": {Kind: "split_doc", Result: "a split documentation block"},
|
||||
"Param list": {Kind: "split_doc_param_list", Result: "a parameter list"},
|
||||
"Empty": {Kind: "", Result: "a block"},
|
||||
"Unknown": {Kind: "wibble", Result: "a wibble block"},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, c.Kind.Label())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// Compact converts a document.Block (ProseMirror JSON) into the
|
||||
// canonical Block representation surfaced to the AI. Wrapper nodes
|
||||
// the AI never authors (listItem, splitDocumentationLeftSide,
|
||||
// splitDocumentationParameterListItem, …) are folded into the
|
||||
// enclosing macro's typed fields. A listItem or taskItem compacted on
|
||||
// its own yields its entry paragraph, as its list would hold it.
|
||||
//
|
||||
// The returned Block preserves the source block's uid attribute so
|
||||
// the AI can refer to it in subsequent edit operations.
|
||||
func Compact(b document.Block) (Block, error) {
|
||||
uid, _ := b.UID()
|
||||
|
||||
switch b.Type {
|
||||
case document.BlockNodeParagraph:
|
||||
return compactParagraph(b, uid), nil
|
||||
case document.BlockNodeHeading:
|
||||
return compactHeading(b, uid), nil
|
||||
case document.BlockNodeBlockquote:
|
||||
return compactBlockquote(b, uid)
|
||||
case document.BlockNodeBulletList:
|
||||
return compactList(b, uid, BlockBulletList)
|
||||
case document.BlockNodeOrderedList:
|
||||
return compactList(b, uid, BlockOrderedList)
|
||||
case document.BlockNodeTaskList:
|
||||
return compactTaskList(b, uid)
|
||||
case document.BlockNodeListItem, document.BlockNodeTaskItem:
|
||||
return compactEntry(b)
|
||||
case document.BlockNodeCalloutBlock:
|
||||
return compactCallout(b, uid)
|
||||
case document.BlockNodeCodeBlock:
|
||||
return compactCode(b, uid), nil
|
||||
case document.BlockNodeTitledCodeBlock:
|
||||
return compactTitledCode(b, uid), nil
|
||||
case document.BlockNodeMermaidBlock:
|
||||
return compactMermaid(b, uid), nil
|
||||
case document.BlockNodeHorizontalRule:
|
||||
return Block{
|
||||
Type: BlockHorizontalRule,
|
||||
UID: uid,
|
||||
}, nil
|
||||
case document.BlockNodeImageBlock:
|
||||
return compactImage(b, uid), nil
|
||||
case document.BlockNodeFileBlock:
|
||||
return compactFile(b, uid), nil
|
||||
case document.BlockNodeFigmaBlock:
|
||||
return compactFigma(b, uid), nil
|
||||
case document.BlockNodeMetricBlock:
|
||||
return compactMetric(b, uid), nil
|
||||
case document.BlockNodeMetricGrid:
|
||||
return compactMetricGrid(b, uid)
|
||||
case document.BlockNodeSplitDoc:
|
||||
return compactSplitDoc(b, uid)
|
||||
case document.BlockNodeParamList:
|
||||
return compactParamList(b, uid)
|
||||
default:
|
||||
return Block{}, fmt.Errorf("unsupported ProseMirror type %q", b.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// compactMany compacts a slice of document.Blocks, short-circuiting
|
||||
// on the first error.
|
||||
func compactMany(blocks []document.Block) ([]Block, error) {
|
||||
if len(blocks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out := make([]Block, 0, len(blocks))
|
||||
|
||||
for i, b := range blocks {
|
||||
c, err := Compact(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compacting block %d (%s): %w", i, b.Type, err)
|
||||
}
|
||||
|
||||
out = append(out, c)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// compactParagraph compacts a paragraph node, emitting its inline
|
||||
// content as minimal-markdown text.
|
||||
func compactParagraph(b document.Block, uid string) Block {
|
||||
return Block{
|
||||
Type: BlockParagraph,
|
||||
UID: uid,
|
||||
Text: emitInlineMarkdown(b.Content),
|
||||
}
|
||||
}
|
||||
|
||||
// compactHeading compacts a heading node, carrying the level attr
|
||||
// alongside its plain text.
|
||||
func compactHeading(b document.Block, uid string) Block {
|
||||
level := 1
|
||||
if a, ok := b.Attrs.Get(document.AttrLevel); ok {
|
||||
level = a.Int()
|
||||
}
|
||||
|
||||
return Block{
|
||||
Type: BlockHeading,
|
||||
UID: uid,
|
||||
Text: flattenText(b.Content),
|
||||
Attrs: document.Attributes{document.AttrLevel: level},
|
||||
}
|
||||
}
|
||||
|
||||
// compactBlockquote compacts a blockquote node. The canonical model
|
||||
// treats blockquote as a single-paragraph wrapper: when the source
|
||||
// has exactly one paragraph child, its text is promoted; otherwise
|
||||
// Items carries the content so callers can still see it.
|
||||
func compactBlockquote(b document.Block, uid string) (Block, error) {
|
||||
if text, ok := singleParagraphText(b.Content); ok {
|
||||
return Block{
|
||||
Type: BlockBlockquote,
|
||||
UID: uid,
|
||||
Text: text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
items, err := compactMany(b.Content)
|
||||
if err != nil {
|
||||
return Block{}, fmt.Errorf("blockquote: %w", err)
|
||||
}
|
||||
|
||||
return Block{
|
||||
Type: BlockBlockquote,
|
||||
UID: uid,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// compactList compacts a bulletList or orderedList by unwrapping
|
||||
// each listItem and compacting its single content child.
|
||||
func compactList(b document.Block, uid string, kind Type) (Block, error) {
|
||||
items := make([]Block, 0, len(b.Content))
|
||||
|
||||
for i, li := range b.Content {
|
||||
if li.Type != document.BlockNodeListItem {
|
||||
return Block{}, fmt.Errorf("%s child %d: expected listItem, got %s", kind, i, li.Type)
|
||||
}
|
||||
|
||||
inner, err := compactEntry(li)
|
||||
if err != nil {
|
||||
return Block{}, fmt.Errorf("%s item %d: %w", kind, i, err)
|
||||
}
|
||||
|
||||
items = append(items, inner)
|
||||
}
|
||||
|
||||
return Block{
|
||||
Type: kind,
|
||||
UID: uid,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// compactEntry compacts a listItem or taskItem into the canonical list
|
||||
// entry: its leading paragraph, with whatever follows that paragraph,
|
||||
// typically a nested list, as the paragraph's children. Dropping the
|
||||
// rest would delete it from the document on the way back through
|
||||
// replace_block.
|
||||
func compactEntry(item document.Block) (Block, error) {
|
||||
if len(item.Content) == 0 {
|
||||
return Block{Type: BlockParagraph}, nil
|
||||
}
|
||||
|
||||
inner, err := Compact(item.Content[0])
|
||||
if err != nil {
|
||||
return Block{}, err
|
||||
}
|
||||
|
||||
inner.Children, err = compactMany(item.Content[1:])
|
||||
if err != nil {
|
||||
return Block{}, err
|
||||
}
|
||||
|
||||
return inner, nil
|
||||
}
|
||||
|
||||
// compactTaskList compacts a taskList by unwrapping each taskItem
|
||||
// into a canonical TaskItem.
|
||||
func compactTaskList(b document.Block, uid string) (Block, error) {
|
||||
items := make([]TaskItem, 0, len(b.Content))
|
||||
|
||||
for i, ti := range b.Content {
|
||||
if ti.Type != document.BlockNodeTaskItem {
|
||||
return Block{}, fmt.Errorf("task_list child %d: expected taskItem, got %s", i, ti.Type)
|
||||
}
|
||||
|
||||
itemUID, _ := ti.UID()
|
||||
|
||||
var checked bool
|
||||
if a, ok := ti.Attrs.Get(document.AttrChecked); ok {
|
||||
checked = a.Bool()
|
||||
}
|
||||
|
||||
inner, err := compactEntry(ti)
|
||||
if err != nil {
|
||||
return Block{}, fmt.Errorf("task_list item %d: %w", i, err)
|
||||
}
|
||||
|
||||
items = append(items, TaskItem{
|
||||
UID: itemUID,
|
||||
Checked: checked,
|
||||
Block: inner,
|
||||
})
|
||||
}
|
||||
|
||||
return Block{
|
||||
Type: BlockTaskList,
|
||||
UID: uid,
|
||||
TaskItems: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// compactCallout compacts a calloutBlock. When the callout holds a
|
||||
// single paragraph child, the canonical block uses the Text
|
||||
// shorthand; otherwise Items carries the child blocks verbatim.
|
||||
func compactCallout(b document.Block, uid string) (Block, error) {
|
||||
attrs := document.Attributes{}
|
||||
if a, ok := b.Attrs.Get(document.AttrIcon); ok && a.String() != "" {
|
||||
attrs[document.AttrIcon] = a.String()
|
||||
}
|
||||
|
||||
if text, ok := singleParagraphText(b.Content); ok {
|
||||
return Block{
|
||||
Type: BlockCallout,
|
||||
UID: uid,
|
||||
Text: text,
|
||||
Attrs: attrsOrNil(attrs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
items, err := compactMany(b.Content)
|
||||
if err != nil {
|
||||
return Block{}, fmt.Errorf("callout items: %w", err)
|
||||
}
|
||||
|
||||
return Block{
|
||||
Type: BlockCallout,
|
||||
UID: uid,
|
||||
Items: items,
|
||||
Attrs: attrsOrNil(attrs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// compactCode compacts a codeBlock node, flattening its content
|
||||
// into raw text (no markdown emission).
|
||||
func compactCode(b document.Block, uid string) Block {
|
||||
attrs := document.Attributes{}
|
||||
if a, ok := b.Attrs.Get(document.AttrLanguage); ok && a.String() != "" {
|
||||
attrs[document.AttrLanguage] = a.String()
|
||||
}
|
||||
|
||||
return Block{
|
||||
Type: BlockCode,
|
||||
UID: uid,
|
||||
Text: flattenText(b.Content),
|
||||
Attrs: attrsOrNil(attrs),
|
||||
}
|
||||
}
|
||||
|
||||
// compactTitledCode reads the title from the first child
|
||||
// (codeBlockTitle) and the body from the second child (codeBlock).
|
||||
// Defensive on malformed input: missing children produce empty
|
||||
// title/body rather than an error.
|
||||
func compactTitledCode(b document.Block, uid string) Block {
|
||||
attrs := document.Attributes{}
|
||||
|
||||
var (
|
||||
title string
|
||||
body string
|
||||
lang string
|
||||
)
|
||||
|
||||
for _, child := range b.Content {
|
||||
switch child.Type {
|
||||
case document.BlockNodeCodeBlockTitle:
|
||||
title = flattenText(child.Content)
|
||||
case document.BlockNodeCodeBlock:
|
||||
body = flattenText(child.Content)
|
||||
|
||||
if a, ok := child.Attrs.Get(document.AttrLanguage); ok {
|
||||
lang = a.String()
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
if title != "" {
|
||||
attrs[document.AttrTitle] = title
|
||||
}
|
||||
|
||||
if lang != "" {
|
||||
attrs[document.AttrLanguage] = lang
|
||||
}
|
||||
|
||||
return Block{
|
||||
Type: BlockTitledCode,
|
||||
UID: uid,
|
||||
Text: body,
|
||||
Attrs: attrsOrNil(attrs),
|
||||
}
|
||||
}
|
||||
|
||||
// compactMermaid compacts a mermaidBlock node, flattening its
|
||||
// content into raw diagram source.
|
||||
func compactMermaid(b document.Block, uid string) Block {
|
||||
return Block{
|
||||
Type: BlockMermaid,
|
||||
UID: uid,
|
||||
Text: flattenText(b.Content),
|
||||
}
|
||||
}
|
||||
|
||||
// compactImage compacts an imageBlock atom node, surfacing its
|
||||
// src/alt/title/width attributes.
|
||||
func compactImage(b document.Block, uid string) Block {
|
||||
attrs := document.Attributes{}
|
||||
|
||||
copyStringAttrs(attrs, b.Attrs, document.AttrSrc, document.AttrAlt, document.AttrTitle)
|
||||
copyPositiveIntAttrs(attrs, b.Attrs, document.AttrWidth)
|
||||
|
||||
return Block{
|
||||
Type: BlockImage,
|
||||
UID: uid,
|
||||
Attrs: attrsOrNil(attrs),
|
||||
}
|
||||
}
|
||||
|
||||
// compactFile compacts a fileBlock atom node, surfacing its
|
||||
// src/name/contentType/size attributes.
|
||||
func compactFile(b document.Block, uid string) Block {
|
||||
attrs := document.Attributes{}
|
||||
|
||||
copyStringAttrs(attrs, b.Attrs, document.AttrSrc, document.AttrName, document.AttrContentType)
|
||||
copyPositiveIntAttrs(attrs, b.Attrs, document.AttrSize)
|
||||
|
||||
return Block{
|
||||
Type: BlockFile,
|
||||
UID: uid,
|
||||
Attrs: attrsOrNil(attrs),
|
||||
}
|
||||
}
|
||||
|
||||
// compactFigma compacts a figmaBlock atom node, surfacing its
|
||||
// src/width/height attributes.
|
||||
func compactFigma(b document.Block, uid string) Block {
|
||||
attrs := document.Attributes{}
|
||||
|
||||
copyStringAttrs(attrs, b.Attrs, document.AttrSrc)
|
||||
copyPositiveIntAttrs(attrs, b.Attrs, document.AttrWidth, document.AttrHeight)
|
||||
|
||||
return Block{
|
||||
Type: BlockFigma,
|
||||
UID: uid,
|
||||
Attrs: attrsOrNil(attrs),
|
||||
}
|
||||
}
|
||||
|
||||
// compactMetric keeps every attribute of the source metric block except
|
||||
// its uid and its simulation flag. The configuration shape is opaque to
|
||||
// the canonical layer, so it passes through as is. The flag is internal
|
||||
// state that a reader never sees.
|
||||
func compactMetric(b document.Block, uid string) Block {
|
||||
attrs := b.Attrs.Copy()
|
||||
delete(attrs, document.AttrUID)
|
||||
delete(attrs, document.AttrSimulationActive)
|
||||
|
||||
return Block{
|
||||
Type: BlockMetric,
|
||||
UID: uid,
|
||||
Attrs: attrsOrNil(attrs),
|
||||
}
|
||||
}
|
||||
|
||||
// compactMetricGrid compacts a metricGrid node, carrying each child
|
||||
// metric in Items.
|
||||
func compactMetricGrid(b document.Block, uid string) (Block, error) {
|
||||
items, err := compactMany(b.Content)
|
||||
if err != nil {
|
||||
return Block{}, fmt.Errorf("metric_grid items: %w", err)
|
||||
}
|
||||
|
||||
return Block{
|
||||
Type: BlockMetricGrid,
|
||||
UID: uid,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// compactSplitDoc folds the splitDocumentationLeftSide and
|
||||
// splitDocumentationRightSide wrapper nodes into the canonical
|
||||
// block's Left/Right fields.
|
||||
func compactSplitDoc(b document.Block, uid string) (Block, error) {
|
||||
out := Block{Type: BlockSplitDoc, UID: uid}
|
||||
|
||||
if a, ok := b.Attrs.Get(document.AttrInversed); ok && a.Bool() {
|
||||
out.Attrs = document.Attributes{document.AttrInversed: true}
|
||||
}
|
||||
|
||||
for _, side := range b.Content {
|
||||
switch side.Type {
|
||||
case document.BlockNodeSplitDocLeft:
|
||||
items, err := compactMany(side.Content)
|
||||
if err != nil {
|
||||
return Block{}, fmt.Errorf("split_doc left: %w", err)
|
||||
}
|
||||
|
||||
out.Left = items
|
||||
case document.BlockNodeSplitDocRight:
|
||||
items, err := compactMany(side.Content)
|
||||
if err != nil {
|
||||
return Block{}, fmt.Errorf("split_doc right: %w", err)
|
||||
}
|
||||
|
||||
out.Right = items
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// compactParamList unwraps the parameter-list tree, surfacing the
|
||||
// header text and each item's name / type / description.
|
||||
func compactParamList(b document.Block, uid string) (Block, error) {
|
||||
out := Block{Type: BlockParamList, UID: uid}
|
||||
|
||||
for _, child := range b.Content {
|
||||
switch child.Type {
|
||||
case document.BlockNodeParamListHeader:
|
||||
out.Header = flattenText(child.Content)
|
||||
case document.BlockNodeParamListItem:
|
||||
out.Params = append(out.Params, compactParamListItem(child))
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// compactParamListItem turns one splitDocumentationParameterListItem
|
||||
// node into a canonical ParamItem.
|
||||
func compactParamListItem(b document.Block) ParamItem {
|
||||
uid, _ := b.UID()
|
||||
item := ParamItem{UID: uid}
|
||||
|
||||
for _, child := range b.Content {
|
||||
switch child.Type {
|
||||
case document.BlockNodeParamListItemHeader:
|
||||
for _, sub := range child.Content {
|
||||
switch sub.Type {
|
||||
case document.BlockNodeParamListItemTitle:
|
||||
item.Name = flattenText(sub.Content)
|
||||
case document.BlockNodeParamListItemType:
|
||||
item.Type = flattenText(sub.Content)
|
||||
default:
|
||||
}
|
||||
}
|
||||
case document.BlockNodeParagraph:
|
||||
item.Description = emitInlineMarkdown(child.Content)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// singleParagraphText reports whether content is exactly one
|
||||
// paragraph node and, when it is, returns that paragraph's inline
|
||||
// content emitted as minimal-markdown text.
|
||||
func singleParagraphText(content []document.Block) (string, bool) {
|
||||
if len(content) == 1 && content[0].Type == document.BlockNodeParagraph {
|
||||
return emitInlineMarkdown(content[0].Content), true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// copyStringAttrs copies each named attribute from src into dst when
|
||||
// it is present with a non-empty string value.
|
||||
func copyStringAttrs(dst, src document.Attributes, keys ...string) {
|
||||
for _, k := range keys {
|
||||
if a, ok := src.Get(k); ok && a.String() != "" {
|
||||
dst[k] = a.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copyPositiveIntAttrs copies each named attribute from src into dst
|
||||
// when it is present with a positive integer value.
|
||||
func copyPositiveIntAttrs(dst, src document.Attributes, keys ...string) {
|
||||
for _, k := range keys {
|
||||
if a, ok := src.Get(k); ok && a.Int() > 0 {
|
||||
dst[k] = a.Int()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flattenText concatenates the text of inline text nodes ignoring
|
||||
// marks. Used for code/mermaid/header bodies that carry raw
|
||||
// non-markdown text.
|
||||
func flattenText(content []document.Block) string {
|
||||
var out []byte
|
||||
|
||||
for _, n := range content {
|
||||
if n.Type == document.BlockNodeText {
|
||||
out = append(out, n.Text...)
|
||||
}
|
||||
}
|
||||
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// attrsOrNil returns m when it has at least one entry, nil
|
||||
// otherwise. Compact output uses nil maps rather than empty ones so
|
||||
// JSON-marshalled canonical blocks omit the field cleanly.
|
||||
func attrsOrNil(m document.Attributes) document.Attributes {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
|
@ -1,585 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
func Test_Compact(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Input document.Block
|
||||
Expected Block
|
||||
Err error
|
||||
}{
|
||||
"Paragraph emits markdown": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: map[string]any{"uid": "p1"},
|
||||
Content: []document.Block{
|
||||
{Type: "text", Text: "hi "},
|
||||
{Type: "text", Text: "bold", Marks: []document.Mark{{Type: "bold"}}},
|
||||
},
|
||||
},
|
||||
Expected: Block{Type: BlockParagraph, UID: "p1", Text: "hi **bold**"},
|
||||
},
|
||||
"Heading text is read plain": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeHeading,
|
||||
Attrs: map[string]any{"uid": "h1", "level": 2},
|
||||
Content: []document.Block{
|
||||
{Type: "text", Text: "my_var "},
|
||||
{Type: "text", Text: "noted", Marks: []document.Mark{{Type: "comment"}}},
|
||||
},
|
||||
},
|
||||
Expected: Block{Type: BlockHeading, UID: "h1", Text: "my_var noted", Attrs: map[string]any{"level": 2}},
|
||||
},
|
||||
"Callout with single paragraph uses Text shorthand": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeCalloutBlock,
|
||||
Attrs: map[string]any{"uid": "c1", "icon": "lucide:warning"},
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "watch out"}}},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockCallout,
|
||||
UID: "c1",
|
||||
Text: "watch out",
|
||||
Attrs: map[string]any{"icon": "lucide:warning"},
|
||||
},
|
||||
},
|
||||
"Split doc unwraps sides into Left and Right": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeSplitDoc,
|
||||
Attrs: map[string]any{"uid": "s1", "inversed": true},
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeSplitDocLeft,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeHeading, Attrs: map[string]any{"uid": "h1", "level": 2}, Content: []document.Block{{Type: "text", Text: "API"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: document.BlockNodeSplitDocRight,
|
||||
Content: []document.Block{},
|
||||
},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockSplitDoc,
|
||||
UID: "s1",
|
||||
Attrs: map[string]any{"inversed": true},
|
||||
Left: []Block{
|
||||
{Type: BlockHeading, UID: "h1", Text: "API", Attrs: map[string]any{"level": 2}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Ordered list unwraps list items": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeOrderedList,
|
||||
Attrs: map[string]any{"uid": "ol1"},
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeListItem,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "one"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockOrderedList,
|
||||
UID: "ol1",
|
||||
Items: []Block{{Type: BlockParagraph, Text: "one"}},
|
||||
},
|
||||
},
|
||||
"Empty list item becomes an empty paragraph": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeBulletList,
|
||||
Content: []document.Block{{Type: document.BlockNodeListItem}},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockBulletList,
|
||||
Items: []Block{{Type: BlockParagraph}},
|
||||
},
|
||||
},
|
||||
// The item wrapper holds a paragraph plus whatever is nested under
|
||||
// it; dropping the rest deletes it from the document on write-back.
|
||||
"List item keeps its nested list": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeBulletList,
|
||||
Attrs: map[string]any{"uid": "l1"},
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeListItem,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: map[string]any{"uid": "p1"},
|
||||
Content: []document.Block{{Type: "text", Text: "one"}},
|
||||
},
|
||||
{
|
||||
Type: document.BlockNodeBulletList,
|
||||
Attrs: map[string]any{"uid": "l2"},
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeListItem,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: map[string]any{"uid": "p2"},
|
||||
Content: []document.Block{{Type: "text", Text: "nested"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockBulletList,
|
||||
UID: "l1",
|
||||
Items: []Block{{
|
||||
Type: BlockParagraph,
|
||||
UID: "p1",
|
||||
Text: "one",
|
||||
Children: []Block{{
|
||||
Type: BlockBulletList,
|
||||
UID: "l2",
|
||||
Items: []Block{{Type: BlockParagraph, UID: "p2", Text: "nested"}},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
"Task item keeps its nested list": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeTaskList,
|
||||
Attrs: map[string]any{"uid": "t1"},
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeTaskItem,
|
||||
Attrs: map[string]any{"uid": "ti1", "checked": true},
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: map[string]any{"uid": "p1"},
|
||||
Content: []document.Block{{Type: "text", Text: "one"}},
|
||||
},
|
||||
{
|
||||
Type: document.BlockNodeBulletList,
|
||||
Attrs: map[string]any{"uid": "l2"},
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeListItem,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: map[string]any{"uid": "p2"},
|
||||
Content: []document.Block{{Type: "text", Text: "nested"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockTaskList,
|
||||
UID: "t1",
|
||||
TaskItems: []TaskItem{{
|
||||
UID: "ti1",
|
||||
Checked: true,
|
||||
Block: Block{
|
||||
Type: BlockParagraph,
|
||||
UID: "p1",
|
||||
Text: "one",
|
||||
Children: []Block{{
|
||||
Type: BlockBulletList,
|
||||
UID: "l2",
|
||||
Items: []Block{{Type: BlockParagraph, UID: "p2", Text: "nested"}},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
"List with non-listItem child fails": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeBulletList,
|
||||
Content: []document.Block{{Type: document.BlockNodeParagraph}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"List item with unsupported content fails": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeBulletList,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeListItem, Content: []document.Block{{Type: "weirdNode"}}},
|
||||
},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"List item with unsupported nested child fails": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeBulletList,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeListItem,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph},
|
||||
{Type: "weirdNode"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Blockquote with multiple paragraphs falls back to items": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeBlockquote,
|
||||
Attrs: map[string]any{"uid": "q1"},
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "a"}}},
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "b"}}},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockBlockquote,
|
||||
UID: "q1",
|
||||
Items: []Block{
|
||||
{Type: BlockParagraph, Text: "a"},
|
||||
{Type: BlockParagraph, Text: "b"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Blockquote with unsupported child fails": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeBlockquote,
|
||||
Content: []document.Block{{Type: "weirdNode"}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Task list with non-taskItem child fails": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeTaskList,
|
||||
Content: []document.Block{{Type: document.BlockNodeParagraph}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Task item without content becomes an empty paragraph": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeTaskList,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeTaskItem, Attrs: map[string]any{"uid": "t1", "checked": true}},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockTaskList,
|
||||
TaskItems: []TaskItem{
|
||||
{UID: "t1", Checked: true, Block: Block{Type: BlockParagraph}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Task item with unsupported content fails": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeTaskList,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeTaskItem, Content: []document.Block{{Type: "weirdNode"}}},
|
||||
},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Task item with unsupported nested child fails": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeTaskList,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeTaskItem,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph},
|
||||
{Type: "weirdNode"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Callout with multiple children falls back to items": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeCalloutBlock,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "a"}}},
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "b"}}},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockCallout,
|
||||
Items: []Block{
|
||||
{Type: BlockParagraph, Text: "a"},
|
||||
{Type: BlockParagraph, Text: "b"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Callout with unsupported child fails": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeCalloutBlock,
|
||||
Content: []document.Block{{Type: "weirdNode"}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Code without language omits attrs": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeCodeBlock,
|
||||
Content: []document.Block{{Type: "text", Text: "x := 1"}},
|
||||
},
|
||||
Expected: Block{Type: BlockCode, Text: "x := 1"},
|
||||
},
|
||||
"Titled code ignores unknown children": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeTitledCodeBlock,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeCodeBlockTitle, Content: []document.Block{{Type: "text", Text: "ex.go"}}},
|
||||
{Type: document.BlockNodeCodeBlock, Attrs: map[string]any{"language": "go"}, Content: []document.Block{{Type: "text", Text: "x"}}},
|
||||
{Type: "weirdNode"},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockTitledCode,
|
||||
Text: "x",
|
||||
Attrs: map[string]any{"title": "ex.go", "language": "go"},
|
||||
},
|
||||
},
|
||||
"Image carries title and width attrs": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeImageBlock,
|
||||
Attrs: map[string]any{"uid": "i1", "src": "http://x", "title": "shot", "width": 200},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockImage,
|
||||
UID: "i1",
|
||||
Attrs: map[string]any{"src": "http://x", "title": "shot", "width": 200},
|
||||
},
|
||||
},
|
||||
"File carries src, name, content type and size attrs": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeFileBlock,
|
||||
Attrs: map[string]any{
|
||||
"uid": "f1", "src": "http://x", "name": "notes.zip", "contentType": "application/zip", "size": 2048, "uploading": false,
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockFile,
|
||||
UID: "f1",
|
||||
Attrs: map[string]any{"src": "http://x", "name": "notes.zip", "contentType": "application/zip", "size": 2048},
|
||||
},
|
||||
},
|
||||
"File without attrs": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeFileBlock,
|
||||
Attrs: map[string]any{"uid": "f1"},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockFile,
|
||||
UID: "f1",
|
||||
},
|
||||
},
|
||||
"Metric passes attrs through without uid": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: map[string]any{"uid": "m1", "query": "up", "unit": "%"},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockMetric,
|
||||
UID: "m1",
|
||||
Attrs: map[string]any{"query": "up", "unit": "%"},
|
||||
},
|
||||
},
|
||||
"Metric keeps its preset and hides its simulation flag": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: map[string]any{
|
||||
"uid": "m1",
|
||||
"simulationPreset": "http_latency",
|
||||
"simulationActive": true,
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockMetric,
|
||||
UID: "m1",
|
||||
Attrs: map[string]any{"simulationPreset": "http_latency"},
|
||||
},
|
||||
},
|
||||
// a metric written before this layer knew the configuration: a
|
||||
// legacy config blob, explicit nulls for unset fields, and an
|
||||
// attribute it still does not name. Reading it must leave all
|
||||
// of them alone, or typing the schema would break documents
|
||||
// already in the system.
|
||||
"Metric keeps a legacy config blob, nulls and unknown attrs": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: map[string]any{
|
||||
"uid": "m1",
|
||||
"config": map[string]any{"type": "line_chart"},
|
||||
"visualizationType": nil,
|
||||
"wibble": 42,
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockMetric,
|
||||
UID: "m1",
|
||||
Attrs: map[string]any{
|
||||
"config": map[string]any{"type": "line_chart"},
|
||||
"visualizationType": nil,
|
||||
"wibble": 42,
|
||||
},
|
||||
},
|
||||
},
|
||||
"Metric grid wraps metric items": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeMetricGrid,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeMetricBlock, Attrs: map[string]any{"uid": "m1", "query": "up"}},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockMetricGrid,
|
||||
Items: []Block{
|
||||
{Type: BlockMetric, UID: "m1", Attrs: map[string]any{"query": "up"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Metric grid with unsupported child fails": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeMetricGrid,
|
||||
Content: []document.Block{{Type: "weirdNode"}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Split doc left failure propagates": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeSplitDoc,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeSplitDocLeft, Content: []document.Block{{Type: "weirdNode"}}},
|
||||
},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Split doc right failure propagates": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeSplitDoc,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeSplitDocRight, Content: []document.Block{{Type: "weirdNode"}}},
|
||||
},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Split doc ignores unknown side nodes": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeSplitDoc,
|
||||
Content: []document.Block{{Type: "weirdSide"}},
|
||||
},
|
||||
Expected: Block{Type: BlockSplitDoc},
|
||||
},
|
||||
"List item compacts to its entry paragraph with its nested blocks": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeListItem,
|
||||
Attrs: map[string]any{"uid": "li1"},
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Attrs: map[string]any{"uid": "p1"}, Content: []document.Block{{Type: "text", Text: "one"}}},
|
||||
{
|
||||
Type: document.BlockNodeBulletList,
|
||||
Attrs: map[string]any{"uid": "l2"},
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeListItem,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Attrs: map[string]any{"uid": "p2"}, Content: []document.Block{{Type: "text", Text: "two"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockParagraph,
|
||||
UID: "p1",
|
||||
Text: "one",
|
||||
Children: []Block{
|
||||
{Type: BlockBulletList, UID: "l2", Items: []Block{{Type: BlockParagraph, UID: "p2", Text: "two"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Task item compacts to its entry paragraph": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeTaskItem,
|
||||
Attrs: map[string]any{"uid": "t1", "checked": true},
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Attrs: map[string]any{"uid": "p1"}, Content: []document.Block{{Type: "text", Text: "done"}}},
|
||||
},
|
||||
},
|
||||
Expected: Block{Type: BlockParagraph, UID: "p1", Text: "done"},
|
||||
},
|
||||
"Empty list item compacts to an empty paragraph": {
|
||||
Input: document.Block{Type: document.BlockNodeListItem},
|
||||
Expected: Block{Type: BlockParagraph},
|
||||
},
|
||||
"Unsupported node type fails": {
|
||||
Input: document.Block{Type: "weirdNode"},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Param list ignores unknown children at every level": {
|
||||
Input: document.Block{
|
||||
Type: document.BlockNodeParamList,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParamListHeader, Content: []document.Block{{Type: "text", Text: "Body"}}},
|
||||
{
|
||||
Type: document.BlockNodeParamListItem,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeParamListItemHeader,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParamListItemTitle, Content: []document.Block{{Type: "text", Text: "id"}}},
|
||||
{Type: "weirdSub"},
|
||||
},
|
||||
},
|
||||
{Type: "weirdChild"},
|
||||
},
|
||||
},
|
||||
{Type: "weirdRow"},
|
||||
},
|
||||
},
|
||||
Expected: Block{
|
||||
Type: BlockParamList,
|
||||
Header: "Body",
|
||||
Params: []ParamItem{{Name: "id"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := Compact(c.Input)
|
||||
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,520 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/strutil"
|
||||
)
|
||||
|
||||
// Expand converts a canonical Block into a document.Block ready to
|
||||
// be applied to a Y.Doc. Every node in the resulting tree carries a
|
||||
// "uid" attribute: when the input supplies one (Block.UID,
|
||||
// TaskItem.UID, ParamItem.UID, or an attr on a passed-through
|
||||
// metric block), Expand preserves it; otherwise a fresh nanoid is
|
||||
// generated. Macro types (split_doc, param_list) construct the
|
||||
// nested TipTap wrapper nodes that the editor's schema requires.
|
||||
//
|
||||
// Expand assumes the input has already been validated by Validate.
|
||||
// Calling Expand on an invalid block may produce a document.Block
|
||||
// that hocuspocus rejects when applied.
|
||||
func Expand(b Block) (document.Block, error) {
|
||||
switch b.Type {
|
||||
case BlockParagraph:
|
||||
return expandParagraph(b), nil
|
||||
case BlockHeading:
|
||||
return expandHeading(b), nil
|
||||
case BlockBlockquote:
|
||||
return expandBlockquote(b)
|
||||
case BlockBulletList:
|
||||
return expandBulletOrOrderedList(b, document.BlockNodeBulletList)
|
||||
case BlockOrderedList:
|
||||
return expandBulletOrOrderedList(b, document.BlockNodeOrderedList)
|
||||
case BlockTaskList:
|
||||
return expandTaskList(b)
|
||||
case BlockCallout:
|
||||
return expandCallout(b)
|
||||
case BlockCode:
|
||||
return expandCode(b), nil
|
||||
case BlockTitledCode:
|
||||
return expandTitledCode(b), nil
|
||||
case BlockMermaid:
|
||||
return expandMermaid(b), nil
|
||||
case BlockHorizontalRule:
|
||||
return expandHorizontalRule(b), nil
|
||||
case BlockImage:
|
||||
return expandImage(b), nil
|
||||
case BlockFile:
|
||||
return document.Block{}, errFileNotAuthored
|
||||
case BlockFigma:
|
||||
return expandFigma(b), nil
|
||||
case BlockMetric:
|
||||
return expandMetric(b), nil
|
||||
case BlockMetricGrid:
|
||||
return expandMetricGrid(b)
|
||||
case BlockSplitDoc:
|
||||
return expandSplitDoc(b)
|
||||
case BlockParamList:
|
||||
return expandParamList(b)
|
||||
}
|
||||
|
||||
return document.Block{}, fmt.Errorf("unknown block type %q", b.Type)
|
||||
}
|
||||
|
||||
// expandMany expands a slice of canonical Blocks into a slice of
|
||||
// document.Blocks, short-circuiting on the first error.
|
||||
func expandMany(blocks []Block) ([]document.Block, error) {
|
||||
if len(blocks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out := make([]document.Block, 0, len(blocks))
|
||||
|
||||
for i, b := range blocks {
|
||||
expanded, err := Expand(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding block %d (%s): %w", i, b.Type, err)
|
||||
}
|
||||
|
||||
out = append(out, expanded)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveUID returns the supplied uid when non-empty, otherwise a
|
||||
// freshly generated nanoid. Centralised so the policy is consistent
|
||||
// across every block kind.
|
||||
func resolveUID(supplied string) string {
|
||||
if supplied != "" {
|
||||
return supplied
|
||||
}
|
||||
|
||||
return strutil.NanoID()
|
||||
}
|
||||
|
||||
// uidAttrs returns the standard {uid: …} attribute map.
|
||||
func uidAttrs(uid string) document.Attributes {
|
||||
return document.Attributes{document.AttrUID: uid}
|
||||
}
|
||||
|
||||
// expandParagraph builds a paragraph node from a canonical block,
|
||||
// parsing Text as inline minimal-markdown.
|
||||
func expandParagraph(b Block) document.Block {
|
||||
return document.Block{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: uidAttrs(resolveUID(b.UID)),
|
||||
Content: ParseInlineMarkdown(b.Text),
|
||||
}
|
||||
}
|
||||
|
||||
// expandHeading builds a heading node, reading the required level
|
||||
// attribute. Level defaults to 1 when missing; Validate should have
|
||||
// already enforced 1-3.
|
||||
func expandHeading(b Block) document.Block {
|
||||
level := 1
|
||||
if a, ok := b.Attrs.Get(document.AttrLevel); ok {
|
||||
level = a.Int()
|
||||
}
|
||||
|
||||
attrs := uidAttrs(resolveUID(b.UID))
|
||||
attrs[document.AttrLevel] = level
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeHeading,
|
||||
Attrs: attrs,
|
||||
Content: rawTextContent(b.Text),
|
||||
}
|
||||
}
|
||||
|
||||
// expandBlockquote builds a blockquote node. Either Text (single-paragraph
|
||||
// shorthand) or Items (any root-legal blocks) supplies the content,
|
||||
// matching what Validate accepts and what Compact produces.
|
||||
func expandBlockquote(b Block) (document.Block, error) {
|
||||
children, err := textOrItemsContent(b)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("blockquote items: %w", err)
|
||||
}
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeBlockquote,
|
||||
Attrs: uidAttrs(resolveUID(b.UID)),
|
||||
Content: children,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// expandBulletOrOrderedList builds a bulletList or orderedList node.
|
||||
// Each canonical Item is wrapped in a listItem; if the item's
|
||||
// canonical type is paragraph, the paragraph node becomes the
|
||||
// listItem's content; otherwise the item is expanded as-is and used
|
||||
// directly.
|
||||
func expandBulletOrOrderedList(b Block, pmType document.BlockNodeType) (document.Block, error) {
|
||||
children := make([]document.Block, 0, len(b.Items))
|
||||
|
||||
for i, item := range b.Items {
|
||||
expanded, err := Expand(item)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("list item %d: %w", i, err)
|
||||
}
|
||||
|
||||
nested, err := expandMany(item.Children)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("list item %d children: %w", i, err)
|
||||
}
|
||||
|
||||
content := append([]document.Block{expanded}, nested...)
|
||||
|
||||
children = append(children, document.Block{
|
||||
Type: document.BlockNodeListItem,
|
||||
Attrs: uidAttrs(strutil.NanoID()),
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
return document.Block{
|
||||
Type: pmType,
|
||||
Attrs: uidAttrs(resolveUID(b.UID)),
|
||||
Content: children,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// expandTaskList builds a taskList node. Each TaskItem becomes a
|
||||
// taskItem node carrying a checked attr and wrapping the row's
|
||||
// content block.
|
||||
func expandTaskList(b Block) (document.Block, error) {
|
||||
children := make([]document.Block, 0, len(b.TaskItems))
|
||||
|
||||
for i, item := range b.TaskItems {
|
||||
expanded, err := Expand(item.Block)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("task item %d: %w", i, err)
|
||||
}
|
||||
|
||||
nested, err := expandMany(item.Block.Children)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("task item %d children: %w", i, err)
|
||||
}
|
||||
|
||||
content := append([]document.Block{expanded}, nested...)
|
||||
|
||||
attrs := uidAttrs(resolveUID(item.UID))
|
||||
attrs[document.AttrChecked] = item.Checked
|
||||
|
||||
children = append(children, document.Block{
|
||||
Type: document.BlockNodeTaskItem,
|
||||
Attrs: attrs,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeTaskList,
|
||||
Attrs: uidAttrs(resolveUID(b.UID)),
|
||||
Content: children,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// expandCallout builds a calloutBlock. Either Text (single-paragraph
|
||||
// shorthand) or Items (any combination of paragraphs and lists)
|
||||
// supplies the content. Icon defaults to "lucide:text" matching the
|
||||
// editor's default.
|
||||
func expandCallout(b Block) (document.Block, error) {
|
||||
attrs := uidAttrs(resolveUID(b.UID))
|
||||
|
||||
attrs[document.AttrIcon] = "lucide:text"
|
||||
if a, ok := b.Attrs.Get(document.AttrIcon); ok {
|
||||
attrs[document.AttrIcon] = a.String()
|
||||
}
|
||||
|
||||
children, err := textOrItemsContent(b)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("callout items: %w", err)
|
||||
}
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeCalloutBlock,
|
||||
Attrs: attrs,
|
||||
Content: children,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// textOrItemsContent builds the child slice of a block that accepts
|
||||
// either the single-paragraph Text shorthand or explicit Items:
|
||||
// present Items are expanded as-is, otherwise Text becomes one
|
||||
// paragraph node.
|
||||
func textOrItemsContent(b Block) ([]document.Block, error) {
|
||||
if len(b.Items) > 0 {
|
||||
return expandMany(b.Items)
|
||||
}
|
||||
|
||||
return []document.Block{{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: uidAttrs(strutil.NanoID()),
|
||||
Content: ParseInlineMarkdown(b.Text),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// expandCode builds a codeBlock. Text is treated as raw code and
|
||||
// emitted as a single text node (no markdown parsing).
|
||||
func expandCode(b Block) document.Block {
|
||||
attrs := uidAttrs(resolveUID(b.UID))
|
||||
if a, ok := b.Attrs.Get(document.AttrLanguage); ok && a.String() != "" {
|
||||
attrs[document.AttrLanguage] = a.String()
|
||||
}
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeCodeBlock,
|
||||
Attrs: attrs,
|
||||
Content: rawTextContent(b.Text),
|
||||
}
|
||||
}
|
||||
|
||||
// expandTitledCode builds a titledCodeBlock with its required
|
||||
// codeBlockTitle + codeBlock children. Title is plain text; the code
|
||||
// body is raw.
|
||||
func expandTitledCode(b Block) document.Block {
|
||||
var title, lang string
|
||||
|
||||
if a, ok := b.Attrs.Get(document.AttrTitle); ok {
|
||||
title = a.String()
|
||||
}
|
||||
|
||||
if a, ok := b.Attrs.Get(document.AttrLanguage); ok {
|
||||
lang = a.String()
|
||||
}
|
||||
|
||||
codeAttrs := uidAttrs(strutil.NanoID())
|
||||
if lang != "" {
|
||||
codeAttrs[document.AttrLanguage] = lang
|
||||
}
|
||||
|
||||
titleNode := document.Block{
|
||||
Type: document.BlockNodeCodeBlockTitle,
|
||||
Attrs: uidAttrs(strutil.NanoID()),
|
||||
Content: rawTextContent(title),
|
||||
}
|
||||
|
||||
codeNode := document.Block{
|
||||
Type: document.BlockNodeCodeBlock,
|
||||
Attrs: codeAttrs,
|
||||
Content: rawTextContent(b.Text),
|
||||
}
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeTitledCodeBlock,
|
||||
Attrs: uidAttrs(resolveUID(b.UID)),
|
||||
Content: []document.Block{
|
||||
titleNode,
|
||||
codeNode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// expandMermaid builds a mermaidBlock. Text is treated as raw
|
||||
// diagram source.
|
||||
func expandMermaid(b Block) document.Block {
|
||||
return document.Block{
|
||||
Type: document.BlockNodeMermaidBlock,
|
||||
Attrs: uidAttrs(resolveUID(b.UID)),
|
||||
Content: rawTextContent(b.Text),
|
||||
}
|
||||
}
|
||||
|
||||
// expandHorizontalRule builds a horizontalRule atom node.
|
||||
func expandHorizontalRule(b Block) document.Block {
|
||||
return document.Block{
|
||||
Type: document.BlockNodeHorizontalRule,
|
||||
Attrs: uidAttrs(resolveUID(b.UID)),
|
||||
}
|
||||
}
|
||||
|
||||
// expandImage builds an imageBlock atom node with src/alt/title/
|
||||
// width attributes.
|
||||
func expandImage(b Block) document.Block {
|
||||
attrs := uidAttrs(resolveUID(b.UID))
|
||||
|
||||
copyStringAttrs(attrs, b.Attrs, document.AttrSrc, document.AttrAlt, document.AttrTitle)
|
||||
copyPositiveIntAttrs(attrs, b.Attrs, document.AttrWidth)
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeImageBlock,
|
||||
Attrs: attrs,
|
||||
}
|
||||
}
|
||||
|
||||
// expandFigma builds a figmaBlock atom node.
|
||||
func expandFigma(b Block) document.Block {
|
||||
attrs := uidAttrs(resolveUID(b.UID))
|
||||
|
||||
copyStringAttrs(attrs, b.Attrs, document.AttrSrc)
|
||||
copyPositiveIntAttrs(attrs, b.Attrs, document.AttrWidth, document.AttrHeight)
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeFigmaBlock,
|
||||
Attrs: attrs,
|
||||
}
|
||||
}
|
||||
|
||||
// expandMetric builds a metricBlock. All caller-supplied attrs are
|
||||
// passed through unchanged (the metric configuration shape is
|
||||
// opaque to the canonical layer); the uid is taken from Block.UID
|
||||
// or freshly generated. A simulation flag the caller sent is dropped.
|
||||
// A block with a preset simulates, and any other does not.
|
||||
func expandMetric(b Block) document.Block {
|
||||
attrs := b.Attrs.Copy()
|
||||
attrs[document.AttrUID] = resolveUID(b.UID)
|
||||
|
||||
delete(attrs, document.AttrSimulationActive)
|
||||
|
||||
if _, ok := attrs.Value(document.AttrSimulationPreset); ok {
|
||||
attrs[document.AttrSimulationActive] = true
|
||||
}
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: attrs,
|
||||
}
|
||||
}
|
||||
|
||||
// expandMetricGrid builds a metricGrid wrapping each item (expected
|
||||
// to be a metric).
|
||||
func expandMetricGrid(b Block) (document.Block, error) {
|
||||
children, err := expandMany(b.Items)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("metric_grid items: %w", err)
|
||||
}
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeMetricGrid,
|
||||
Attrs: uidAttrs(resolveUID(b.UID)),
|
||||
Content: children,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// expandSplitDoc builds the full splitDocumentation tree, wrapping
|
||||
// Left and Right in their splitDocumentationLeftSide /
|
||||
// splitDocumentationRightSide nodes.
|
||||
func expandSplitDoc(b Block) (document.Block, error) {
|
||||
left, err := expandMany(b.Left)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("split_doc left: %w", err)
|
||||
}
|
||||
|
||||
right, err := expandMany(b.Right)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("split_doc right: %w", err)
|
||||
}
|
||||
|
||||
leftNode := document.Block{
|
||||
Type: document.BlockNodeSplitDocLeft,
|
||||
Attrs: uidAttrs(strutil.NanoID()),
|
||||
Content: left,
|
||||
}
|
||||
|
||||
rightNode := document.Block{
|
||||
Type: document.BlockNodeSplitDocRight,
|
||||
Attrs: uidAttrs(strutil.NanoID()),
|
||||
Content: right,
|
||||
}
|
||||
|
||||
attrs := uidAttrs(resolveUID(b.UID))
|
||||
if a, ok := b.Attrs.Get(document.AttrInversed); ok && a.Bool() {
|
||||
attrs[document.AttrInversed] = true
|
||||
}
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeSplitDoc,
|
||||
Attrs: attrs,
|
||||
Content: []document.Block{
|
||||
leftNode,
|
||||
rightNode,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// expandParamList builds the full splitDocumentationParameterList
|
||||
// tree: a header node followed by one item per Params entry. Each
|
||||
// item nests an itemHeader (title + type) and a paragraph carrying
|
||||
// the description.
|
||||
func expandParamList(b Block) (document.Block, error) {
|
||||
children := make([]document.Block, 0, 1+len(b.Params))
|
||||
|
||||
children = append(children, document.Block{
|
||||
Type: document.BlockNodeParamListHeader,
|
||||
Attrs: uidAttrs(strutil.NanoID()),
|
||||
Content: rawTextContent(b.Header),
|
||||
})
|
||||
|
||||
for _, p := range b.Params {
|
||||
titleNode := document.Block{
|
||||
Type: document.BlockNodeParamListItemTitle,
|
||||
Attrs: uidAttrs(strutil.NanoID()),
|
||||
Content: rawTextContent(p.Name),
|
||||
}
|
||||
|
||||
typeNode := document.Block{
|
||||
Type: document.BlockNodeParamListItemType,
|
||||
Attrs: uidAttrs(strutil.NanoID()),
|
||||
Content: rawTextContent(p.Type),
|
||||
}
|
||||
|
||||
headerNode := document.Block{
|
||||
Type: document.BlockNodeParamListItemHeader,
|
||||
Attrs: uidAttrs(strutil.NanoID()),
|
||||
Content: []document.Block{
|
||||
titleNode,
|
||||
typeNode,
|
||||
},
|
||||
}
|
||||
|
||||
descNode := document.Block{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: uidAttrs(strutil.NanoID()),
|
||||
Content: ParseInlineMarkdown(p.Description),
|
||||
}
|
||||
|
||||
children = append(children, document.Block{
|
||||
Type: document.BlockNodeParamListItem,
|
||||
Attrs: uidAttrs(resolveUID(p.UID)),
|
||||
Content: []document.Block{
|
||||
headerNode,
|
||||
descNode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return document.Block{
|
||||
Type: document.BlockNodeParamList,
|
||||
Attrs: uidAttrs(resolveUID(b.UID)),
|
||||
Content: children,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TextContent returns the inline content a block of type pm holds for
|
||||
// text. A heading takes no formatting, and code, mermaid and a code
|
||||
// block's title hold raw source, so their text is kept as written; every
|
||||
// other type reads text as minimal markdown.
|
||||
func TextContent(pm document.BlockNodeType, text string) []document.Block {
|
||||
switch pm {
|
||||
case document.BlockNodeHeading,
|
||||
document.BlockNodeCodeBlock,
|
||||
document.BlockNodeTitledCodeBlock,
|
||||
document.BlockNodeCodeBlockTitle,
|
||||
document.BlockNodeMermaidBlock:
|
||||
return rawTextContent(text)
|
||||
default:
|
||||
return ParseInlineMarkdown(text)
|
||||
}
|
||||
}
|
||||
|
||||
// rawTextContent returns the inline content slice for a node that
|
||||
// stores raw text (code, mermaid, parameter title/type, code block
|
||||
// title, paragraph-list header). Empty text returns nil so the
|
||||
// resulting node has no content rather than an empty text node.
|
||||
func rawTextContent(text string) []document.Block {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []document.Block{{Type: document.BlockNodeText, Text: text}}
|
||||
}
|
||||
|
|
@ -1,784 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stripUIDsPM recursively removes uid attributes throughout a
|
||||
// document.Block tree so structural equality assertions don't have
|
||||
// to know which uids will be generated. It returns a transformed
|
||||
// copy and leaves the input untouched.
|
||||
func stripUIDsPM(b document.Block) document.Block {
|
||||
out := document.Block{
|
||||
Type: b.Type,
|
||||
Text: b.Text,
|
||||
Marks: b.Marks,
|
||||
}
|
||||
|
||||
if b.Attrs != nil {
|
||||
na := make(map[string]any, len(b.Attrs))
|
||||
for k, v := range b.Attrs {
|
||||
if k == document.AttrUID {
|
||||
continue
|
||||
}
|
||||
|
||||
na[k] = v
|
||||
}
|
||||
|
||||
if len(na) > 0 {
|
||||
out.Attrs = na
|
||||
}
|
||||
}
|
||||
|
||||
if len(b.Content) > 0 {
|
||||
out.Content = make([]document.Block, len(b.Content))
|
||||
for i, c := range b.Content {
|
||||
out.Content[i] = stripUIDsPM(c)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// stripUIDsCanonical strips UID fields throughout a canonical Block
|
||||
// tree (Block.UID, TaskItem.UID, ParamItem.UID) so round-trip
|
||||
// assertions can ignore generated identifiers. It rewrites the
|
||||
// nested Items/Children/TaskItems/Left/Right/Params slices in
|
||||
// place, so the input tree is stripped as a side effect.
|
||||
func stripUIDsCanonical(b Block) Block {
|
||||
b.UID = ""
|
||||
|
||||
for i, c := range b.Items {
|
||||
b.Items[i] = stripUIDsCanonical(c)
|
||||
}
|
||||
|
||||
for i, c := range b.Children {
|
||||
b.Children[i] = stripUIDsCanonical(c)
|
||||
}
|
||||
|
||||
for i, t := range b.TaskItems {
|
||||
b.TaskItems[i] = TaskItem{
|
||||
Checked: t.Checked,
|
||||
Block: stripUIDsCanonical(t.Block),
|
||||
}
|
||||
}
|
||||
|
||||
for i, l := range b.Left {
|
||||
b.Left[i] = stripUIDsCanonical(l)
|
||||
}
|
||||
|
||||
for i, r := range b.Right {
|
||||
b.Right[i] = stripUIDsCanonical(r)
|
||||
}
|
||||
|
||||
for i, p := range b.Params {
|
||||
p.UID = ""
|
||||
b.Params[i] = p
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func Test_Expand(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Input Block
|
||||
Expected document.Block
|
||||
Err error
|
||||
|
||||
// RoundTrip asserts that compacting the expanded block returns the
|
||||
// input instead of comparing against Expected.
|
||||
RoundTrip bool
|
||||
|
||||
// Check replaces the expected-block comparison for a case that
|
||||
// asserts on something the comparison strips, such as the uid.
|
||||
Check func(t *testing.T, got document.Block)
|
||||
}{
|
||||
"Round trip: paragraph": {Input: Block{Type: BlockParagraph, Text: "hi **there**"}, RoundTrip: true},
|
||||
"Round trip: heading": {Input: Block{Type: BlockHeading, Text: "Title", Attrs: map[string]any{"level": 1}}, RoundTrip: true},
|
||||
"Round trip: blockquote": {Input: Block{Type: BlockBlockquote, Text: "quote"}, RoundTrip: true},
|
||||
"Round trip: blockquote with items": {RoundTrip: true, Input: Block{
|
||||
Type: BlockBlockquote,
|
||||
Items: []Block{
|
||||
{Type: BlockParagraph, Text: "one"},
|
||||
{Type: BlockParagraph, Text: "two"},
|
||||
},
|
||||
}},
|
||||
"Round trip: bullet list with a nested list": {RoundTrip: true, Input: Block{
|
||||
Type: BlockBulletList,
|
||||
Items: []Block{
|
||||
{
|
||||
Type: BlockParagraph,
|
||||
Text: "one",
|
||||
Children: []Block{{
|
||||
Type: BlockBulletList,
|
||||
Items: []Block{{Type: BlockParagraph, Text: "nested"}},
|
||||
}},
|
||||
},
|
||||
{Type: BlockParagraph, Text: "two"},
|
||||
},
|
||||
}},
|
||||
"Round trip: task list with a nested list": {RoundTrip: true, Input: Block{
|
||||
Type: BlockTaskList,
|
||||
TaskItems: []TaskItem{{
|
||||
Checked: true,
|
||||
Block: Block{
|
||||
Type: BlockParagraph,
|
||||
Text: "done",
|
||||
Children: []Block{{
|
||||
Type: BlockBulletList,
|
||||
Items: []Block{{Type: BlockParagraph, Text: "nested"}},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
"Round trip: bullet list": {RoundTrip: true, Input: Block{
|
||||
Type: BlockBulletList,
|
||||
Items: []Block{{Type: BlockParagraph, Text: "one"}, {Type: BlockParagraph, Text: "two"}},
|
||||
}},
|
||||
"Round trip: task list": {RoundTrip: true, Input: Block{
|
||||
Type: BlockTaskList,
|
||||
TaskItems: []TaskItem{
|
||||
{Checked: true, Block: Block{Type: BlockParagraph, Text: "done"}},
|
||||
},
|
||||
}},
|
||||
"Round trip: callout text shorthand": {RoundTrip: true, Input: Block{
|
||||
Type: BlockCallout,
|
||||
Text: "warn",
|
||||
Attrs: map[string]any{"icon": "lucide:warning"},
|
||||
}},
|
||||
"Round trip: code": {RoundTrip: true, Input: Block{
|
||||
Type: BlockCode,
|
||||
Text: "x := 1",
|
||||
Attrs: map[string]any{"language": "go"},
|
||||
}},
|
||||
"Round trip: titled code": {RoundTrip: true, Input: Block{
|
||||
Type: BlockTitledCode,
|
||||
Text: "x := 1",
|
||||
Attrs: map[string]any{"title": "ex.go", "language": "go"},
|
||||
}},
|
||||
"Round trip: mermaid": {Input: Block{Type: BlockMermaid, Text: "graph TD; A-->B;"}, RoundTrip: true},
|
||||
"Round trip: horizontal rule": {Input: Block{Type: BlockHorizontalRule}, RoundTrip: true},
|
||||
"Round trip: image": {RoundTrip: true, Input: Block{
|
||||
Type: BlockImage,
|
||||
Attrs: map[string]any{"src": "http://x", "alt": "x", "width": 100},
|
||||
}},
|
||||
"Round trip: figma": {RoundTrip: true, Input: Block{
|
||||
Type: BlockFigma,
|
||||
Attrs: map[string]any{"src": "http://figma", "width": 320, "height": 200},
|
||||
}},
|
||||
"Round trip: split doc": {RoundTrip: true, Input: Block{
|
||||
Type: BlockSplitDoc,
|
||||
Attrs: map[string]any{"inversed": true},
|
||||
Left: []Block{
|
||||
{Type: BlockHeading, Text: "API", Attrs: map[string]any{"level": 1}},
|
||||
{Type: BlockParagraph, Text: "explain"},
|
||||
},
|
||||
Right: []Block{
|
||||
{Type: BlockTitledCode, Text: "ok", Attrs: map[string]any{"title": "example"}},
|
||||
},
|
||||
}},
|
||||
"Round trip: param list": {RoundTrip: true, Input: Block{
|
||||
Type: BlockParamList,
|
||||
Header: "Body",
|
||||
Params: []ParamItem{
|
||||
{Name: "id", Type: "string", Description: "the **user** id"},
|
||||
{Name: "limit", Type: "number?", Description: "page size"},
|
||||
},
|
||||
}},
|
||||
"An explicit uid is preserved": {
|
||||
Input: Block{Type: BlockParagraph, UID: "preserve-me", Text: "x"},
|
||||
Check: func(t *testing.T, got document.Block) {
|
||||
uid, ok := got.UID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "preserve-me", uid)
|
||||
},
|
||||
},
|
||||
"A missing uid is generated": {
|
||||
Input: Block{Type: BlockParagraph, Text: "x"},
|
||||
Check: func(t *testing.T, got document.Block) {
|
||||
uid, ok := got.UID()
|
||||
require.True(t, ok)
|
||||
assert.NotEmpty(t, uid)
|
||||
},
|
||||
},
|
||||
"Paragraph with bold mark": {
|
||||
Input: Block{Type: BlockParagraph, Text: "say **hi**"},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Content: []document.Block{
|
||||
{Type: "text", Text: "say "},
|
||||
{Type: "text", Text: "hi", Marks: []document.Mark{{Type: "bold"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Heading carries level attr": {
|
||||
Input: Block{Type: BlockHeading, Text: "Title", Attrs: map[string]any{"level": 2}},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeHeading,
|
||||
Attrs: map[string]any{"level": 2},
|
||||
Content: []document.Block{
|
||||
{Type: "text", Text: "Title"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Heading text is kept as written": {
|
||||
Input: Block{Type: BlockHeading, Text: "The **my_var** flag", Attrs: map[string]any{"level": 2}},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeHeading,
|
||||
Attrs: map[string]any{"level": 2},
|
||||
Content: []document.Block{
|
||||
{Type: "text", Text: "The **my_var** flag"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Blockquote wraps paragraph": {
|
||||
Input: Block{Type: BlockBlockquote, Text: "quote"},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeBlockquote,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Content: []document.Block{{Type: "text", Text: "quote"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Blockquote wraps items": {
|
||||
Input: Block{
|
||||
Type: BlockBlockquote,
|
||||
Items: []Block{
|
||||
{Type: BlockParagraph, Text: "one"},
|
||||
{Type: BlockParagraph, Text: "two"},
|
||||
},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeBlockquote,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Content: []document.Block{{Type: "text", Text: "one"}},
|
||||
},
|
||||
{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Content: []document.Block{{Type: "text", Text: "two"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Bullet list wraps each item in listItem > paragraph": {
|
||||
Input: Block{
|
||||
Type: BlockBulletList,
|
||||
Items: []Block{
|
||||
{Type: BlockParagraph, Text: "first"},
|
||||
{Type: BlockParagraph, Text: "second"},
|
||||
},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeBulletList,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeListItem,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "first"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: document.BlockNodeListItem,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "second"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Task list carries checked flag on each task item": {
|
||||
Input: Block{
|
||||
Type: BlockTaskList,
|
||||
TaskItems: []TaskItem{
|
||||
{Checked: true, Block: Block{Type: BlockParagraph, Text: "done"}},
|
||||
{Checked: false, Block: Block{Type: BlockParagraph, Text: "todo"}},
|
||||
},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeTaskList,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeTaskItem,
|
||||
Attrs: map[string]any{"checked": true},
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "done"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: document.BlockNodeTaskItem,
|
||||
Attrs: map[string]any{"checked": false},
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "todo"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Callout text shorthand wraps a paragraph": {
|
||||
Input: Block{Type: BlockCallout, Text: "warn", Attrs: map[string]any{"icon": "lucide:warning"}},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeCalloutBlock,
|
||||
Attrs: map[string]any{"icon": "lucide:warning"},
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "warn"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Code is emitted raw without markdown parsing": {
|
||||
Input: Block{Type: BlockCode, Text: "**not** bold", Attrs: map[string]any{"language": "go"}},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeCodeBlock,
|
||||
Attrs: map[string]any{"language": "go"},
|
||||
Content: []document.Block{{Type: "text", Text: "**not** bold"}},
|
||||
},
|
||||
},
|
||||
"Titled code expands to title + code children": {
|
||||
Input: Block{
|
||||
Type: BlockTitledCode,
|
||||
Text: "x := 1",
|
||||
Attrs: map[string]any{"title": "example.go", "language": "go"},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeTitledCodeBlock,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeCodeBlockTitle, Content: []document.Block{{Type: "text", Text: "example.go"}}},
|
||||
{Type: document.BlockNodeCodeBlock, Attrs: map[string]any{"language": "go"}, Content: []document.Block{{Type: "text", Text: "x := 1"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Mermaid is raw text": {
|
||||
Input: Block{Type: BlockMermaid, Text: "graph TD; A-->B;"},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeMermaidBlock,
|
||||
Content: []document.Block{{Type: "text", Text: "graph TD; A-->B;"}},
|
||||
},
|
||||
},
|
||||
"Horizontal rule has no content": {
|
||||
Input: Block{Type: BlockHorizontalRule},
|
||||
Expected: document.Block{Type: document.BlockNodeHorizontalRule},
|
||||
},
|
||||
"Image carries src and width attrs": {
|
||||
Input: Block{Type: BlockImage, Attrs: map[string]any{"src": "http://x", "width": 200}},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeImageBlock,
|
||||
Attrs: map[string]any{"src": "http://x", "width": 200},
|
||||
},
|
||||
},
|
||||
"Split doc builds left and right wrappers": {
|
||||
Input: Block{
|
||||
Type: BlockSplitDoc,
|
||||
Attrs: map[string]any{"inversed": true},
|
||||
Left: []Block{
|
||||
{Type: BlockHeading, Text: "API", Attrs: map[string]any{"level": 1}},
|
||||
{Type: BlockParagraph, Text: "intro"},
|
||||
},
|
||||
Right: []Block{
|
||||
{Type: BlockTitledCode, Text: "ok", Attrs: map[string]any{"title": "example"}},
|
||||
{Type: BlockMermaid, Text: "graph TD"},
|
||||
},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeSplitDoc,
|
||||
Attrs: map[string]any{"inversed": true},
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeSplitDocLeft,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeHeading, Attrs: map[string]any{"level": 1}, Content: []document.Block{{Type: "text", Text: "API"}}},
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "intro"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: document.BlockNodeSplitDocRight,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeTitledCodeBlock,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeCodeBlockTitle, Content: []document.Block{{Type: "text", Text: "example"}}},
|
||||
{Type: document.BlockNodeCodeBlock, Content: []document.Block{{Type: "text", Text: "ok"}}},
|
||||
},
|
||||
},
|
||||
{Type: document.BlockNodeMermaidBlock, Content: []document.Block{{Type: "text", Text: "graph TD"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Param list builds header and full item nesting": {
|
||||
Input: Block{
|
||||
Type: BlockParamList,
|
||||
Header: "Body",
|
||||
Params: []ParamItem{
|
||||
{Name: "id", Type: "string", Description: "the user id"},
|
||||
},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeParamList,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParamListHeader, Content: []document.Block{{Type: "text", Text: "Body"}}},
|
||||
{
|
||||
Type: document.BlockNodeParamListItem,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeParamListItemHeader,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParamListItemTitle, Content: []document.Block{{Type: "text", Text: "id"}}},
|
||||
{Type: document.BlockNodeParamListItemType, Content: []document.Block{{Type: "text", Text: "string"}}},
|
||||
},
|
||||
},
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "the user id"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Ordered list wraps items in listItem": {
|
||||
Input: Block{
|
||||
Type: BlockOrderedList,
|
||||
Items: []Block{{Type: BlockParagraph, Text: "one"}},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeOrderedList,
|
||||
Content: []document.Block{
|
||||
{
|
||||
Type: document.BlockNodeListItem,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "one"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Heading without level defaults to 1": {
|
||||
Input: Block{Type: BlockHeading, Text: "T"},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeHeading,
|
||||
Attrs: map[string]any{"level": 1},
|
||||
Content: []document.Block{{Type: "text", Text: "T"}},
|
||||
},
|
||||
},
|
||||
"Callout without icon uses the default": {
|
||||
Input: Block{Type: BlockCallout, Text: "note"},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeCalloutBlock,
|
||||
Attrs: map[string]any{"icon": "lucide:text"},
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "note"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Callout with items expands each": {
|
||||
Input: Block{
|
||||
Type: BlockCallout,
|
||||
Attrs: map[string]any{"icon": "lucide:warning"},
|
||||
Items: []Block{
|
||||
{Type: BlockParagraph, Text: "a"},
|
||||
{Type: BlockParagraph, Text: "b"},
|
||||
},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeCalloutBlock,
|
||||
Attrs: map[string]any{"icon": "lucide:warning"},
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "a"}}},
|
||||
{Type: document.BlockNodeParagraph, Content: []document.Block{{Type: "text", Text: "b"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Callout with invalid item fails": {
|
||||
Input: Block{
|
||||
Type: BlockCallout,
|
||||
Items: []Block{{Type: "not_a_type"}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Blockquote with invalid item fails": {
|
||||
Input: Block{
|
||||
Type: BlockBlockquote,
|
||||
Items: []Block{{Type: "not_a_type"}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Bullet list with invalid item fails": {
|
||||
Input: Block{
|
||||
Type: BlockBulletList,
|
||||
Items: []Block{{Type: "not_a_type"}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Bullet list with invalid nested child fails": {
|
||||
Input: Block{
|
||||
Type: BlockBulletList,
|
||||
Items: []Block{{
|
||||
Type: BlockParagraph,
|
||||
Text: "one",
|
||||
Children: []Block{{Type: "not_a_type"}},
|
||||
}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Task list with invalid item fails": {
|
||||
Input: Block{
|
||||
Type: BlockTaskList,
|
||||
TaskItems: []TaskItem{{Block: Block{Type: "not_a_type"}}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Task list with invalid nested child fails": {
|
||||
Input: Block{
|
||||
Type: BlockTaskList,
|
||||
TaskItems: []TaskItem{{
|
||||
Block: Block{
|
||||
Type: BlockParagraph,
|
||||
Text: "one",
|
||||
Children: []Block{{Type: "not_a_type"}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Metric passes attrs through": {
|
||||
Input: Block{Type: BlockMetric, Attrs: map[string]any{"query": "up"}},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: map[string]any{"query": "up"},
|
||||
},
|
||||
},
|
||||
"Metric naming a preset simulates": {
|
||||
Input: Block{
|
||||
Type: BlockMetric,
|
||||
Attrs: map[string]any{"simulationPreset": "http_latency"},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: map[string]any{
|
||||
"simulationPreset": "http_latency",
|
||||
"simulationActive": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"Metric naming a preset simulates whatever flag was sent": {
|
||||
Input: Block{
|
||||
Type: BlockMetric,
|
||||
Attrs: map[string]any{
|
||||
"simulationPreset": "http_latency",
|
||||
"simulationActive": false,
|
||||
},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: map[string]any{
|
||||
"simulationPreset": "http_latency",
|
||||
"simulationActive": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"Metric naming no preset drops the flag it was sent": {
|
||||
Input: Block{
|
||||
Type: BlockMetric,
|
||||
Attrs: map[string]any{
|
||||
"simulationPreset": nil,
|
||||
"simulationActive": true,
|
||||
},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: map[string]any{"simulationPreset": nil},
|
||||
},
|
||||
},
|
||||
// the other half of the legacy round trip: what Compact read
|
||||
// back out has to expand into exactly what it came from.
|
||||
"Metric keeps a legacy config blob, nulls and unknown attrs": {
|
||||
Input: Block{
|
||||
Type: BlockMetric,
|
||||
UID: "m1",
|
||||
Attrs: map[string]any{
|
||||
"config": map[string]any{"type": "line_chart"},
|
||||
"visualizationType": nil,
|
||||
"wibble": 42,
|
||||
},
|
||||
},
|
||||
// the harness strips uids, so what is asserted here is that
|
||||
// every other attribute survived untouched; the uid's own
|
||||
// preservation is covered by the cases that check it.
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: map[string]any{
|
||||
"config": map[string]any{"type": "line_chart"},
|
||||
"visualizationType": nil,
|
||||
"wibble": 42,
|
||||
},
|
||||
},
|
||||
},
|
||||
"Metric grid wraps metric items": {
|
||||
Input: Block{
|
||||
Type: BlockMetricGrid,
|
||||
Items: []Block{{Type: BlockMetric, Attrs: map[string]any{"query": "up"}}},
|
||||
},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeMetricGrid,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeMetricBlock, Attrs: map[string]any{"query": "up"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Metric grid with invalid item fails": {
|
||||
Input: Block{
|
||||
Type: BlockMetricGrid,
|
||||
Items: []Block{{Type: "not_a_type"}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Split doc with invalid left fails": {
|
||||
Input: Block{
|
||||
Type: BlockSplitDoc,
|
||||
Left: []Block{{Type: "not_a_type"}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Split doc with invalid right fails": {
|
||||
Input: Block{
|
||||
Type: BlockSplitDoc,
|
||||
Left: []Block{{Type: BlockHeading, Text: "T", Attrs: map[string]any{"level": 1}}},
|
||||
Right: []Block{{Type: "not_a_type"}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Split doc with empty sides yields empty wrappers": {
|
||||
Input: Block{Type: BlockSplitDoc},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeSplitDoc,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeSplitDocLeft},
|
||||
{Type: document.BlockNodeSplitDocRight},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Image carries title and alt attrs": {
|
||||
Input: Block{Type: BlockImage, Attrs: map[string]any{"src": "http://x", "alt": "pic", "title": "shot"}},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeImageBlock,
|
||||
Attrs: map[string]any{"src": "http://x", "alt": "pic", "title": "shot"},
|
||||
},
|
||||
},
|
||||
"Titled code without title has an empty title node": {
|
||||
Input: Block{Type: BlockTitledCode, Text: "x", Attrs: map[string]any{"language": "go"}},
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeTitledCodeBlock,
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeCodeBlockTitle},
|
||||
{Type: document.BlockNodeCodeBlock, Attrs: map[string]any{"language": "go"}, Content: []document.Block{{Type: "text", Text: "x"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Unknown block type fails": {
|
||||
Input: Block{Type: "not_a_type"},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"File cannot be authored": {
|
||||
Input: Block{Type: BlockFile, Attrs: map[string]any{"src": "http://x", "name": "notes.zip"}},
|
||||
Err: errFileNotAuthored,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := Expand(c.Input)
|
||||
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.RoundTrip {
|
||||
require.NoError(t, Validate(c.Input), "input should validate")
|
||||
|
||||
compacted, cerr := Compact(got)
|
||||
require.NoError(t, cerr)
|
||||
|
||||
assert.Equal(t,
|
||||
stripUIDsCanonical(c.Input),
|
||||
stripUIDsCanonical(compacted),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if c.Check != nil {
|
||||
c.Check(t, got)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Expected, stripUIDsPM(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_TextContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Type document.BlockNodeType
|
||||
Text string
|
||||
Expected []document.Block
|
||||
}{
|
||||
"Paragraph text is read as markdown": {
|
||||
Type: document.BlockNodeParagraph,
|
||||
Text: "say **hi**",
|
||||
Expected: []document.Block{
|
||||
{Type: document.BlockNodeText, Text: "say "},
|
||||
{Type: document.BlockNodeText, Text: "hi", Marks: []document.Mark{{Type: "bold"}}},
|
||||
},
|
||||
},
|
||||
"Heading text is kept raw": {
|
||||
Type: document.BlockNodeHeading,
|
||||
Text: "The **my_var** flag",
|
||||
Expected: []document.Block{{Type: document.BlockNodeText, Text: "The **my_var** flag"}},
|
||||
},
|
||||
"Code text is kept raw": {
|
||||
Type: document.BlockNodeCodeBlock,
|
||||
Text: `my_var = "a\b" **1**`,
|
||||
Expected: []document.Block{{Type: document.BlockNodeText, Text: `my_var = "a\b" **1**`}},
|
||||
},
|
||||
"Titled code text is kept raw": {
|
||||
Type: document.BlockNodeTitledCodeBlock,
|
||||
Text: "a_b_c",
|
||||
Expected: []document.Block{{Type: document.BlockNodeText, Text: "a_b_c"}},
|
||||
},
|
||||
"Code block title is kept raw": {
|
||||
Type: document.BlockNodeCodeBlockTitle,
|
||||
Text: "GET /v1/_internal",
|
||||
Expected: []document.Block{{Type: document.BlockNodeText, Text: "GET /v1/_internal"}},
|
||||
},
|
||||
"Mermaid text is kept raw": {
|
||||
Type: document.BlockNodeMermaidBlock,
|
||||
Text: "graph TD; A_1-->B_2;",
|
||||
Expected: []document.Block{{Type: document.BlockNodeText, Text: "graph TD; A_1-->B_2;"}},
|
||||
},
|
||||
"Empty raw text has no content": {
|
||||
Type: document.BlockNodeCodeBlock,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Expected, TextContent(c.Type, c.Text))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,738 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// Inline mark types used in canonical-markdown output.
|
||||
const (
|
||||
_markBold = "bold"
|
||||
_markItalic = "italic"
|
||||
_markUnderline = "underline"
|
||||
_markStrike = "strike"
|
||||
_markCode = "code"
|
||||
_markLink = "link"
|
||||
)
|
||||
|
||||
// _escapeGrowHeadroom is the extra builder capacity reserved for the
|
||||
// backslashes escaping inserts.
|
||||
const _escapeGrowHeadroom = 4
|
||||
|
||||
// _attrHref is the attribute key carrying a link mark's target URL.
|
||||
const _attrHref = "href"
|
||||
|
||||
// closeMisses records, for one span, the first start from which an
|
||||
// underscore close scan failed. A later scan only covers less of the
|
||||
// span, so it is skipped, which keeps the parse from going cubic. Zero
|
||||
// means no failure yet, since no scan starts at 0.
|
||||
type closeMisses struct {
|
||||
// plain is the miss for an underscore opened outside a word.
|
||||
plain int
|
||||
|
||||
// strict is the miss for one opened inside a word.
|
||||
strict int
|
||||
}
|
||||
|
||||
// activeMark is one mark in the open-stack used during emit.
|
||||
type activeMark struct {
|
||||
// kind is the mark type ("bold", "italic", "link", …).
|
||||
kind string
|
||||
|
||||
// href is set when kind == _markLink.
|
||||
href string
|
||||
}
|
||||
|
||||
// inlineAtom is a maximal run of text sharing the same ordered set
|
||||
// of marks. The parser produces a stream of atoms which are then
|
||||
// merged into ProseMirror text nodes.
|
||||
type inlineAtom struct {
|
||||
// text is the literal run.
|
||||
text string
|
||||
|
||||
// marks is the ordered set of marks active over text. The order
|
||||
// matches the open order so emit can re-derive the same
|
||||
// nesting.
|
||||
marks []activeMark
|
||||
}
|
||||
|
||||
// ParseInlineMarkdown converts a minimal-markdown string into a slice
|
||||
// of ProseMirror text nodes with marks. The supported syntax is:
|
||||
//
|
||||
// **bold** → mark bold
|
||||
// *italic* → mark italic
|
||||
// _underline_ → mark underline, unless both ends sit inside a word,
|
||||
// so my_var_name is literal
|
||||
// ~~strike~~ → mark strike
|
||||
// `code` → mark code (literal; no inner parsing)
|
||||
// [label](url) → mark link with href=url; label is recursively parsed
|
||||
// \X → literal X (backslash escape)
|
||||
//
|
||||
// Unmatched delimiters are emitted as literal text. Newlines inside
|
||||
// the input are preserved verbatim; the canonical block model treats
|
||||
// multi-paragraph content as separate paragraph blocks, so a single
|
||||
// block's text should not normally contain newlines.
|
||||
//
|
||||
// An empty input returns a nil slice (a valid representation of an
|
||||
// empty paragraph).
|
||||
func ParseInlineMarkdown(input string) []document.Block {
|
||||
if input == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
atoms := parseSpan(input, nil)
|
||||
|
||||
return atomsToTextNodes(atoms)
|
||||
}
|
||||
|
||||
// emitInlineMarkdown is the inverse of ParseInlineMarkdown: it
|
||||
// renders a slice of ProseMirror inline text nodes back into the
|
||||
// canonical-markdown subset. Round-tripping parse(emit(x)) is
|
||||
// idempotent at the level of (text, mark-set) atoms — text nodes
|
||||
// with the same mark-set are merged on emit.
|
||||
func emitInlineMarkdown(nodes []document.Block) string {
|
||||
var (
|
||||
buf strings.Builder
|
||||
active []activeMark
|
||||
)
|
||||
|
||||
for _, n := range nodes {
|
||||
if n.Type != document.BlockNodeText {
|
||||
continue
|
||||
}
|
||||
|
||||
desired := extractMarks(n.Marks)
|
||||
|
||||
// A code span cannot represent a literal backtick, so text
|
||||
// carrying one falls back to escaped plain text without the
|
||||
// code mark.
|
||||
if strings.ContainsRune(n.Text, '`') {
|
||||
desired = slices.DeleteFunc(desired, func(m activeMark) bool {
|
||||
return m.kind == _markCode
|
||||
})
|
||||
}
|
||||
|
||||
// ProseMirror mark order is not semantic, so reorder the
|
||||
// desired marks to share the longest possible prefix with
|
||||
// the open stack: keeping delimiters open avoids ambiguous
|
||||
// adjacent delimiter runs.
|
||||
desired = reorderMarks(desired, active)
|
||||
|
||||
// Close marks that are no longer desired, in reverse open
|
||||
// order, until the active stack is a prefix of desired.
|
||||
for len(active) > 0 {
|
||||
if isMarkPrefix(active, desired) {
|
||||
break
|
||||
}
|
||||
|
||||
buf.WriteString(closeDelim(active[len(active)-1]))
|
||||
|
||||
active = active[:len(active)-1]
|
||||
}
|
||||
|
||||
// Open the marks present in desired but not yet active. We
|
||||
// open them in the order they appear in desired so the
|
||||
// nesting matches the parse output.
|
||||
for _, m := range desired {
|
||||
if !slices.Contains(active, m) {
|
||||
buf.WriteString(openDelim(m))
|
||||
active = append(active, m)
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString(escapeMarkdown(n.Text, active))
|
||||
}
|
||||
|
||||
// Close any marks still open at the end.
|
||||
for _, v := range slices.Backward(active) {
|
||||
buf.WriteString(closeDelim(v))
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// parseSpan parses the entire input string with the given outer
|
||||
// mark stack already active. It is called recursively for the
|
||||
// contents of bold/italic/underline/strike spans and link labels.
|
||||
func parseSpan(s string, outer []activeMark) []inlineAtom { //nolint:gocognit // this function is complex, however, it's well-structured
|
||||
var out []inlineAtom
|
||||
|
||||
cur := strings.Builder{}
|
||||
flush := func() {
|
||||
if cur.Len() == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
out = append(out, inlineAtom{
|
||||
text: cur.String(),
|
||||
marks: append([]activeMark(nil), outer...),
|
||||
})
|
||||
cur.Reset()
|
||||
}
|
||||
|
||||
var misses closeMisses
|
||||
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
c := s[i]
|
||||
|
||||
// Backslash escape: consume the next byte as literal.
|
||||
if c == '\\' && i+1 < len(s) {
|
||||
cur.WriteByte(s[i+1])
|
||||
i += 2
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Code span: literal until matching backtick. No parsing
|
||||
// inside.
|
||||
if c == '`' {
|
||||
if end := strings.IndexByte(s[i+1:], '`'); end >= 0 {
|
||||
flush()
|
||||
|
||||
out = append(out, inlineAtom{
|
||||
text: s[i+1 : i+1+end],
|
||||
marks: append(append([]activeMark(nil), outer...), activeMark{kind: _markCode}),
|
||||
})
|
||||
i = i + 1 + end + 1
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Link: [label](url). The label is recursively parsed; the
|
||||
// url is taken literally (no inline parsing inside).
|
||||
if c == '[' {
|
||||
if label, href, end, ok := scanLink(s, i); ok {
|
||||
flush()
|
||||
|
||||
linkMark := activeMark{kind: _markLink, href: href}
|
||||
inner := parseSpan(label, append(append([]activeMark(nil), outer...), linkMark))
|
||||
out = append(out, inner...)
|
||||
i = end
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Two-character mark delimiters: ** and ~~. Check before
|
||||
// the single-character ones so "**" is not consumed as two
|
||||
// italics. If no close is found, consume both bytes as
|
||||
// literal so we don't fall through to the single-char
|
||||
// branch and treat ** as ***italic*italic*.
|
||||
if i+1 < len(s) { //nolint:nestif // the branching is sequential and readable
|
||||
pair := s[i : i+2]
|
||||
|
||||
if pair == "**" || pair == "~~" {
|
||||
mark := _markBold
|
||||
if pair == "~~" {
|
||||
mark = _markStrike
|
||||
}
|
||||
|
||||
if end := scanDelimClose(s, i+len(pair), pair, false); end >= 0 {
|
||||
flush()
|
||||
|
||||
inner := parseSpan(s[i+len(pair):end], append(append([]activeMark(nil), outer...), activeMark{kind: mark}))
|
||||
out = append(out, inner...)
|
||||
i = end + len(pair)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
cur.WriteString(pair)
|
||||
|
||||
i += 2
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Single-character mark delimiters: * and _.
|
||||
if c == '*' || c == '_' {
|
||||
mark := _markItalic
|
||||
if c == '_' {
|
||||
mark = _markUnderline
|
||||
}
|
||||
|
||||
if end := scanSingleClose(s, i, &misses); end >= 0 {
|
||||
flush()
|
||||
|
||||
inner := parseSpan(s[i+1:end], append(append([]activeMark(nil), outer...), activeMark{kind: mark}))
|
||||
out = append(out, inner...)
|
||||
i = end + 1
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
cur.WriteByte(c)
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
flush()
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// scanLink tries to parse a markdown link starting at s[i] == '['.
|
||||
// It returns the label (raw, still containing markdown), the href,
|
||||
// the byte offset just after the closing ')', and ok=true on a
|
||||
// successful match. On any malformed shape it returns ok=false and
|
||||
// the caller treats the '[' as literal.
|
||||
func scanLink(s string, i int) (label, href string, end int, ok bool) {
|
||||
// Find the matching ']'. Allow escaped brackets inside the
|
||||
// label and skip over backtick code spans so a `]` inside code
|
||||
// doesn't end the label.
|
||||
j := i + 1
|
||||
|
||||
for j < len(s) {
|
||||
switch s[j] {
|
||||
case '\\':
|
||||
j += 2
|
||||
|
||||
continue
|
||||
case '`':
|
||||
if k := strings.IndexByte(s[j+1:], '`'); k >= 0 {
|
||||
j = j + 1 + k + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return "", "", 0, false
|
||||
case ']':
|
||||
if j+1 < len(s) && s[j+1] == '(' {
|
||||
hrefStart := j + len("](")
|
||||
|
||||
end := scanHrefClose(s, hrefStart)
|
||||
if end < 0 {
|
||||
return "", "", 0, false
|
||||
}
|
||||
|
||||
return s[i+1 : j], unescapeLinkHref(s[hrefStart:end]), end + 1, true
|
||||
}
|
||||
|
||||
return "", "", 0, false
|
||||
}
|
||||
|
||||
j++
|
||||
}
|
||||
|
||||
return "", "", 0, false
|
||||
}
|
||||
|
||||
// scanHrefClose returns the byte offset of the ')' closing a link
|
||||
// href starting at start, or -1 when there is none. An escaped ')'
|
||||
// belongs to the URL and does not close it.
|
||||
func scanHrefClose(s string, start int) int {
|
||||
for i := start; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if s[i] == ')' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// scanSingleClose returns where the * or _ span opening at byte i of s
|
||||
// closes, or -1, reading and extending the span's misses.
|
||||
func scanSingleClose(s string, i int, misses *closeMisses) int {
|
||||
if s[i] == '*' {
|
||||
return scanDelimClose(s, i+1, "*", false)
|
||||
}
|
||||
|
||||
// an underscore after a word character closes only before a non-word
|
||||
// one, so my_var_name stays literal.
|
||||
strict := wordBefore(s, i)
|
||||
|
||||
miss := &misses.plain
|
||||
if strict {
|
||||
miss = &misses.strict
|
||||
}
|
||||
|
||||
if *miss > 0 && i+1 >= *miss {
|
||||
return -1
|
||||
}
|
||||
|
||||
end := scanDelimClose(s, i+1, "_", strict)
|
||||
if end < 0 {
|
||||
*miss = i + 1
|
||||
}
|
||||
|
||||
return end
|
||||
}
|
||||
|
||||
// scanDelimClose returns the byte offset of the closing delimiter
|
||||
// matching delim starting from byte position start, or -1 if no
|
||||
// closing delimiter is found. It skips over escapes, code spans,
|
||||
// and link labels so a delimiter inside those does not end the span.
|
||||
//
|
||||
// For two-character delimiters it also tracks lone occurrences of
|
||||
// the delimiter character: when a match sits at the start of a
|
||||
// three-plus run and a lone character is pending inside the span
|
||||
// (as in "**a*b***" or "***ab***"), the run's first character
|
||||
// closes the inner single-character span, so the real close starts
|
||||
// one character later.
|
||||
//
|
||||
// strict skips a close followed by a word character.
|
||||
func scanDelimClose(s string, start int, delim string, strict bool) int {
|
||||
lone := 0
|
||||
i := start
|
||||
|
||||
for i < len(s) {
|
||||
c := s[i]
|
||||
|
||||
if c == '\\' && i+1 < len(s) {
|
||||
i += 2
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if c == '`' {
|
||||
if k := strings.IndexByte(s[i+1:], '`'); k >= 0 {
|
||||
i = i + 1 + k + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if c == '[' {
|
||||
if _, _, end, ok := scanLink(s, i); ok {
|
||||
i = end
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if strict && strings.HasPrefix(s[i:], delim) && wordAt(s, i+len(delim)) {
|
||||
i += len(delim)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(s[i:], delim) {
|
||||
if len(delim) == 2 && lone%2 == 1 && i+len(delim) < len(s) && s[i+len(delim)] == delim[0] {
|
||||
lone--
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
|
||||
if len(delim) == 2 && c == delim[0] {
|
||||
lone++
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// wordBefore reports whether the character ending at byte i of s is a
|
||||
// letter or digit.
|
||||
func wordBefore(s string, i int) bool {
|
||||
r, _ := utf8.DecodeLastRuneInString(s[:i])
|
||||
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
// wordAt reports whether the character starting at byte i of s is a
|
||||
// letter or digit.
|
||||
func wordAt(s string, i int) bool {
|
||||
r, _ := utf8.DecodeRuneInString(s[i:])
|
||||
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
// atomsToTextNodes merges adjacent atoms with identical mark sets
|
||||
// and emits ProseMirror text nodes.
|
||||
func atomsToTextNodes(atoms []inlineAtom) []document.Block {
|
||||
if len(atoms) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]document.Block, 0, len(atoms))
|
||||
|
||||
var (
|
||||
buf strings.Builder
|
||||
curMarks []activeMark
|
||||
haveOpen bool
|
||||
)
|
||||
|
||||
flush := func() {
|
||||
if !haveOpen || buf.Len() == 0 {
|
||||
buf.Reset()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
out = append(out, document.Block{
|
||||
Type: document.BlockNodeText,
|
||||
Text: buf.String(),
|
||||
Marks: marksToDocument(curMarks),
|
||||
})
|
||||
buf.Reset()
|
||||
}
|
||||
|
||||
for _, a := range atoms {
|
||||
if a.text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !haveOpen || !marksEqual(curMarks, a.marks) {
|
||||
flush()
|
||||
|
||||
curMarks = a.marks
|
||||
haveOpen = true
|
||||
}
|
||||
|
||||
buf.WriteString(a.text)
|
||||
}
|
||||
|
||||
flush()
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// marksEqual reports whether two mark stacks describe the same set
|
||||
// of active marks. Order matters because nested marks emit in open
|
||||
// order.
|
||||
func marksEqual(a, b []activeMark) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range a {
|
||||
if a[i].kind != b[i].kind || a[i].href != b[i].href {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// marksToDocument converts the parser's internal mark stack into
|
||||
// document.Mark values suitable for a ProseMirror text node.
|
||||
func marksToDocument(marks []activeMark) []document.Mark {
|
||||
if len(marks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]document.Mark, 0, len(marks))
|
||||
|
||||
for _, m := range marks {
|
||||
dm := document.Mark{Type: m.kind}
|
||||
if m.kind == _markLink {
|
||||
dm.Attrs = document.Attributes{_attrHref: m.href}
|
||||
}
|
||||
|
||||
out = append(out, dm)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// extractMarks converts a document.Mark slice into the parser's
|
||||
// internal mark stack. Marks of unknown types are dropped.
|
||||
func extractMarks(marks []document.Mark) []activeMark {
|
||||
if len(marks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]activeMark, 0, len(marks))
|
||||
|
||||
for _, m := range marks {
|
||||
switch m.Type {
|
||||
case _markBold, _markItalic, _markUnderline, _markStrike, _markCode:
|
||||
out = append(out, activeMark{kind: m.Type})
|
||||
case _markLink:
|
||||
var href string
|
||||
|
||||
if a, ok := m.Attrs.Get(_attrHref); ok {
|
||||
href = a.String()
|
||||
}
|
||||
|
||||
out = append(out, activeMark{kind: _markLink, href: href})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// reorderMarks returns desired reordered so that its common prefix
|
||||
// with active is as long as possible; the marks not shared with
|
||||
// active keep their original relative order.
|
||||
func reorderMarks(desired, active []activeMark) []activeMark {
|
||||
if len(desired) == 0 || len(active) == 0 {
|
||||
return desired
|
||||
}
|
||||
|
||||
out := make([]activeMark, 0, len(desired))
|
||||
rest := slices.Clone(desired)
|
||||
|
||||
for _, a := range active {
|
||||
i := slices.Index(rest, a)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
out = append(out, rest[i])
|
||||
rest = slices.Delete(rest, i, i+1)
|
||||
}
|
||||
|
||||
return append(out, rest...)
|
||||
}
|
||||
|
||||
// isMarkPrefix reports whether prefix is an order-preserving prefix
|
||||
// of full.
|
||||
func isMarkPrefix(prefix, full []activeMark) bool {
|
||||
if len(prefix) > len(full) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i, p := range prefix {
|
||||
if p.kind != full[i].kind || p.href != full[i].href {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// openDelim returns the markdown opening delimiter for a mark.
|
||||
func openDelim(m activeMark) string {
|
||||
switch m.kind {
|
||||
case _markBold:
|
||||
return "**"
|
||||
case _markItalic:
|
||||
return "*"
|
||||
case _markUnderline:
|
||||
return "_"
|
||||
case _markStrike:
|
||||
return "~~"
|
||||
case _markCode:
|
||||
return "`"
|
||||
case _markLink:
|
||||
return "["
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// closeDelim returns the markdown closing delimiter for a mark.
|
||||
// Most marks use the same delimiter for open and close; link uses
|
||||
// "](url)".
|
||||
func closeDelim(m activeMark) string {
|
||||
switch m.kind {
|
||||
case _markBold:
|
||||
return "**"
|
||||
case _markItalic:
|
||||
return "*"
|
||||
case _markUnderline:
|
||||
return "_"
|
||||
case _markStrike:
|
||||
return "~~"
|
||||
case _markCode:
|
||||
return "`"
|
||||
case _markLink:
|
||||
return "](" + escapeLinkHref(m.href) + ")"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// escapeLinkHref escapes the characters that would otherwise end the
|
||||
// URL early when the emitted link is parsed back, so an href such as
|
||||
// ".../Go_(programming_language)" survives the round trip.
|
||||
func escapeLinkHref(href string) string {
|
||||
if !strings.ContainsAny(href, "\\)") {
|
||||
return href
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.Grow(len(href) + _escapeGrowHeadroom)
|
||||
|
||||
for i := range len(href) {
|
||||
c := href[i]
|
||||
if c == '\\' || c == ')' {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
|
||||
b.WriteByte(c)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// unescapeLinkHref is the inverse of escapeLinkHref: it drops the
|
||||
// backslash from every escaped pair, mirroring how parseSpan treats
|
||||
// escapes in ordinary text.
|
||||
func unescapeLinkHref(href string) string {
|
||||
if !strings.Contains(href, "\\") {
|
||||
return href
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.Grow(len(href))
|
||||
|
||||
for i := 0; i < len(href); i++ {
|
||||
if href[i] == '\\' && i+1 < len(href) {
|
||||
i++
|
||||
}
|
||||
|
||||
b.WriteByte(href[i])
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// escapeMarkdown returns text with markdown metacharacters escaped
|
||||
// for emission. Inside a code span, no escaping is performed because
|
||||
// code content is literal.
|
||||
func escapeMarkdown(text string, active []activeMark) string {
|
||||
for _, m := range active {
|
||||
if m.kind == _markCode {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.ContainsAny(text, "\\*_~`[]") {
|
||||
return text
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.Grow(len(text) + _escapeGrowHeadroom)
|
||||
|
||||
for i := range len(text) {
|
||||
c := text[i]
|
||||
switch c {
|
||||
case '\\', '*', '_', '~', '`', '[', ']':
|
||||
b.WriteByte('\\')
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -1,574 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_ParseInlineMarkdown(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Input string
|
||||
Expected []document.Block
|
||||
}{
|
||||
"Plain text": {
|
||||
Input: "hello world",
|
||||
Expected: []document.Block{{Type: "text", Text: "hello world"}},
|
||||
},
|
||||
"Empty string": {
|
||||
Input: "",
|
||||
Expected: nil,
|
||||
},
|
||||
"Bold span": {
|
||||
Input: "say **hi** there",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "say "},
|
||||
{Type: "text", Text: "hi", Marks: []document.Mark{{Type: "bold"}}},
|
||||
{Type: "text", Text: " there"},
|
||||
},
|
||||
},
|
||||
"Italic span": {
|
||||
Input: "*ital* x",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "ital", Marks: []document.Mark{{Type: "italic"}}},
|
||||
{Type: "text", Text: " x"},
|
||||
},
|
||||
},
|
||||
"Underline span": {
|
||||
Input: "_under_ x",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "under", Marks: []document.Mark{{Type: "underline"}}},
|
||||
{Type: "text", Text: " x"},
|
||||
},
|
||||
},
|
||||
"Underscores inside a word at both ends are literal": {
|
||||
Input: "my_var_name and a_b_c",
|
||||
Expected: []document.Block{{Type: "text", Text: "my_var_name and a_b_c"}},
|
||||
},
|
||||
"Underline span ending a word": {
|
||||
Input: "foo_bar_",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "foo"},
|
||||
{Type: "text", Text: "bar", Marks: []document.Mark{{Type: "underline"}}},
|
||||
},
|
||||
},
|
||||
"Underline span starting a word": {
|
||||
Input: "_foo_bar",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "foo", Marks: []document.Mark{{Type: "underline"}}},
|
||||
{Type: "text", Text: "bar"},
|
||||
},
|
||||
},
|
||||
"Underline span inside curly quotes": {
|
||||
Input: "“_term_”",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "“"},
|
||||
{Type: "text", Text: "term", Marks: []document.Mark{{Type: "underline"}}},
|
||||
{Type: "text", Text: "”"},
|
||||
},
|
||||
},
|
||||
"Underline span next to punctuation": {
|
||||
Input: "(_under_).",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "("},
|
||||
{Type: "text", Text: "under", Marks: []document.Mark{{Type: "underline"}}},
|
||||
{Type: "text", Text: ")."},
|
||||
},
|
||||
},
|
||||
"Underline span opened outside a word closes at the next underscore": {
|
||||
Input: "_snake_case x",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "snake", Marks: []document.Mark{{Type: "underline"}}},
|
||||
{Type: "text", Text: "case x"},
|
||||
},
|
||||
},
|
||||
"Underline span opened inside a word skips a close inside one": {
|
||||
Input: "my_var_name and_x_",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "my"},
|
||||
{Type: "text", Text: "var_name and_x", Marks: []document.Mark{{Type: "underline"}}},
|
||||
},
|
||||
},
|
||||
"Underscores inside a word of multi-byte letters are literal": {
|
||||
Input: "é_x_é",
|
||||
Expected: []document.Block{{Type: "text", Text: "é_x_é"}},
|
||||
},
|
||||
// each opener would rescan to the end without the failed scan
|
||||
// being remembered.
|
||||
"Unclosed underscores inside words are scanned once": {
|
||||
Input: strings.Repeat("a_a ", 50000),
|
||||
Expected: []document.Block{{Type: "text", Text: strings.Repeat("a_a ", 50000)}},
|
||||
},
|
||||
"Strike span": {
|
||||
Input: "~~gone~~",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "gone", Marks: []document.Mark{{Type: "strike"}}},
|
||||
},
|
||||
},
|
||||
"Code span is literal": {
|
||||
Input: "use `os.Exit(1)` then",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "use "},
|
||||
{Type: "text", Text: "os.Exit(1)", Marks: []document.Mark{{Type: "code"}}},
|
||||
{Type: "text", Text: " then"},
|
||||
},
|
||||
},
|
||||
"Code span does not parse inner markdown": {
|
||||
Input: "`**not bold**`",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "**not bold**", Marks: []document.Mark{{Type: "code"}}},
|
||||
},
|
||||
},
|
||||
"Bold and underline nested": {
|
||||
Input: "**bold _and_ under**",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "bold ", Marks: []document.Mark{{Type: "bold"}}},
|
||||
{Type: "text", Text: "and", Marks: []document.Mark{{Type: "bold"}, {Type: "underline"}}},
|
||||
{Type: "text", Text: " under", Marks: []document.Mark{{Type: "bold"}}},
|
||||
},
|
||||
},
|
||||
"Link with markdown label": {
|
||||
Input: "[**oxy**](https://x)",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "oxy", Marks: []document.Mark{
|
||||
{Type: "link", Attrs: map[string]any{"href": "https://x"}},
|
||||
{Type: "bold"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
"Plain link": {
|
||||
Input: "see [docs](https://docs)",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "see "},
|
||||
{Type: "text", Text: "docs", Marks: []document.Mark{
|
||||
{Type: "link", Attrs: map[string]any{"href": "https://docs"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
"Escaped asterisks are literal": {
|
||||
Input: `\*not italic\*`,
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "*not italic*"},
|
||||
},
|
||||
},
|
||||
"Unmatched bold becomes literal": {
|
||||
Input: "**unfinished",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "**unfinished"},
|
||||
},
|
||||
},
|
||||
"Unmatched bracket becomes literal": {
|
||||
Input: "[label without",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "[label without"},
|
||||
},
|
||||
},
|
||||
"Empty bold span": {
|
||||
Input: "x ** ** y",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "x "},
|
||||
{Type: "text", Text: " ", Marks: []document.Mark{{Type: "bold"}}},
|
||||
{Type: "text", Text: " y"},
|
||||
},
|
||||
},
|
||||
"Adjacent same-mark runs merge": {
|
||||
Input: "**a****b**",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "ab", Marks: []document.Mark{{Type: "bold"}}},
|
||||
},
|
||||
},
|
||||
"Bold run extended by a bold italic tail": {
|
||||
Input: "**a*b***",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "a", Marks: []document.Mark{{Type: "bold"}}},
|
||||
{Type: "text", Text: "b", Marks: []document.Mark{{Type: "bold"}, {Type: "italic"}}},
|
||||
},
|
||||
},
|
||||
"Bold wrapping italic via a triple run": {
|
||||
Input: "***bold italic***",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "bold italic", Marks: []document.Mark{{Type: "bold"}, {Type: "italic"}}},
|
||||
},
|
||||
},
|
||||
"Backtick inside link label": {
|
||||
Input: "[`x`](https://y)",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "x", Marks: []document.Mark{
|
||||
{Type: "link", Attrs: map[string]any{"href": "https://y"}},
|
||||
{Type: "code"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
"Escaped bracket inside link label": {
|
||||
Input: `[a\]b](https://u)`,
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "a]b", Marks: []document.Mark{
|
||||
{Type: "link", Attrs: map[string]any{"href": "https://u"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
"Unclosed backtick in link label is literal": {
|
||||
Input: "[`x](u)",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "[`x](u)"},
|
||||
},
|
||||
},
|
||||
"Bracket without url part is literal": {
|
||||
Input: "[x]y",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "[x]y"},
|
||||
},
|
||||
},
|
||||
"Escaped paren stays inside the href": {
|
||||
Input: `[Go](https://x/Go_\(lang\))`,
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "Go", Marks: []document.Mark{
|
||||
{Type: "link", Attrs: map[string]any{"href": "https://x/Go_(lang)"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
"Link with missing closing paren is literal": {
|
||||
Input: "[x](u",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "[x](u"},
|
||||
},
|
||||
},
|
||||
"Escape inside bold span": {
|
||||
Input: `**a\*b**`,
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "a*b", Marks: []document.Mark{{Type: "bold"}}},
|
||||
},
|
||||
},
|
||||
"Code span inside bold span": {
|
||||
Input: "**`*`**",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "*", Marks: []document.Mark{{Type: "bold"}, {Type: "code"}}},
|
||||
},
|
||||
},
|
||||
"Unclosed backtick inside bold span is literal": {
|
||||
Input: "**a`b**",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "a`b", Marks: []document.Mark{{Type: "bold"}}},
|
||||
},
|
||||
},
|
||||
"Link inside italic span": {
|
||||
Input: "*a [l](https://u) b*",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "a ", Marks: []document.Mark{{Type: "italic"}}},
|
||||
{Type: "text", Text: "l", Marks: []document.Mark{
|
||||
{Type: "italic"},
|
||||
{Type: "link", Attrs: map[string]any{"href": "https://u"}},
|
||||
}},
|
||||
{Type: "text", Text: " b", Marks: []document.Mark{{Type: "italic"}}},
|
||||
},
|
||||
},
|
||||
"Empty link label and url yields nothing": {
|
||||
Input: "[]()",
|
||||
Expected: nil,
|
||||
},
|
||||
"Empty code span is dropped": {
|
||||
Input: "a``b",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "ab"},
|
||||
},
|
||||
},
|
||||
"Escape at end of input is literal": {
|
||||
Input: `a\`,
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: `a\`},
|
||||
},
|
||||
},
|
||||
"Adjacent different marks stay separate": {
|
||||
Input: "*a*_b_",
|
||||
Expected: []document.Block{
|
||||
{Type: "text", Text: "a", Marks: []document.Mark{{Type: "italic"}}},
|
||||
{Type: "text", Text: "b", Marks: []document.Mark{{Type: "underline"}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := ParseInlineMarkdown(c.Input)
|
||||
assert.Equal(t, c.Expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_emitInlineMarkdown(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Input []document.Block
|
||||
Expected string
|
||||
|
||||
// Markdown asserts a round trip instead of an emitted string:
|
||||
// parsing it, emitting the result and parsing again has to yield
|
||||
// the same atoms.
|
||||
Markdown string
|
||||
}{
|
||||
"Round trip: plain text": {Markdown: "plain text"},
|
||||
"Round trip: bold": {Markdown: "**bold**"},
|
||||
"Round trip: bold wrapping italic": {Markdown: "***both***"},
|
||||
"Round trip: italic": {Markdown: "*italic*"},
|
||||
"Round trip: underline": {Markdown: "_underline_"},
|
||||
"Round trip: strike": {Markdown: "~~strike~~"},
|
||||
"Round trip: code": {Markdown: "`code`"},
|
||||
"Round trip: link": {Markdown: "[label](https://x)"},
|
||||
"Round trip: mixed marks": {Markdown: "mix **bold** and *italic* and `code`"},
|
||||
"Round trip: escaped literal": {Markdown: `escaped \* literal`},
|
||||
"Round trip: link inside bold": {Markdown: "**[link inside bold](https://y)**"},
|
||||
"Round trip: strike with code mark": {Markdown: "~~strike with `code` inside~~"},
|
||||
"Round trip: parenthesised href": {Markdown: `[Go](https://en.wikipedia.org/wiki/Go_\(programming_language\))`},
|
||||
"Round trip: bracket in label": {Markdown: `[label with \] bracket](https://x)`},
|
||||
"Plain text": {
|
||||
Input: []document.Block{{Type: "text", Text: "hello"}},
|
||||
Expected: "hello",
|
||||
},
|
||||
"Bold only": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "hi", Marks: []document.Mark{{Type: "bold"}}},
|
||||
},
|
||||
Expected: "**hi**",
|
||||
},
|
||||
"Plain then bold": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "say "},
|
||||
{Type: "text", Text: "hi", Marks: []document.Mark{{Type: "bold"}}},
|
||||
},
|
||||
Expected: "say **hi**",
|
||||
},
|
||||
"Special characters escape in plain text": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "use * carefully"},
|
||||
},
|
||||
Expected: `use \* carefully`,
|
||||
},
|
||||
"Round trip: underline around a snake case word": {Markdown: "_snake_case_ and my\\_var"},
|
||||
"Underline touching a word survives a round trip": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "“foo"},
|
||||
{Type: "text", Text: "bar", Marks: []document.Mark{{Type: "underline"}}},
|
||||
{Type: "text", Text: "” my_var"},
|
||||
},
|
||||
Expected: "“foo_bar_” my\\_var",
|
||||
},
|
||||
"Code text with a backtick falls back to escaped plain text": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "a`b", Marks: []document.Mark{{Type: "code"}}},
|
||||
},
|
||||
Expected: "a\\`b",
|
||||
},
|
||||
"Code mark does not escape inside": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "**", Marks: []document.Mark{{Type: "code"}}},
|
||||
},
|
||||
Expected: "`**`",
|
||||
},
|
||||
"Link emit": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "x", Marks: []document.Mark{
|
||||
{Type: "link", Attrs: map[string]any{"href": "https://y"}},
|
||||
}},
|
||||
},
|
||||
Expected: "[x](https://y)",
|
||||
},
|
||||
"Link emit escapes a paren in the href": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "x", Marks: []document.Mark{
|
||||
{Type: "link", Attrs: map[string]any{"href": "https://y/a_(b)"}},
|
||||
}},
|
||||
},
|
||||
Expected: `[x](https://y/a_(b\))`,
|
||||
},
|
||||
"Non-text nodes are skipped": {
|
||||
Input: []document.Block{
|
||||
{Type: document.BlockNodeParagraph},
|
||||
{Type: "text", Text: "hi"},
|
||||
},
|
||||
Expected: "hi",
|
||||
},
|
||||
"Incompatible mark order reorders to extend the open run": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "a", Marks: []document.Mark{{Type: "bold"}}},
|
||||
{Type: "text", Text: "b", Marks: []document.Mark{{Type: "italic"}, {Type: "bold"}}},
|
||||
},
|
||||
Expected: "**a*b***",
|
||||
},
|
||||
"Nested mark closes when the next run drops it": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "a", Marks: []document.Mark{{Type: "bold"}, {Type: "italic"}}},
|
||||
{Type: "text", Text: "b", Marks: []document.Mark{{Type: "bold"}}},
|
||||
},
|
||||
Expected: "***a*b**",
|
||||
},
|
||||
"Disjoint mark pair closes the unshared active mark": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "a", Marks: []document.Mark{{Type: "bold"}}},
|
||||
{Type: "text", Text: "b", Marks: []document.Mark{{Type: "italic"}, {Type: "underline"}}},
|
||||
},
|
||||
Expected: "**a***_b_*",
|
||||
},
|
||||
"Disjoint marks close and reopen": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "a", Marks: []document.Mark{{Type: "bold"}}},
|
||||
{Type: "text", Text: "b", Marks: []document.Mark{{Type: "italic"}}},
|
||||
},
|
||||
Expected: "**a***b*",
|
||||
},
|
||||
"Link href change closes and reopens the link": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "a", Marks: []document.Mark{
|
||||
{Type: "link", Attrs: map[string]any{"href": "https://one"}},
|
||||
}},
|
||||
{Type: "text", Text: "b", Marks: []document.Mark{
|
||||
{Type: "link", Attrs: map[string]any{"href": "https://two"}},
|
||||
}},
|
||||
},
|
||||
Expected: "[a](https://one)[b](https://two)",
|
||||
},
|
||||
"Unknown mark types are dropped": {
|
||||
Input: []document.Block{
|
||||
{Type: "text", Text: "hi", Marks: []document.Mark{{Type: "sparkle"}}},
|
||||
},
|
||||
Expected: "hi",
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if c.Markdown != "" {
|
||||
parsed := ParseInlineMarkdown(c.Markdown)
|
||||
emitted := emitInlineMarkdown(parsed)
|
||||
|
||||
require.Equal(t, parsed, ParseInlineMarkdown(emitted),
|
||||
"parse(emit(parse(x))) must equal parse(x); emit produced %q", emitted,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
got := emitInlineMarkdown(c.Input)
|
||||
assert.Equal(t, c.Expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_openDelim(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Mark activeMark
|
||||
Result string
|
||||
}{
|
||||
"Bold": {Mark: activeMark{kind: "bold"}, Result: "**"},
|
||||
"Italic": {Mark: activeMark{kind: "italic"}, Result: "*"},
|
||||
"Underline": {Mark: activeMark{kind: "underline"}, Result: "_"},
|
||||
"Strike": {Mark: activeMark{kind: "strike"}, Result: "~~"},
|
||||
"Code": {Mark: activeMark{kind: "code"}, Result: "`"},
|
||||
"Link": {Mark: activeMark{kind: "link", href: "https://x"}, Result: "["},
|
||||
"Unknown": {Mark: activeMark{kind: "sparkle"}, Result: ""},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, openDelim(c.Mark))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_closeDelim(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Mark activeMark
|
||||
Result string
|
||||
}{
|
||||
"Bold": {Mark: activeMark{kind: "bold"}, Result: "**"},
|
||||
"Italic": {Mark: activeMark{kind: "italic"}, Result: "*"},
|
||||
"Underline": {Mark: activeMark{kind: "underline"}, Result: "_"},
|
||||
"Strike": {Mark: activeMark{kind: "strike"}, Result: "~~"},
|
||||
"Code": {Mark: activeMark{kind: "code"}, Result: "`"},
|
||||
"Link": {Mark: activeMark{kind: "link", href: "https://x"}, Result: "](https://x)"},
|
||||
"Link with a parenthesis in the href": {
|
||||
Mark: activeMark{kind: "link", href: "https://x/a_(b)"},
|
||||
Result: `](https://x/a_(b\))`,
|
||||
},
|
||||
"Unknown": {Mark: activeMark{kind: "sparkle"}, Result: ""},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, closeDelim(c.Mark))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_scanSingleClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Input string
|
||||
Misses closeMisses
|
||||
Expected int
|
||||
Recorded closeMisses
|
||||
}{
|
||||
"Asterisk closes at the next asterisk": {
|
||||
Input: "*a*",
|
||||
Expected: 2,
|
||||
},
|
||||
"Underscore outside a word closes at the next underscore": {
|
||||
Input: "_a_b",
|
||||
Expected: 2,
|
||||
},
|
||||
"Underscore inside a word skips a close inside one": {
|
||||
Input: "a_b_c_",
|
||||
Expected: 5,
|
||||
},
|
||||
"Failed scan is recorded": {
|
||||
Input: "a_b_c",
|
||||
Expected: -1,
|
||||
Recorded: closeMisses{strict: 2},
|
||||
},
|
||||
"Scan after a recorded failure is skipped": {
|
||||
Input: "x a_b_",
|
||||
Misses: closeMisses{strict: 1},
|
||||
Expected: -1,
|
||||
Recorded: closeMisses{strict: 1},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
misses := c.Misses
|
||||
|
||||
start := strings.IndexAny(c.Input, "*_")
|
||||
assert.Equal(t, c.Expected, scanSingleClose(c.Input, start, &misses))
|
||||
assert.Equal(t, c.Recorded, misses)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_wordBefore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, wordBefore("a_", 1))
|
||||
assert.True(t, wordBefore("é_", len("é")))
|
||||
assert.False(t, wordBefore("“_", len("“")))
|
||||
assert.False(t, wordBefore("_", 0))
|
||||
}
|
||||
|
||||
func Test_wordAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, wordAt("_7", 1))
|
||||
assert.False(t, wordAt("_”", 1))
|
||||
assert.False(t, wordAt("_", 1))
|
||||
}
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// Metric query-row field names. They name the fields inside the
|
||||
// queries array a metric block carries, not the block's own attributes,
|
||||
// so they stay with the validation that reads them.
|
||||
const (
|
||||
// _metricQueryName is the display name of one query row.
|
||||
_metricQueryName = "name"
|
||||
|
||||
// _metricQueryQuery is the query text of one query row.
|
||||
_metricQueryQuery = "query"
|
||||
|
||||
// _metricQueryLegendFormat is the optional legend template of one
|
||||
// query row.
|
||||
_metricQueryLegendFormat = "legendFormat"
|
||||
)
|
||||
|
||||
// Metric enum values. They mirror the editor's enums in
|
||||
// web/app/components/editor/blocks/metrics/utils.ts (TimeRangePreset,
|
||||
// RefreshInterval, the Visualization*Unit enums, MetricBlockWidth,
|
||||
// MetricSimulationPreset) and web/app/utils/api/data-source/generic-query.ts
|
||||
// (GenericQueryChartType); a value added there has to be added here or the
|
||||
// assistant cannot author it.
|
||||
var (
|
||||
// _metricVisualizationTypes are the chart kinds; they equal the
|
||||
// datasource processor's ChartType values, which the tools package
|
||||
// pins with a test since this package must not import it.
|
||||
_metricVisualizationTypes = []string{"line_chart", "bar_chart", "gauge_chart"}
|
||||
|
||||
// _metricTimeRanges are the time-window presets.
|
||||
_metricTimeRanges = []string{
|
||||
"last_5_minutes", "last_15_minutes", "last_30_minutes",
|
||||
"last_1_hour", "last_3_hours", "last_6_hours", "last_12_hours", "last_24_hours",
|
||||
"last_2_days", "last_7_days", "last_30_days", "last_90_days",
|
||||
"last_6_months", "last_1_year", "last_2_years", "last_5_years",
|
||||
"today", "yesterday", "today_so_far",
|
||||
"this_week", "this_week_so_far", "this_month", "this_month_so_far",
|
||||
"this_year", "this_year_so_far",
|
||||
"previous_week", "previous_month", "previous_year",
|
||||
}
|
||||
|
||||
// _metricRefreshIntervals are the re-query periods.
|
||||
_metricRefreshIntervals = []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"}
|
||||
|
||||
// _metricUnitTypes are the value units: custom, time, data and percent.
|
||||
_metricUnitTypes = []string{
|
||||
"custom",
|
||||
"nanoseconds", "microseconds", "milliseconds", "seconds", "minutes", "hours", "days",
|
||||
"bytes", "kilobytes", "megabytes", "gigabytes", "terabytes",
|
||||
"bits", "kilobits", "megabits", "gigabits", "terabits",
|
||||
"percent0to100", "percent0to1",
|
||||
}
|
||||
|
||||
// _metricWidths are the block widths inside a metric_grid.
|
||||
_metricWidths = []string{"compact", "standard", "wide"}
|
||||
|
||||
// _metricSimulationPresets are the generated series a block draws
|
||||
// while the metric it documents has no real data to answer with.
|
||||
_metricSimulationPresets = []string{
|
||||
"cpu_usage", "memory_usage", "disk_usage",
|
||||
"http_requests", "http_latency", "error_rate",
|
||||
}
|
||||
|
||||
// _metricEnums maps each enum-valued attribute to its values, which
|
||||
// is the table validateMetric checks against and MetricEnums
|
||||
// publishes.
|
||||
_metricEnums = map[string][]string{
|
||||
document.AttrVisualizationType: _metricVisualizationTypes,
|
||||
document.AttrTimeRange: _metricTimeRanges,
|
||||
document.AttrRefreshInterval: _metricRefreshIntervals,
|
||||
document.AttrUnitType: _metricUnitTypes,
|
||||
document.AttrWidth: _metricWidths,
|
||||
document.AttrSimulationPreset: _metricSimulationPresets,
|
||||
}
|
||||
)
|
||||
|
||||
// MetricEnums returns every enum-valued metric attribute with its
|
||||
// allowed values, keyed by attribute name. The prompt pins itself to it
|
||||
// so the model is told exactly the values Validate accepts, and nothing
|
||||
// else.
|
||||
func MetricEnums() map[string][]string {
|
||||
out := make(map[string][]string, len(_metricEnums))
|
||||
|
||||
for k, v := range _metricEnums {
|
||||
out[k] = slices.Clone(v)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// DeriveSimulation sets the simulation flag on an attribute update. A
|
||||
// flag the caller sent is dropped. A changed preset switches the
|
||||
// simulation on, and a null preset switches it off. Any other update
|
||||
// leaves the stored flag alone.
|
||||
func DeriveSimulation(update map[string]any, stored document.Attributes) {
|
||||
delete(update, document.AttrSimulationActive)
|
||||
|
||||
preset, named := update[document.AttrSimulationPreset]
|
||||
if !named || preset == stored[document.AttrSimulationPreset] {
|
||||
return
|
||||
}
|
||||
|
||||
update[document.AttrSimulationActive] = preset != nil
|
||||
}
|
||||
|
||||
// KeepSimulation copies the stored simulation flag onto each metric in
|
||||
// next that stored holds with the same uid and preset. A metric re-sent
|
||||
// after its data arrived then does not start simulating again. It
|
||||
// writes to next's attribute maps.
|
||||
func KeepSimulation(next, stored document.Block) {
|
||||
if next.Type != document.BlockNodeMetricBlock {
|
||||
for _, child := range next.Content {
|
||||
KeepSimulation(child, stored)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
uid, ok := next.UID()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
old, found := stored.FindByUID(uid)
|
||||
if !found || old.Type != document.BlockNodeMetricBlock {
|
||||
return
|
||||
}
|
||||
|
||||
preset, ok := next.Attrs.Value(document.AttrSimulationPreset)
|
||||
if !ok || preset != old.Attrs[document.AttrSimulationPreset] {
|
||||
return
|
||||
}
|
||||
|
||||
delete(next.Attrs, document.AttrSimulationActive)
|
||||
|
||||
if active, set := old.Attrs.Value(document.AttrSimulationActive); set {
|
||||
next.Attrs[document.AttrSimulationActive] = active
|
||||
}
|
||||
}
|
||||
|
||||
// validateMetric checks a metric block: no content, and every known
|
||||
// attribute it does carry is well-formed. Only present, non-null values
|
||||
// are checked — the editor stores null for "unset" and an older block
|
||||
// may carry attributes this layer does not name — so a block read back
|
||||
// and re-sent always passes.
|
||||
func validateMetric(b Block, path string) error {
|
||||
if err := validateContentless(b, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validateMetricAttrs(b.Attrs, path)
|
||||
}
|
||||
|
||||
// validateMetricAttrs checks a metric block's attributes: every enum
|
||||
// holds one of its values, every string is a string, every number a
|
||||
// number, and the queries and thresholds arrays are well-formed.
|
||||
func validateMetricAttrs(attrs document.Attributes, path string) error {
|
||||
for _, key := range []string{
|
||||
document.AttrVisualizationType,
|
||||
document.AttrTimeRange,
|
||||
document.AttrRefreshInterval,
|
||||
document.AttrUnitType,
|
||||
document.AttrWidth,
|
||||
document.AttrSimulationPreset,
|
||||
} {
|
||||
if err := validateMetricEnum(attrs, key, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
document.AttrTitle,
|
||||
document.AttrDataSourceID,
|
||||
document.AttrUnitCustom,
|
||||
document.AttrBaseThresholdColor,
|
||||
} {
|
||||
if err := validateMetricString(attrs, key, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
document.AttrDecimals,
|
||||
document.AttrAxisBoundsMin,
|
||||
document.AttrAxisBoundsMax,
|
||||
} {
|
||||
if err := validateMetricNumber(attrs, key, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateMetricQueries(attrs, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validateMetricThresholds(attrs, path)
|
||||
}
|
||||
|
||||
// validateMetricEnum checks that the named attribute, when set, is one
|
||||
// of its allowed values.
|
||||
func validateMetricEnum(attrs document.Attributes, key, path string) error {
|
||||
v, ok := attrs.Value(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
s, isString := v.(string)
|
||||
if !isString || !slices.Contains(_metricEnums[key], s) {
|
||||
return verr(
|
||||
joinPath(path, "attrs."+key),
|
||||
fmt.Sprintf("metric %s must be one of: %s", key, strings.Join(_metricEnums[key], ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMetricString checks that the named attribute, when set, is a
|
||||
// string.
|
||||
func validateMetricString(attrs document.Attributes, key, path string) error {
|
||||
v, ok := attrs.Value(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, isString := v.(string); !isString {
|
||||
return verr(joinPath(path, "attrs."+key), fmt.Sprintf("metric %s must be a string", key))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMetricNumber checks that the named attribute, when set, is a
|
||||
// number.
|
||||
func validateMetricNumber(attrs document.Attributes, key, path string) error {
|
||||
v, ok := attrs.Value(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isNumber(v) {
|
||||
return verr(joinPath(path, "attrs."+key), fmt.Sprintf("metric %s must be a number", key))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMetricQueries checks that queries, when set, is an array of
|
||||
// objects each carrying a string name and query, and an optional string
|
||||
// legendFormat.
|
||||
func validateMetricQueries(attrs document.Attributes, path string) error {
|
||||
v, ok := attrs.Value(document.AttrQueries)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := objectRows(v)
|
||||
if err != nil {
|
||||
return verr(joinPath(path, "attrs."+document.AttrQueries), "metric queries must be an array of {name, query, legendFormat} objects")
|
||||
}
|
||||
|
||||
for i, row := range rows {
|
||||
rowPath := joinPath(path, fmt.Sprintf("attrs.%s[%d]", document.AttrQueries, i))
|
||||
|
||||
for _, key := range []string{_metricQueryName, _metricQueryQuery} {
|
||||
if _, isString := row[key].(string); !isString {
|
||||
return verr(rowPath, fmt.Sprintf("metric query %s must be a string", key))
|
||||
}
|
||||
}
|
||||
|
||||
if lf, present := row.Value(_metricQueryLegendFormat); present {
|
||||
if _, isString := lf.(string); !isString {
|
||||
return verr(rowPath, "metric query legendFormat must be a string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMetricThresholds checks that thresholds, when set, is an array
|
||||
// of objects whose value, when set, is a number and whose label and
|
||||
// color, when set, are strings.
|
||||
func validateMetricThresholds(attrs document.Attributes, path string) error {
|
||||
v, ok := attrs.Value(document.AttrThresholds)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := objectRows(v)
|
||||
if err != nil {
|
||||
return verr(joinPath(path, "attrs."+document.AttrThresholds), "metric thresholds must be an array of {value, label, color} objects")
|
||||
}
|
||||
|
||||
for i, row := range rows {
|
||||
rowPath := joinPath(path, fmt.Sprintf("attrs.%s[%d]", document.AttrThresholds, i))
|
||||
|
||||
if val, present := row.Value("value"); present && !isNumber(val) {
|
||||
return verr(rowPath, "metric threshold value must be a number")
|
||||
}
|
||||
|
||||
for _, key := range []string{"label", "color"} {
|
||||
if s, present := row.Value(key); present {
|
||||
if _, isString := s.(string); !isString {
|
||||
return verr(rowPath, fmt.Sprintf("metric threshold %s must be a string", key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// objectRows reads an attribute value as a list of objects.
|
||||
//
|
||||
// The value reaches this either from the model's JSON or from the
|
||||
// content column, and both decode an array of objects to []any of
|
||||
// map[string]any, so that is the only shape there is to read.
|
||||
func objectRows(v any) ([]document.Attributes, error) {
|
||||
rows, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("value is %T, not an array", v)
|
||||
}
|
||||
|
||||
out := make([]document.Attributes, 0, len(rows))
|
||||
|
||||
for _, r := range rows {
|
||||
m, isObject := r.(map[string]any)
|
||||
if !isObject {
|
||||
return nil, fmt.Errorf("element is %T, not an object", r)
|
||||
}
|
||||
|
||||
out = append(out, m)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isNumber reports whether v is one of the numeric types a decoded JSON
|
||||
// payload or a hand-built attribute map can carry.
|
||||
func isNumber(v any) bool {
|
||||
switch v.(type) {
|
||||
case float64, float32, int, int64, int32:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,465 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_MetricEnums(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := MetricEnums()
|
||||
|
||||
require.Len(t, got, 6)
|
||||
assert.Equal(t, []string{"line_chart", "bar_chart", "gauge_chart"}, got[document.AttrVisualizationType])
|
||||
assert.Equal(t, []string{"compact", "standard", "wide"}, got[document.AttrWidth])
|
||||
assert.Len(t, got[document.AttrSimulationPreset], 6)
|
||||
assert.Len(t, got[document.AttrTimeRange], 28)
|
||||
assert.Len(t, got[document.AttrRefreshInterval], 10)
|
||||
assert.Len(t, got[document.AttrUnitType], 20)
|
||||
|
||||
// the copy protects the package's own table from a caller that
|
||||
// sorts or appends to what it was handed.
|
||||
got[document.AttrVisualizationType][0] = "wibble"
|
||||
assert.Equal(t, "line_chart", MetricEnums()[document.AttrVisualizationType][0])
|
||||
}
|
||||
|
||||
func Test_DeriveSimulation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Update map[string]any
|
||||
Stored document.Attributes
|
||||
Expected map[string]any
|
||||
}{
|
||||
"An update naming no preset leaves the flag alone": {
|
||||
Update: map[string]any{"title": "Requests"},
|
||||
Stored: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false},
|
||||
Expected: map[string]any{"title": "Requests"},
|
||||
},
|
||||
"A flag the caller sent is dropped": {
|
||||
Update: map[string]any{"title": "Requests", "simulationActive": true},
|
||||
Stored: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false},
|
||||
Expected: map[string]any{"title": "Requests"},
|
||||
},
|
||||
"The stored preset sent again leaves the flag alone": {
|
||||
Update: map[string]any{"simulationPreset": "cpu_usage"},
|
||||
Stored: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false},
|
||||
Expected: map[string]any{"simulationPreset": "cpu_usage"},
|
||||
},
|
||||
"A first preset switches the simulation on": {
|
||||
Update: map[string]any{"simulationPreset": "cpu_usage"},
|
||||
Stored: document.Attributes{"simulationPreset": nil},
|
||||
Expected: map[string]any{"simulationPreset": "cpu_usage", "simulationActive": true},
|
||||
},
|
||||
"Another preset switches the simulation on": {
|
||||
Update: map[string]any{"simulationPreset": "error_rate"},
|
||||
Stored: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false},
|
||||
Expected: map[string]any{"simulationPreset": "error_rate", "simulationActive": true},
|
||||
},
|
||||
"A null preset switches the simulation off": {
|
||||
Update: map[string]any{"simulationPreset": nil},
|
||||
Stored: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true},
|
||||
Expected: map[string]any{"simulationPreset": nil, "simulationActive": false},
|
||||
},
|
||||
"A null preset on a block that names none changes nothing": {
|
||||
Update: map[string]any{"simulationPreset": nil},
|
||||
Stored: document.Attributes{},
|
||||
Expected: map[string]any{"simulationPreset": nil},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
DeriveSimulation(c.Update, c.Stored)
|
||||
|
||||
assert.Equal(t, c.Expected, c.Update)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_KeepSimulation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metric := func(uid string, attrs document.Attributes) document.Block {
|
||||
attrs[document.AttrUID] = uid
|
||||
|
||||
return document.Block{Type: document.BlockNodeMetricBlock, Attrs: attrs}
|
||||
}
|
||||
|
||||
grid := func(items ...document.Block) document.Block {
|
||||
return document.Block{Type: document.BlockNodeMetricGrid, Content: items}
|
||||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
Next document.Block
|
||||
Stored document.Block
|
||||
Expected document.Block
|
||||
}{
|
||||
"The same preset keeps a simulation that was switched off": {
|
||||
Next: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}),
|
||||
Expected: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}),
|
||||
},
|
||||
"The same preset keeps a simulation that is running": {
|
||||
Next: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
Expected: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
},
|
||||
"The same preset stored without a flag drops the derived one": {
|
||||
Next: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage"}),
|
||||
Expected: metric("m1", document.Attributes{"simulationPreset": "cpu_usage"}),
|
||||
},
|
||||
"Another preset keeps what was derived": {
|
||||
Next: metric("m1", document.Attributes{"simulationPreset": "error_rate", "simulationActive": true}),
|
||||
Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}),
|
||||
Expected: metric("m1", document.Attributes{"simulationPreset": "error_rate", "simulationActive": true}),
|
||||
},
|
||||
"A block naming no preset is left alone": {
|
||||
Next: metric("m1", document.Attributes{"simulationPreset": nil}),
|
||||
Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
Expected: metric("m1", document.Attributes{"simulationPreset": nil}),
|
||||
},
|
||||
"A block the stored tree does not hold is left alone": {
|
||||
Next: metric("m2", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}),
|
||||
Expected: metric("m2", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
},
|
||||
"A block without a uid is left alone": {
|
||||
Next: document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true},
|
||||
},
|
||||
Stored: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}),
|
||||
Expected: document.Block{
|
||||
Type: document.BlockNodeMetricBlock,
|
||||
Attrs: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true},
|
||||
},
|
||||
},
|
||||
"A uid the stored tree gives to another kind of block is left alone": {
|
||||
Next: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
Stored: document.Block{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: document.Attributes{document.AttrUID: "m1"},
|
||||
},
|
||||
Expected: metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
},
|
||||
"A grid carries the flag of each metric it holds": {
|
||||
Next: grid(
|
||||
metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
metric("m2", document.Attributes{"simulationPreset": "error_rate", "simulationActive": true}),
|
||||
),
|
||||
Stored: grid(
|
||||
metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}),
|
||||
metric("m2", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}),
|
||||
),
|
||||
Expected: grid(
|
||||
metric("m1", document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}),
|
||||
metric("m2", document.Attributes{"simulationPreset": "error_rate", "simulationActive": true}),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
KeepSimulation(c.Next, c.Stored)
|
||||
|
||||
assert.Equal(t, c.Expected, c.Next)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validateMetric(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Block Block
|
||||
Attrs document.Attributes
|
||||
Err error
|
||||
ExpectedPath string
|
||||
}{
|
||||
// a metric says everything in its attrs and carries no blocks of
|
||||
// its own.
|
||||
"Text is not accepted": {
|
||||
Block: Block{Text: "x"},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Items are not accepted": {
|
||||
Block: Block{Items: []Block{{Type: BlockParagraph, Text: "x"}}},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"No attrs at all": {},
|
||||
"A fully configured block": {
|
||||
Attrs: document.Attributes{
|
||||
document.AttrTitle: "Request rate",
|
||||
document.AttrDataSourceID: "d1qbc8kv2vg000cnb6ag",
|
||||
document.AttrVisualizationType: "line_chart",
|
||||
document.AttrQueries: []any{map[string]any{"name": "Query 1", "query": "up", "legendFormat": "{{job}}"}},
|
||||
document.AttrTimeRange: "last_1_hour",
|
||||
document.AttrRefreshInterval: "5m",
|
||||
document.AttrThresholds: []any{map[string]any{"value": 90.0, "label": "warn", "color": "#f00"}},
|
||||
document.AttrBaseThresholdColor: "#0f0",
|
||||
document.AttrDecimals: 2,
|
||||
document.AttrUnitType: "percent0to100",
|
||||
document.AttrUnitCustom: "rps",
|
||||
document.AttrAxisBoundsMin: 0,
|
||||
document.AttrAxisBoundsMax: 100.5,
|
||||
document.AttrWidth: "wide",
|
||||
document.AttrSimulationPreset: "cpu_usage",
|
||||
},
|
||||
},
|
||||
"Nulls are as absent as missing keys": {
|
||||
Attrs: document.Attributes{
|
||||
document.AttrVisualizationType: nil,
|
||||
document.AttrTimeRange: nil,
|
||||
document.AttrQueries: nil,
|
||||
document.AttrThresholds: nil,
|
||||
document.AttrDecimals: nil,
|
||||
document.AttrAxisBoundsMin: nil,
|
||||
},
|
||||
},
|
||||
"An unknown attr passes through": {
|
||||
Attrs: document.Attributes{"config": map[string]any{"type": "line_chart"}, "wibble": 1},
|
||||
},
|
||||
"Unknown visualization type": {
|
||||
Attrs: document.Attributes{document.AttrVisualizationType: "pie_chart"},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.visualizationType",
|
||||
},
|
||||
"Non-string visualization type": {
|
||||
Attrs: document.Attributes{document.AttrVisualizationType: 1},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.visualizationType",
|
||||
},
|
||||
"Unknown time range": {
|
||||
Attrs: document.Attributes{document.AttrTimeRange: "last_4_hours"},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.timeRange",
|
||||
},
|
||||
"Unknown refresh interval": {
|
||||
Attrs: document.Attributes{document.AttrRefreshInterval: "45s"},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.refreshInterval",
|
||||
},
|
||||
"Unknown unit type": {
|
||||
Attrs: document.Attributes{document.AttrUnitType: "furlongs"},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.unitType",
|
||||
},
|
||||
"Unknown width": {
|
||||
Attrs: document.Attributes{document.AttrWidth: "enormous"},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.width",
|
||||
},
|
||||
"Unknown simulation preset": {
|
||||
Attrs: document.Attributes{document.AttrSimulationPreset: "solar_flares"},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.simulationPreset",
|
||||
},
|
||||
"Non-string title": {
|
||||
Attrs: document.Attributes{document.AttrTitle: 1},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.title",
|
||||
},
|
||||
"Non-string data source id": {
|
||||
Attrs: document.Attributes{document.AttrDataSourceID: 1},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.dataSourceId",
|
||||
},
|
||||
"Non-string unit custom": {
|
||||
Attrs: document.Attributes{document.AttrUnitCustom: 1},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.unitCustom",
|
||||
},
|
||||
"Non-string base threshold color": {
|
||||
Attrs: document.Attributes{document.AttrBaseThresholdColor: 1},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.baseThresholdColor",
|
||||
},
|
||||
"Non-string simulation preset": {
|
||||
Attrs: document.Attributes{document.AttrSimulationPreset: 1},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.simulationPreset",
|
||||
},
|
||||
"Non-numeric decimals": {
|
||||
Attrs: document.Attributes{document.AttrDecimals: "two"},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.decimals",
|
||||
},
|
||||
"Non-numeric axis bounds min": {
|
||||
Attrs: document.Attributes{document.AttrAxisBoundsMin: "zero"},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.axisBoundsMin",
|
||||
},
|
||||
"Non-numeric axis bounds max": {
|
||||
Attrs: document.Attributes{document.AttrAxisBoundsMax: "ten"},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.axisBoundsMax",
|
||||
},
|
||||
"Queries that are not an array": {
|
||||
Attrs: document.Attributes{document.AttrQueries: "up"},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.queries",
|
||||
},
|
||||
"A query row missing its query": {
|
||||
Attrs: document.Attributes{document.AttrQueries: []any{map[string]any{"name": "Query 1"}}},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.queries[0]",
|
||||
},
|
||||
"A query row with a non-string legend format": {
|
||||
Attrs: document.Attributes{
|
||||
document.AttrQueries: []any{map[string]any{"name": "Query 1", "query": "up", "legendFormat": 1}},
|
||||
},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.queries[0]",
|
||||
},
|
||||
"Thresholds that are not an array": {
|
||||
Attrs: document.Attributes{document.AttrThresholds: 90},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.thresholds",
|
||||
},
|
||||
"A threshold row with a non-numeric value": {
|
||||
Attrs: document.Attributes{document.AttrThresholds: []any{map[string]any{"value": "90"}}},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.thresholds[0]",
|
||||
},
|
||||
"A threshold row with a non-string color": {
|
||||
Attrs: document.Attributes{document.AttrThresholds: []any{map[string]any{"color": 1}}},
|
||||
Err: assert.AnError,
|
||||
ExpectedPath: "attrs.thresholds[0]",
|
||||
},
|
||||
"A threshold row with nothing set at all": {
|
||||
Attrs: document.Attributes{document.AttrThresholds: []any{map[string]any{}}},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := c.Block
|
||||
b.Type = BlockMetric
|
||||
b.Attrs = c.Attrs
|
||||
|
||||
err := validateMetric(b, "")
|
||||
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if c.Err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var ve *validationError
|
||||
|
||||
require.ErrorAs(t, err, &ve, "expected validationError, got %T", err)
|
||||
assert.Equal(t, c.ExpectedPath, ve.Path, "validationError path mismatch (full: %s)", ve.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validateMetricEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, validateMetricEnum(document.Attributes{}, document.AttrTimeRange, ""))
|
||||
require.NoError(t, validateMetricEnum(document.Attributes{document.AttrTimeRange: "today"}, document.AttrTimeRange, ""))
|
||||
require.Error(t, validateMetricEnum(document.Attributes{document.AttrTimeRange: "tomorrow"}, document.AttrTimeRange, ""))
|
||||
}
|
||||
|
||||
func Test_validateMetricString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, validateMetricString(document.Attributes{}, document.AttrTitle, ""))
|
||||
require.NoError(t, validateMetricString(document.Attributes{document.AttrTitle: ""}, document.AttrTitle, ""))
|
||||
require.Error(t, validateMetricString(document.Attributes{document.AttrTitle: 1.5}, document.AttrTitle, ""))
|
||||
}
|
||||
|
||||
func Test_validateMetricNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, validateMetricNumber(document.Attributes{}, document.AttrDecimals, ""))
|
||||
require.NoError(t, validateMetricNumber(document.Attributes{document.AttrDecimals: 0}, document.AttrDecimals, ""))
|
||||
require.Error(t, validateMetricNumber(document.Attributes{document.AttrDecimals: true}, document.AttrDecimals, ""))
|
||||
}
|
||||
|
||||
func Test_validateMetricQueries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, validateMetricQueries(document.Attributes{}, ""))
|
||||
require.NoError(t, validateMetricQueries(document.Attributes{
|
||||
document.AttrQueries: []any{map[string]any{"name": "a", "query": "up"}},
|
||||
}, ""))
|
||||
require.Error(t, validateMetricQueries(document.Attributes{
|
||||
document.AttrQueries: []any{map[string]any{"name": 1, "query": "up"}},
|
||||
}, ""))
|
||||
}
|
||||
|
||||
func Test_validateMetricThresholds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, validateMetricThresholds(document.Attributes{}, ""))
|
||||
require.NoError(t, validateMetricThresholds(document.Attributes{
|
||||
document.AttrThresholds: []any{map[string]any{"value": 1, "label": "x"}},
|
||||
}, ""))
|
||||
require.Error(t, validateMetricThresholds(document.Attributes{
|
||||
document.AttrThresholds: []any{map[string]any{"label": 1}},
|
||||
}, ""))
|
||||
}
|
||||
|
||||
func Test_objectRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Input any
|
||||
Expected []document.Attributes
|
||||
Err error
|
||||
}{
|
||||
"Decoded JSON array": {
|
||||
Input: []any{map[string]any{"a": 1}, map[string]any{"b": 2}},
|
||||
Expected: []document.Attributes{{"a": 1}, {"b": 2}},
|
||||
},
|
||||
"An element that is not an object": {
|
||||
Input: []any{[]any{1}},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Empty array": {
|
||||
Input: []any{},
|
||||
Expected: []document.Attributes{},
|
||||
},
|
||||
"Not an array at all": {
|
||||
Input: "a",
|
||||
Err: assert.AnError,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := objectRows(c.Input)
|
||||
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
assert.Equal(t, c.Expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_isNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, v := range []any{1.5, float32(1.5), 1, int64(1), int32(1)} {
|
||||
assert.True(t, isNumber(v), "%T should be a number", v)
|
||||
}
|
||||
|
||||
for _, v := range []any{"1", true, nil, []any{1}} {
|
||||
assert.False(t, isNumber(v), "%T should not be a number", v)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
package block
|
||||
|
||||
import "github.com/oxynote/oxynote/server/core/internal/document"
|
||||
|
||||
// _canonicalTypes maps every ProseMirror node type the AI can read to its
|
||||
// canonical name. Wrapper nodes the AI never authors — listItem,
|
||||
// splitDocumentationLeftSide and the parameter-list nesting — are absent:
|
||||
// they are folded into their macro's typed fields by Compact and have no
|
||||
// canonical type of their own.
|
||||
var _canonicalTypes = map[document.BlockNodeType]Type{
|
||||
document.BlockNodeParagraph: BlockParagraph,
|
||||
document.BlockNodeHeading: BlockHeading,
|
||||
document.BlockNodeBlockquote: BlockBlockquote,
|
||||
document.BlockNodeBulletList: BlockBulletList,
|
||||
document.BlockNodeOrderedList: BlockOrderedList,
|
||||
document.BlockNodeTaskList: BlockTaskList,
|
||||
document.BlockNodeCalloutBlock: BlockCallout,
|
||||
document.BlockNodeCodeBlock: BlockCode,
|
||||
document.BlockNodeTitledCodeBlock: BlockTitledCode,
|
||||
document.BlockNodeMermaidBlock: BlockMermaid,
|
||||
document.BlockNodeHorizontalRule: BlockHorizontalRule,
|
||||
document.BlockNodeImageBlock: BlockImage,
|
||||
document.BlockNodeFileBlock: BlockFile,
|
||||
document.BlockNodeFigmaBlock: BlockFigma,
|
||||
document.BlockNodeMetricBlock: BlockMetric,
|
||||
document.BlockNodeMetricGrid: BlockMetricGrid,
|
||||
document.BlockNodeSplitDoc: BlockSplitDoc,
|
||||
document.BlockNodeParamList: BlockParamList,
|
||||
}
|
||||
|
||||
// _partNames names the ProseMirror nodes without a canonical type the
|
||||
// way a message to the model refers to them: by the block they belong
|
||||
// to.
|
||||
var _partNames = map[document.BlockNodeType]string{
|
||||
document.BlockNodeDoc: "the document root",
|
||||
document.BlockNodeListItem: "a list entry",
|
||||
document.BlockNodeTaskItem: "a task list entry",
|
||||
document.BlockNodeCodeBlockTitle: "a titled_code's title",
|
||||
document.BlockNodeSplitDocLeft: "a split_doc's left side",
|
||||
document.BlockNodeSplitDocRight: "a split_doc's right side",
|
||||
document.BlockNodeParamListHeader: "a split_doc_param_list's header",
|
||||
document.BlockNodeParamListItem: "a split_doc_param_list row",
|
||||
document.BlockNodeParamListItemHeader: "a split_doc_param_list row",
|
||||
document.BlockNodeParamListItemTitle: "a split_doc_param_list row's name",
|
||||
document.BlockNodeParamListItemType: "a split_doc_param_list row's type",
|
||||
}
|
||||
|
||||
// _typeOrder lists every canonical type once, in the order the block
|
||||
// model documents them. Callers publishing the set — a tool schema's
|
||||
// enum — take it from here so the order is the same on every build,
|
||||
// which is what keeps a provider's prompt cache warm across restarts.
|
||||
var _typeOrder = []Type{
|
||||
BlockParagraph,
|
||||
BlockHeading,
|
||||
BlockBlockquote,
|
||||
BlockBulletList,
|
||||
BlockOrderedList,
|
||||
BlockTaskList,
|
||||
BlockCallout,
|
||||
BlockCode,
|
||||
BlockTitledCode,
|
||||
BlockMermaid,
|
||||
BlockHorizontalRule,
|
||||
BlockImage,
|
||||
BlockFile,
|
||||
BlockFigma,
|
||||
BlockMetric,
|
||||
BlockMetricGrid,
|
||||
BlockSplitDoc,
|
||||
BlockParamList,
|
||||
}
|
||||
|
||||
// Types returns every canonical block type, in documentation order.
|
||||
func Types() []string {
|
||||
out := make([]string, 0, len(_typeOrder))
|
||||
|
||||
for _, t := range _typeOrder {
|
||||
out = append(out, string(t))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// RootTypes returns the canonical block types that may sit directly
|
||||
// under the document root, in documentation order. The rest reach the
|
||||
// document inside a macro and are never placed on their own.
|
||||
func RootTypes() []string {
|
||||
out := make([]string, 0, len(_allowedAtRoot))
|
||||
|
||||
for _, t := range _typeOrder {
|
||||
if _allowedAtRoot[t] {
|
||||
out = append(out, string(t))
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// CanonicalType returns the canonical name of a ProseMirror node type. The
|
||||
// second return value reports whether the node type has one at all.
|
||||
func CanonicalType(pm document.BlockNodeType) (Type, bool) {
|
||||
t, ok := _canonicalTypes[pm]
|
||||
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// DescribeNode returns how a message to the model names a ProseMirror
|
||||
// node type: by its canonical type, or by the block it is part of when
|
||||
// it has no type of its own. An unknown node type is returned as it is.
|
||||
func DescribeNode(pm document.BlockNodeType) string {
|
||||
if t, ok := _canonicalTypes[pm]; ok {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
if name, ok := _partNames[pm]; ok {
|
||||
return name
|
||||
}
|
||||
|
||||
return string(pm)
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_CanonicalType(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
PM document.BlockNodeType
|
||||
Result Type
|
||||
Found bool
|
||||
}{
|
||||
"Mapped node type": {
|
||||
PM: document.BlockNodeParagraph,
|
||||
Result: BlockParagraph,
|
||||
Found: true,
|
||||
},
|
||||
"Macro node type": {
|
||||
PM: document.BlockNodeParamList,
|
||||
Result: BlockParamList,
|
||||
Found: true,
|
||||
},
|
||||
"Wrapper node type the canonical model folds away": {
|
||||
PM: document.BlockNodeListItem,
|
||||
},
|
||||
"Unknown node type": {
|
||||
PM: document.BlockNodeType("nonsense"),
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, ok := CanonicalType(c.PM)
|
||||
assert.Equal(t, c.Found, ok)
|
||||
assert.Equal(t, c.Result, res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// every canonical type Expand knows how to build must be reachable from a
|
||||
// ProseMirror type, or a block the AI writes could never be read back.
|
||||
func Test_canonicalTypesCoverExpand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
covered := make(map[Type]bool, len(_canonicalTypes))
|
||||
|
||||
for _, ct := range _canonicalTypes {
|
||||
covered[ct] = true
|
||||
}
|
||||
|
||||
for _, tp := range []Type{
|
||||
BlockParagraph,
|
||||
BlockHeading,
|
||||
BlockBlockquote,
|
||||
BlockBulletList,
|
||||
BlockOrderedList,
|
||||
BlockTaskList,
|
||||
BlockCallout,
|
||||
BlockCode,
|
||||
BlockTitledCode,
|
||||
BlockMermaid,
|
||||
BlockHorizontalRule,
|
||||
BlockImage,
|
||||
BlockFigma,
|
||||
BlockMetric,
|
||||
BlockMetricGrid,
|
||||
BlockSplitDoc,
|
||||
BlockParamList,
|
||||
} {
|
||||
assert.True(t, covered[tp], "%s is not reachable from a ProseMirror type", tp)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_DescribeNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
PM document.BlockNodeType
|
||||
Result string
|
||||
}{
|
||||
"Node type with a canonical type": {
|
||||
PM: document.BlockNodeCalloutBlock,
|
||||
Result: "callout",
|
||||
},
|
||||
"Node type that is part of a block": {
|
||||
PM: document.BlockNodeSplitDocLeft,
|
||||
Result: "a split_doc's left side",
|
||||
},
|
||||
"Document root": {
|
||||
PM: document.BlockNodeDoc,
|
||||
Result: "the document root",
|
||||
},
|
||||
"Unknown node type": {
|
||||
PM: document.BlockNodeType("nonsense"),
|
||||
Result: "nonsense",
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, DescribeNode(c.PM))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,851 +0,0 @@
|
|||
package block
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// MaxTextLength caps a block's text, in bytes. Markdown parsing is
|
||||
// superlinear on some inputs, so the cap bounds what one write costs.
|
||||
const MaxTextLength = 64 << 10
|
||||
|
||||
// errFileNotAuthored is returned by every write path handed a file
|
||||
// block: one exists only by uploading in the editor, so the AI reads and
|
||||
// moves it but never writes it.
|
||||
var errFileNotAuthored = errors.New(
|
||||
"file blocks are created by uploading in the editor and cannot be written by the assistant; " +
|
||||
"move_block moves an existing one",
|
||||
)
|
||||
|
||||
// Allowed child-type sets, mirroring the editor's schema rules.
|
||||
var (
|
||||
// _allowedBlockquoteItems mirrors TipTap's default Blockquote
|
||||
// content (block+) — the same content expression as the document
|
||||
// root — so anything root-legal may sit inside, and Compact can
|
||||
// emit any of it back out of a stored blockquote.
|
||||
_allowedBlockquoteItems = _allowedAtRoot
|
||||
|
||||
// _allowedListItemContent is the set of block types that can be
|
||||
// nested under a list or task-list entry — the "block*" half of
|
||||
// the editor's "paragraph block*" content for ListItem and a
|
||||
// nested TaskItem. The "paragraph" half is the entry itself, which
|
||||
// validateListEntry checks; the two positions take different types
|
||||
// and do not share a rule.
|
||||
_allowedListItemContent = map[Type]bool{
|
||||
BlockParagraph: true,
|
||||
BlockBulletList: true,
|
||||
BlockOrderedList: true,
|
||||
BlockTaskList: true,
|
||||
}
|
||||
|
||||
// _allowedCalloutItems mirrors CalloutBlock.content in
|
||||
// web/app/components/editor/blocks/callout/index.ts.
|
||||
_allowedCalloutItems = map[Type]bool{
|
||||
BlockParagraph: true,
|
||||
BlockBulletList: true,
|
||||
BlockOrderedList: true,
|
||||
BlockTaskList: true,
|
||||
}
|
||||
|
||||
// _allowedSplitDocLeftBody is the set of types that may appear
|
||||
// between the leading heading and any trailing param_lists on
|
||||
// a split_doc left side.
|
||||
_allowedSplitDocLeftBody = map[Type]bool{
|
||||
BlockParagraph: true,
|
||||
BlockBulletList: true,
|
||||
BlockOrderedList: true,
|
||||
BlockTaskList: true,
|
||||
BlockCallout: true,
|
||||
}
|
||||
|
||||
// _allowedSplitDocRight is the set of types that may appear on a
|
||||
// split_doc right side.
|
||||
_allowedSplitDocRight = map[Type]bool{
|
||||
BlockTitledCode: true,
|
||||
BlockMetric: true,
|
||||
BlockMermaid: true,
|
||||
}
|
||||
|
||||
// _allowedSplitDocLeft is what a block landing on an existing
|
||||
// split_doc left side may be: the body set plus param_list. The
|
||||
// leading heading is excluded — it is created with the macro and
|
||||
// edited in place, never placed next to another block.
|
||||
_allowedSplitDocLeft = map[Type]bool{
|
||||
BlockParagraph: true,
|
||||
BlockBulletList: true,
|
||||
BlockOrderedList: true,
|
||||
BlockTaskList: true,
|
||||
BlockCallout: true,
|
||||
BlockParamList: true,
|
||||
}
|
||||
|
||||
// _allowedInContainer maps a ProseMirror container node to the
|
||||
// canonical types it accepts as direct children. Containers absent
|
||||
// from the map — lists, whose direct children are wrapper items,
|
||||
// and macro internals — accept no canonical block at all. The
|
||||
// document root is handled by ValidateAsRoot.
|
||||
_allowedInContainer = map[document.BlockNodeType]map[Type]bool{
|
||||
document.BlockNodeBlockquote: _allowedBlockquoteItems,
|
||||
document.BlockNodeListItem: _allowedListItemContent,
|
||||
document.BlockNodeTaskItem: _allowedListItemContent,
|
||||
document.BlockNodeCalloutBlock: _allowedCalloutItems,
|
||||
document.BlockNodeSplitDocLeft: _allowedSplitDocLeft,
|
||||
document.BlockNodeSplitDocRight: _allowedSplitDocRight,
|
||||
document.BlockNodeMetricGrid: {BlockMetric: true},
|
||||
}
|
||||
|
||||
// _allowedAtRoot is the set of canonical block types that may
|
||||
// sit directly under the document root. Types not in this set
|
||||
// use custom TipTap groups
|
||||
// (splitDocumentationParameterList, metricBlock,
|
||||
// titledCodeBlock) and only become valid inside their containing
|
||||
// macro.
|
||||
_allowedAtRoot = map[Type]bool{
|
||||
BlockParagraph: true,
|
||||
BlockHeading: true,
|
||||
BlockBlockquote: true,
|
||||
BlockBulletList: true,
|
||||
BlockOrderedList: true,
|
||||
BlockTaskList: true,
|
||||
BlockCallout: true,
|
||||
BlockCode: true,
|
||||
BlockMermaid: true,
|
||||
BlockHorizontalRule: true,
|
||||
BlockImage: true,
|
||||
BlockFile: true,
|
||||
BlockFigma: true,
|
||||
BlockMetricGrid: true,
|
||||
BlockSplitDoc: true,
|
||||
}
|
||||
)
|
||||
|
||||
// validationError is the structured error returned by Validate.
|
||||
// Path locates the offending block within a nested canonical tree
|
||||
// using a slash-separated breadcrumb (e.g. "split_doc/left[0]",
|
||||
// "param_list/params[2]"); Message describes the rule that failed.
|
||||
type validationError struct {
|
||||
// Path is a slash-separated breadcrumb identifying the offending
|
||||
// node. Indices use bracketed integers (e.g. "items[3]").
|
||||
Path string
|
||||
|
||||
// Message is a single-sentence description of the violated rule.
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error renders the validation error in the form
|
||||
// "{path}: {message}".
|
||||
func (e *validationError) Error() string {
|
||||
if e.Path == "" {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
return e.Path + ": " + e.Message
|
||||
}
|
||||
|
||||
// Validate checks that a canonical Block satisfies every constraint
|
||||
// Expand and the editor schema require. It is intended to be run on
|
||||
// AI-supplied input before Expand. Validate stops at the first
|
||||
// violation and returns a validationError describing it; callers
|
||||
// can surface that message to the AI to recover precisely.
|
||||
//
|
||||
// A nil error means Expand will produce a structurally valid
|
||||
// document.Block tree (subject to runtime constraints like the
|
||||
// referenced uid existing, which only matter once the operation
|
||||
// reaches hocuspocus).
|
||||
func Validate(b Block) error {
|
||||
return validateBlock(b, "")
|
||||
}
|
||||
|
||||
// ValidateAsRoot validates a block being inserted at the top level
|
||||
// of a document. It runs Validate first and then rejects types
|
||||
// whose TipTap group restricts them to a specific parent
|
||||
// (titled_code, metric, param_list) — those types must reach the
|
||||
// document tree via their macro container (split_doc, metric_grid)
|
||||
// and never sit at the root.
|
||||
func ValidateAsRoot(b Block) error {
|
||||
if err := validateBlock(b, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return AllowedInContainer(document.BlockNodeDoc, b.Type)
|
||||
}
|
||||
|
||||
// ValidateAttrs checks the attribute rules for a block of the given
|
||||
// type, asking nothing about its content. An attribute-only update
|
||||
// names no content, so the content rules have nothing to say about it,
|
||||
// while the attributes it does set answer to exactly the rules a whole
|
||||
// block's would. Types carrying no attribute rules pass.
|
||||
//
|
||||
// Callers merge the update over the block's current attributes before
|
||||
// calling: a rule reads the attributes the block will end up with, not
|
||||
// the subset one call happened to name.
|
||||
func ValidateAttrs(t Type, attrs document.Attributes) error {
|
||||
switch t {
|
||||
case BlockHeading:
|
||||
return validateHeadingAttrs(attrs, "")
|
||||
case BlockTitledCode:
|
||||
return validateTitledCodeAttrs(attrs, "")
|
||||
case BlockCode:
|
||||
return validateLanguageAttr(attrs, "")
|
||||
case BlockImage, BlockFigma:
|
||||
return validateSrcAttrs(t, attrs, "")
|
||||
case BlockFile:
|
||||
return errFileNotAuthored
|
||||
case BlockMetric:
|
||||
return validateMetricAttrs(attrs, "")
|
||||
case BlockParagraph,
|
||||
BlockBlockquote,
|
||||
BlockBulletList,
|
||||
BlockOrderedList,
|
||||
BlockTaskList,
|
||||
BlockCallout,
|
||||
BlockMermaid,
|
||||
BlockHorizontalRule,
|
||||
BlockMetricGrid,
|
||||
BlockSplitDoc,
|
||||
BlockParamList:
|
||||
// these carry no attribute the canonical model constrains:
|
||||
// callout's icon is a free string, and the rest have none.
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateInContainer validates a block that is about to land among the
|
||||
// direct children of the given ProseMirror node. It runs Validate first
|
||||
// and then checks the container's child rules: the document root takes
|
||||
// the root set, macro containers take their own sets, and a node whose
|
||||
// direct children are wrapper items — a list, a macro internal —
|
||||
// accepts no canonical block at all.
|
||||
func ValidateInContainer(container document.BlockNodeType, b Block) error {
|
||||
if err := validateBlock(b, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return AllowedInContainer(container, b.Type)
|
||||
}
|
||||
|
||||
// AllowedInContainer reports whether a block of type t may sit among
|
||||
// the direct children of the given ProseMirror node, asking nothing
|
||||
// about the block's content. A move carries a block that is already in
|
||||
// the document, so its content is not in question — only where it is
|
||||
// allowed to land.
|
||||
func AllowedInContainer(container document.BlockNodeType, t Type) error {
|
||||
if container == document.BlockNodeDoc {
|
||||
if !_allowedAtRoot[t] {
|
||||
return verr("", fmt.Sprintf(
|
||||
"%s is not allowed at the document root; it must appear inside %s",
|
||||
t, containerForType(t),
|
||||
))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
allowed, ok := _allowedInContainer[container]
|
||||
if !ok {
|
||||
return verr("", "blocks cannot be placed directly inside "+DescribeNode(container))
|
||||
}
|
||||
|
||||
if !allowed[t] {
|
||||
return verr("", fmt.Sprintf(
|
||||
"%s is not allowed inside %s, which holds only %s",
|
||||
t, DescribeNode(container), listAllowed(allowed),
|
||||
))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// containerForType returns a human-readable description of the
|
||||
// macro that must contain a non-root block type. Used to build
|
||||
// helpful error messages for ValidateAsRoot.
|
||||
func containerForType(t Type) string {
|
||||
switch t {
|
||||
case BlockTitledCode:
|
||||
return "split_doc.right"
|
||||
case BlockMetric:
|
||||
return "metric_grid or split_doc.right"
|
||||
case BlockParamList:
|
||||
return "split_doc.left"
|
||||
default:
|
||||
return "its parent macro"
|
||||
}
|
||||
}
|
||||
|
||||
// validateBlock dispatches on b.Type. Path is the breadcrumb to b
|
||||
// from the validation root (passed in by recursive calls).
|
||||
func validateBlock(b Block, path string) error {
|
||||
if len(b.Text) > MaxTextLength {
|
||||
return verr(joinPath(path, "text"), fmt.Sprintf("text is longer than %d bytes; split it across blocks", MaxTextLength))
|
||||
}
|
||||
|
||||
switch b.Type {
|
||||
case "":
|
||||
return verr(path, "block type is required")
|
||||
case BlockParagraph, BlockMermaid:
|
||||
return validateTextBearing(b, path)
|
||||
case BlockCode:
|
||||
if err := validateTextBearing(b, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validateLanguageAttr(b.Attrs, path)
|
||||
case BlockHeading:
|
||||
return validateHeading(b, path)
|
||||
case BlockBlockquote:
|
||||
return validateBlockquote(b, path)
|
||||
case BlockBulletList, BlockOrderedList:
|
||||
return validateList(b, path)
|
||||
case BlockTaskList:
|
||||
return validateTaskList(b, path)
|
||||
case BlockCallout:
|
||||
return validateCallout(b, path)
|
||||
case BlockTitledCode:
|
||||
return validateTitledCode(b, path)
|
||||
case BlockHorizontalRule:
|
||||
return validateContentless(b, path)
|
||||
case BlockMetric:
|
||||
return validateMetric(b, path)
|
||||
case BlockImage, BlockFigma:
|
||||
return validateAtomWithSrc(b, path)
|
||||
case BlockFile:
|
||||
return verr(path, errFileNotAuthored.Error())
|
||||
case BlockMetricGrid:
|
||||
return validateMetricGrid(b, path)
|
||||
case BlockSplitDoc:
|
||||
return validateSplitDoc(b, path)
|
||||
case BlockParamList:
|
||||
return validateParamList(b, path)
|
||||
}
|
||||
|
||||
return verr(path, fmt.Sprintf(
|
||||
"unknown block type %q; the types are %s",
|
||||
b.Type, strings.Join(Types(), ", "),
|
||||
))
|
||||
}
|
||||
|
||||
// validateTextBearing checks that the block carries only inline Text
|
||||
// content and no compound fields. Used for paragraph, code and mermaid
|
||||
// (heading, blockquote and titled_code layer their own rules on top of
|
||||
// this).
|
||||
func validateTextBearing(b Block, path string) error {
|
||||
if err := mustNotHaveCompoundFields(b, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(b.Items) != 0 {
|
||||
return verr(path, fmt.Sprintf("%s does not accept items", b.Type))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHeading layers the level-attr rule on top of the
|
||||
// text-bearing checks.
|
||||
func validateHeading(b Block, path string) error {
|
||||
if err := validateTextBearing(b, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validateHeadingAttrs(b.Attrs, path)
|
||||
}
|
||||
|
||||
// validateHeadingAttrs checks a heading's level.
|
||||
func validateHeadingAttrs(attrs document.Attributes, path string) error {
|
||||
if a, ok := attrs.Get(document.AttrLevel); !ok || a.Int() < 1 || a.Int() > 3 {
|
||||
return verr(joinPath(path, "attrs.level"), "heading level must be 1, 2, or 3")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateBlockquote checks the text-or-items exclusivity rule and
|
||||
// the allowed item types. Empty is legal: a blockquote holding one
|
||||
// empty paragraph compacts to neither text nor items, and Expand
|
||||
// turns the empty form back into that single paragraph.
|
||||
func validateBlockquote(b Block, path string) error {
|
||||
if err := mustNotHaveCompoundFields(b, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Text != "" && len(b.Items) > 0 {
|
||||
return verr(path, "blockquote accepts either text or items, not both")
|
||||
}
|
||||
|
||||
if b.Text != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validateItemsAllowed(b.Items, joinPath(path, "items"), _allowedBlockquoteItems)
|
||||
}
|
||||
|
||||
// validateList checks a bullet_list or ordered_list: no text, at
|
||||
// least one item, items of allowed types.
|
||||
func validateList(b Block, path string) error {
|
||||
if err := mustNotHaveCompoundFields(b, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Text != "" {
|
||||
return verr(path, fmt.Sprintf("%s does not accept text", b.Type))
|
||||
}
|
||||
|
||||
if len(b.Items) == 0 {
|
||||
return verr(path, fmt.Sprintf("%s requires at least one item", b.Type))
|
||||
}
|
||||
|
||||
return validateListItems(b.Items, joinPath(path, "items"))
|
||||
}
|
||||
|
||||
// validateTaskList checks that rows live in task_items and each
|
||||
// row's content block is an allowed list-item type.
|
||||
func validateTaskList(b Block, path string) error {
|
||||
if err := mustHaveNoCanonicalCompoundExcept(b, path, "task_items"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Text != "" {
|
||||
return verr(path, "task_list does not accept text")
|
||||
}
|
||||
|
||||
if len(b.TaskItems) == 0 {
|
||||
return verr(path, "task_list requires at least one task_item")
|
||||
}
|
||||
|
||||
for i, ti := range b.TaskItems {
|
||||
itemPath := joinPath(path, fmt.Sprintf("task_items[%d]", i))
|
||||
|
||||
children := ti.Block.Children
|
||||
ti.Block.Children = nil
|
||||
|
||||
if err := validateBlock(ti.Block, joinPath(itemPath, "block")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateListEntry(ti.Block.Type, joinPath(itemPath, "block")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateItemsAllowed(
|
||||
children,
|
||||
joinPath(itemPath, "block/children"),
|
||||
_allowedListItemContent,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateCallout checks the text-or-items exclusivity rule and
|
||||
// the allowed item types. Empty is legal: a callout holding one
|
||||
// empty paragraph compacts to neither text nor items, and Expand
|
||||
// turns the empty form back into that single paragraph.
|
||||
func validateCallout(b Block, path string) error {
|
||||
if err := mustHaveNoCanonicalCompoundExcept(b, path, "items"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Text != "" && len(b.Items) > 0 {
|
||||
return verr(path, "callout accepts either text or items, not both")
|
||||
}
|
||||
|
||||
if b.Text != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validateItemsAllowed(b.Items, joinPath(path, "items"), _allowedCalloutItems)
|
||||
}
|
||||
|
||||
// validateTitledCode checks that a titled_code block carries a
|
||||
// non-empty title attr and no compound content.
|
||||
func validateTitledCode(b Block, path string) error {
|
||||
if err := validateTextBearing(b, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validateTitledCodeAttrs(b.Attrs, path)
|
||||
}
|
||||
|
||||
// validateTitledCodeAttrs checks that a titled_code carries a title
|
||||
// and a language that is a string, if any.
|
||||
func validateTitledCodeAttrs(attrs document.Attributes, path string) error {
|
||||
if a, ok := attrs.Get(document.AttrTitle); !ok || strings.TrimSpace(a.String()) == "" {
|
||||
return verr(joinPath(path, "attrs.title"), "titled_code requires a non-empty title")
|
||||
}
|
||||
|
||||
return validateLanguageAttr(attrs, path)
|
||||
}
|
||||
|
||||
// validateLanguageAttr checks that a code language, when set, is a
|
||||
// string. The editor's highlighter fails on any other value, and the
|
||||
// document with it stops rendering.
|
||||
func validateLanguageAttr(attrs document.Attributes, path string) error {
|
||||
v, ok := attrs.Value(document.AttrLanguage)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, isString := v.(string); !isString {
|
||||
return verr(joinPath(path, "attrs.language"), "code language must be a string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateContentless checks that the block carries no content at all,
|
||||
// leaving its attrs as the only thing it can say. Used for
|
||||
// horizontal_rule and metric (image and figma layer the src rule on
|
||||
// top of this).
|
||||
func validateContentless(b Block, path string) error {
|
||||
if err := mustNotHaveCompoundFields(b, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Text != "" || len(b.Items) != 0 {
|
||||
return verr(path, fmt.Sprintf("%s does not accept content", b.Type))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAtomWithSrc checks that the block carries no content and
|
||||
// points somewhere. Used for image and figma.
|
||||
func validateAtomWithSrc(b Block, path string) error {
|
||||
if err := validateContentless(b, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validateSrcAttrs(b.Type, b.Attrs, path)
|
||||
}
|
||||
|
||||
// validateSrcAttrs checks that an image or figma block carries a src.
|
||||
func validateSrcAttrs(t Type, attrs document.Attributes, path string) error {
|
||||
if a, ok := attrs.Get(document.AttrSrc); !ok || strings.TrimSpace(a.String()) == "" {
|
||||
return verr(joinPath(path, "attrs.src"), fmt.Sprintf("%s requires a non-empty src", t))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMetricGrid checks that every item is a metric and there
|
||||
// is at least one.
|
||||
func validateMetricGrid(b Block, path string) error {
|
||||
if err := mustHaveNoCanonicalCompoundExcept(b, path, "items"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Text != "" {
|
||||
return verr(path, "metric_grid does not accept text")
|
||||
}
|
||||
|
||||
if len(b.Items) == 0 {
|
||||
return verr(path, "metric_grid requires at least one metric item")
|
||||
}
|
||||
|
||||
for i, item := range b.Items {
|
||||
if item.Type != BlockMetric {
|
||||
return verr(
|
||||
joinPath(path, fmt.Sprintf("items[%d]", i)),
|
||||
fmt.Sprintf("metric_grid items must be metric, got %s", item.Type),
|
||||
)
|
||||
}
|
||||
|
||||
if err := validateBlock(item, joinPath(path, fmt.Sprintf("items[%d]", i))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSplitDoc checks the split_doc shape: a level-1 heading
|
||||
// leading the left side, body blocks before any param_lists, and
|
||||
// titled_code/metric/mermaid blocks on the right.
|
||||
func validateSplitDoc(b Block, path string) error {
|
||||
if err := mustHaveNoCanonicalCompoundExcept(b, path, "left", "right"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Text != "" {
|
||||
return verr(path, "split_doc does not accept text")
|
||||
}
|
||||
|
||||
if len(b.Left) == 0 {
|
||||
return verr(joinPath(path, "left"), "split_doc requires at least one left block")
|
||||
}
|
||||
|
||||
if len(b.Right) == 0 {
|
||||
return verr(joinPath(path, "right"), "split_doc requires at least one right block")
|
||||
}
|
||||
|
||||
// First left block must be a heading at level 1; the rest may be
|
||||
// paragraphs, lists, callouts, or param_lists in that order
|
||||
// (heading first, then any number of body blocks, then any
|
||||
// number of param_lists).
|
||||
//
|
||||
// Level 1 mirrors what the web editor's split_doc.createAndFill produces
|
||||
// for a freshly inserted split_doc — the panel provides its own
|
||||
// visual emphasis, so the heading inside is always level 1
|
||||
// regardless of the surrounding outline depth.
|
||||
if b.Left[0].Type != BlockHeading {
|
||||
return verr(joinPath(path, "left[0]"), "split_doc left must begin with a heading")
|
||||
}
|
||||
|
||||
if a, ok := b.Left[0].Attrs.Get(document.AttrLevel); !ok || a.Int() != 1 {
|
||||
return verr(
|
||||
joinPath(path, "left[0].attrs.level"),
|
||||
"split_doc left heading must be level 1",
|
||||
)
|
||||
}
|
||||
|
||||
seenParamList := false
|
||||
|
||||
for i, lb := range b.Left {
|
||||
if err := validateBlock(lb, joinPath(path, fmt.Sprintf("left[%d]", i))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if lb.Type == BlockParamList {
|
||||
seenParamList = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if seenParamList {
|
||||
return verr(
|
||||
joinPath(path, fmt.Sprintf("left[%d]", i)),
|
||||
"non-param_list blocks must come before any param_list on the left side",
|
||||
)
|
||||
}
|
||||
|
||||
if !_allowedSplitDocLeftBody[lb.Type] {
|
||||
return verr(
|
||||
joinPath(path, fmt.Sprintf("left[%d]", i)),
|
||||
fmt.Sprintf("split_doc left body must be one of %s, got %s",
|
||||
listAllowed(_allowedSplitDocLeftBody), lb.Type),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for i, rb := range b.Right {
|
||||
if err := validateBlock(rb, joinPath(path, fmt.Sprintf("right[%d]", i))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !_allowedSplitDocRight[rb.Type] {
|
||||
return verr(
|
||||
joinPath(path, fmt.Sprintf("right[%d]", i)),
|
||||
fmt.Sprintf("split_doc right must be one of %s, got %s",
|
||||
listAllowed(_allowedSplitDocRight), rb.Type),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateParamList checks the header and per-row name
|
||||
// requirements of a param_list.
|
||||
func validateParamList(b Block, path string) error {
|
||||
if err := mustHaveNoCanonicalCompoundExcept(b, path, "header", "params"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Text != "" {
|
||||
return verr(path, "param_list uses header and params, not text")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(b.Header) == "" {
|
||||
return verr(joinPath(path, "header"), "param_list requires a non-empty header")
|
||||
}
|
||||
|
||||
if len(b.Params) == 0 {
|
||||
return verr(joinPath(path, "params"), "param_list requires at least one row")
|
||||
}
|
||||
|
||||
for i, p := range b.Params {
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return verr(
|
||||
joinPath(path, fmt.Sprintf("params[%d]/name", i)),
|
||||
"param row requires a non-empty name",
|
||||
)
|
||||
}
|
||||
|
||||
if len(p.Description) > MaxTextLength {
|
||||
return verr(
|
||||
joinPath(path, fmt.Sprintf("params[%d]/description", i)),
|
||||
fmt.Sprintf("param description is longer than %d bytes", MaxTextLength),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustNotHaveCompoundFields rejects a block that sets any of the
|
||||
// macro-only fields (TaskItems, Left, Right, Header, Params). Used
|
||||
// by simple block types that should never see them.
|
||||
func mustNotHaveCompoundFields(b Block, path string) error {
|
||||
if len(b.Children) != 0 {
|
||||
return verr(path, fmt.Sprintf(
|
||||
"%s does not accept children; only a list or task-list item's content block carries them",
|
||||
b.Type,
|
||||
))
|
||||
}
|
||||
|
||||
if len(b.TaskItems) != 0 {
|
||||
return verr(path, fmt.Sprintf("%s does not accept task_items", b.Type))
|
||||
}
|
||||
|
||||
if len(b.Left) != 0 || len(b.Right) != 0 {
|
||||
return verr(path, fmt.Sprintf("%s does not accept left/right", b.Type))
|
||||
}
|
||||
|
||||
if b.Header != "" || len(b.Params) != 0 {
|
||||
return verr(path, fmt.Sprintf("%s does not accept header/params", b.Type))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustHaveNoCanonicalCompoundExcept enforces that only the listed
|
||||
// compound fields are populated. Each name is one of "items",
|
||||
// "task_items", "left", "right", "header", "params".
|
||||
func mustHaveNoCanonicalCompoundExcept(b Block, path string, allowed ...string) error {
|
||||
allow := map[string]bool{}
|
||||
for _, a := range allowed {
|
||||
allow[a] = true
|
||||
}
|
||||
|
||||
if !allow["items"] && len(b.Items) != 0 {
|
||||
return verr(path, fmt.Sprintf("%s does not accept items", b.Type))
|
||||
}
|
||||
|
||||
if !allow["task_items"] && len(b.TaskItems) != 0 {
|
||||
return verr(path, fmt.Sprintf("%s does not accept task_items", b.Type))
|
||||
}
|
||||
|
||||
if !allow["left"] && len(b.Left) != 0 {
|
||||
return verr(path, fmt.Sprintf("%s does not accept left", b.Type))
|
||||
}
|
||||
|
||||
if !allow["right"] && len(b.Right) != 0 {
|
||||
return verr(path, fmt.Sprintf("%s does not accept right", b.Type))
|
||||
}
|
||||
|
||||
if !allow["header"] && b.Header != "" {
|
||||
return verr(path, fmt.Sprintf("%s does not accept header", b.Type))
|
||||
}
|
||||
|
||||
if !allow["params"] && len(b.Params) != 0 {
|
||||
return verr(path, fmt.Sprintf("%s does not accept params", b.Type))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateListItems checks the content blocks of a list or task list.
|
||||
// An item's own children — a nested list, in practice — are validated
|
||||
// separately, since every other position rejects them outright.
|
||||
func validateListItems(items []Block, path string) error {
|
||||
for i, item := range items {
|
||||
itemPath := joinPath(path, fmt.Sprintf("[%d]", i))
|
||||
|
||||
children := item.Children
|
||||
item.Children = nil
|
||||
|
||||
if err := validateListEntry(item.Type, itemPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateBlock(item, itemPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateItemsAllowed(children, joinPath(itemPath, "children"), _allowedListItemContent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateListEntry checks the block a list or task-list entry leads
|
||||
// with. The editor's ListItem and nested TaskItem both hold
|
||||
// "paragraph block*", so the entry is the paragraph and anything else
|
||||
// belongs under it — expanding a list as the entry would produce an
|
||||
// item with no leading paragraph, which the schema does not admit.
|
||||
func validateListEntry(t Type, path string) error {
|
||||
if t != BlockParagraph {
|
||||
return verr(path, fmt.Sprintf(
|
||||
"a list entry is a %s, got %s; nest other blocks under the entry with children",
|
||||
BlockParagraph, t,
|
||||
))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateItemsAllowed validates a slice of items, requiring each
|
||||
// to be of a type in allowed.
|
||||
func validateItemsAllowed(items []Block, path string, allowed map[Type]bool) error {
|
||||
for i, item := range items {
|
||||
itemPath := joinPath(path, fmt.Sprintf("[%d]", i))
|
||||
if !allowed[item.Type] {
|
||||
return verr(itemPath, fmt.Sprintf("type %s not allowed here; expected one of %s",
|
||||
item.Type, listAllowed(allowed)))
|
||||
}
|
||||
|
||||
if err := validateBlock(item, itemPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listAllowed returns a sorted comma-separated list of the keys in
|
||||
// the allowed-set, for use in error messages.
|
||||
func listAllowed(m map[Type]bool) string {
|
||||
names := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
names = append(names, string(k))
|
||||
}
|
||||
|
||||
// Sort so error messages are deterministic for tests.
|
||||
slices.Sort(names)
|
||||
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// verr is a tiny validationError constructor.
|
||||
func verr(path, message string) error {
|
||||
return &validationError{Path: path, Message: message}
|
||||
}
|
||||
|
||||
// joinPath concatenates a parent breadcrumb with a child segment.
|
||||
// When the child starts with '[' (an index suffix) the segments
|
||||
// join without a separator; otherwise a '/' separator is used.
|
||||
func joinPath(parent, child string) string {
|
||||
if parent == "" {
|
||||
return child
|
||||
}
|
||||
|
||||
if strings.HasPrefix(child, "[") {
|
||||
return parent + child
|
||||
}
|
||||
|
||||
return parent + "/" + child
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -16,17 +16,11 @@ import (
|
|||
// surfaced to callers.
|
||||
const _maxErrorPreviewBytes = 1024
|
||||
|
||||
// Result is the outcome of an Apply call. Applied counts the
|
||||
// operations Node committed to the Y.Doc; Errors lists per-op
|
||||
// failures (e.g. uid not found, malformed block). A partial-success
|
||||
// response has Applied < len(ops) and at least one entry in Errors.
|
||||
// Result is the outcome of an Apply call. A batch applies whole or not
|
||||
// at all: Errors is empty only when every operation landed.
|
||||
type Result struct {
|
||||
// Applied is the number of operations the Node side committed
|
||||
// to the live Y.Doc.
|
||||
Applied int `json:"applied"`
|
||||
|
||||
// Errors contains one entry per failed operation, in input
|
||||
// order. An empty slice means every operation succeeded.
|
||||
// Errors holds the operation that failed, if any. The batch stops
|
||||
// at the first failure.
|
||||
Errors []OpError `json:"errors"`
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +51,7 @@ func NewClient(httpClient *http.Client, baseURL string) *Client {
|
|||
}
|
||||
|
||||
// Apply submits the given operations to the Node service for the
|
||||
// (documentID, branchID) document and returns the per-op outcome.
|
||||
// (documentID, branchID) document and returns the outcome.
|
||||
//
|
||||
// A system batch is one core originated rather than a person: its persist
|
||||
// is allowed onto a protected branch, where the writes an editor or the
|
||||
|
|
@ -65,11 +59,11 @@ func NewClient(httpClient *http.Client, baseURL string) *Client {
|
|||
// pass true — nothing reachable from a prompt may. The userID is the
|
||||
// person an ordinary batch is made for, and its persist credits them. It
|
||||
// is empty for a system batch.
|
||||
// Any transport-level failure (bad status, malformed response,
|
||||
// canonical expansion error) is returned as an error and no
|
||||
// operations are considered applied; failures of individual
|
||||
// operations within an otherwise successful HTTP response are
|
||||
// surfaced via Result.Errors.
|
||||
// Any transport-level failure (bad status, malformed response) is
|
||||
// returned as an error and no
|
||||
// operations are considered applied; an operation the service
|
||||
// refused within an otherwise successful HTTP response is surfaced
|
||||
// via Result.Errors.
|
||||
func (c *Client) Apply(
|
||||
ctx context.Context,
|
||||
documentID, branchID xid.ID,
|
||||
|
|
@ -81,22 +75,11 @@ func (c *Client) Apply(
|
|||
return Result{}, nil
|
||||
}
|
||||
|
||||
wires := make([]wireOp, 0, len(ops))
|
||||
|
||||
for i, op := range ops {
|
||||
w, err := op()
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("operation %d: %w", i, err)
|
||||
}
|
||||
|
||||
wires = append(wires, w)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(struct {
|
||||
Operations []wireOp `json:"operations"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
System bool `json:"system"`
|
||||
}{Operations: wires, UserID: userID, System: system})
|
||||
Operations []Operation `json:"operations"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
System bool `json:"system"`
|
||||
}{Operations: ops, UserID: userID, System: system})
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("marshaling operations: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,39 +47,30 @@ func Test_Client_Apply(t *testing.T) {
|
|||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
Ops []Operation
|
||||
UserID string
|
||||
System bool
|
||||
BaseURL string
|
||||
CloseEarly bool
|
||||
TruncateBody bool
|
||||
StatusCode int
|
||||
ResponseBody string
|
||||
ExpectedApplied int
|
||||
ExpectedErrors []OpError
|
||||
ExpectedPath string
|
||||
Err error
|
||||
Ops []Operation
|
||||
UserID string
|
||||
System bool
|
||||
BaseURL string
|
||||
CloseEarly bool
|
||||
TruncateBody bool
|
||||
StatusCode int
|
||||
ResponseBody string
|
||||
ExpectedErrors []OpError
|
||||
ExpectedPath string
|
||||
Err error
|
||||
|
||||
// NoRequest asserts Apply returned before any HTTP round trip.
|
||||
NoRequest bool
|
||||
}{
|
||||
"Operation failure skips the request": {
|
||||
// an operation that cannot produce its wire form fails before
|
||||
// the client builds a request at all.
|
||||
Ops: []Operation{func() (wireOp, error) { return wireOp{}, assert.AnError }},
|
||||
Err: assert.AnError,
|
||||
NoRequest: true,
|
||||
},
|
||||
"Successful batch reports applied count": {
|
||||
"Successful batch": {
|
||||
Ops: []Operation{
|
||||
Append(paragraph()),
|
||||
Delete("uid-1"),
|
||||
},
|
||||
StatusCode: 200,
|
||||
ResponseBody: `{"applied": 2, "errors": []}`,
|
||||
ExpectedApplied: 2,
|
||||
ExpectedErrors: []OpError{},
|
||||
ExpectedPath: path,
|
||||
StatusCode: 200,
|
||||
ResponseBody: `{"errors": []}`,
|
||||
ExpectedErrors: []OpError{},
|
||||
ExpectedPath: path,
|
||||
},
|
||||
// the mark is what a protected branch accepts a persist on, so
|
||||
// it has to reach the wire rather than stay a caller's intent.
|
||||
|
|
@ -87,32 +78,29 @@ func Test_Client_Apply(t *testing.T) {
|
|||
Ops: []Operation{
|
||||
Append(paragraph()),
|
||||
},
|
||||
System: true,
|
||||
StatusCode: 200,
|
||||
ResponseBody: `{"applied": 1, "errors": []}`,
|
||||
ExpectedApplied: 1,
|
||||
ExpectedErrors: []OpError{},
|
||||
ExpectedPath: path,
|
||||
System: true,
|
||||
StatusCode: 200,
|
||||
ResponseBody: `{"errors": []}`,
|
||||
ExpectedErrors: []OpError{},
|
||||
ExpectedPath: path,
|
||||
},
|
||||
"An ordinary batch names its user": {
|
||||
Ops: []Operation{
|
||||
Append(paragraph()),
|
||||
},
|
||||
UserID: "user-1",
|
||||
StatusCode: 200,
|
||||
ResponseBody: `{"applied": 1, "errors": []}`,
|
||||
ExpectedApplied: 1,
|
||||
ExpectedErrors: []OpError{},
|
||||
ExpectedPath: path,
|
||||
UserID: "user-1",
|
||||
StatusCode: 200,
|
||||
ResponseBody: `{"errors": []}`,
|
||||
ExpectedErrors: []OpError{},
|
||||
ExpectedPath: path,
|
||||
},
|
||||
"Partial failure surfaces per-op errors": {
|
||||
Ops: []Operation{
|
||||
Delete("missing"),
|
||||
Append(paragraph()),
|
||||
},
|
||||
StatusCode: 200,
|
||||
ResponseBody: `{"applied": 1, "errors": [{"index": 0, "message": "block_uid not found: missing"}]}`,
|
||||
ExpectedApplied: 1,
|
||||
StatusCode: 200,
|
||||
ResponseBody: `{"errors": [{"index": 0, "message": "block_uid not found: missing"}]}`,
|
||||
ExpectedErrors: []OpError{
|
||||
{Index: 0, Message: "block_uid not found: missing"},
|
||||
},
|
||||
|
|
@ -127,10 +115,9 @@ func Test_Client_Apply(t *testing.T) {
|
|||
Err: assert.AnError,
|
||||
},
|
||||
"Empty ops returns empty result without HTTP call": {
|
||||
Ops: nil,
|
||||
StatusCode: 0,
|
||||
ExpectedApplied: 0,
|
||||
NoRequest: true,
|
||||
Ops: nil,
|
||||
StatusCode: 0,
|
||||
NoRequest: true,
|
||||
},
|
||||
"Invalid base URL fails before the request": {
|
||||
Ops: []Operation{Delete("x")},
|
||||
|
|
@ -213,7 +200,6 @@ func Test_Client_Apply(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.ExpectedApplied, res.Applied)
|
||||
assert.Equal(t, c.ExpectedErrors, res.Errors)
|
||||
|
||||
if len(c.Ops) > 0 {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
package edit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/block"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/markup"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// _nodeName matches a camel-cased word, the shape of the ProseMirror node
|
||||
// types the realtime service names in its messages.
|
||||
var _nodeName = regexp.MustCompile(`\b[a-z]+[A-Z][A-Za-z]*\b`)
|
||||
// _nodeName matches a word that may be one of the ProseMirror node types
|
||||
// the realtime service names in its messages.
|
||||
var _nodeName = regexp.MustCompile(`\b[a-z][A-Za-z]*\b`)
|
||||
|
||||
// OpError describes one operation's failure on the Node side.
|
||||
type OpError struct {
|
||||
|
|
@ -25,9 +26,8 @@ type OpError struct {
|
|||
|
||||
// describe rewrites the Node-side message into what the model can act
|
||||
// on: a uid it holds no block for points at get_document, a reference
|
||||
// inside the moved block says what to pick instead, and an operation
|
||||
// kind is named as the tool the model knows it by, and a node type by
|
||||
// the canonical name the model knows. A message with no rewrite passes
|
||||
// inside the moved block says what to pick instead, and a node type is
|
||||
// named by the element the model knows. A message with no rewrite passes
|
||||
// through as it is.
|
||||
func (e OpError) describe() string {
|
||||
for _, prefix := range []string{"reference_uid not found: ", "block_uid not found: "} {
|
||||
|
|
@ -40,22 +40,24 @@ func (e OpError) describe() string {
|
|||
return fmt.Sprintf("reference block %s sits inside the block being moved; choose a reference outside it", uid)
|
||||
}
|
||||
|
||||
msg := strings.ReplaceAll(e.Message, "update_text", "update_block_text")
|
||||
|
||||
return _nodeName.ReplaceAllStringFunc(msg, func(w string) string {
|
||||
return block.DescribeNode(document.BlockNodeType(w))
|
||||
return _nodeName.ReplaceAllStringFunc(e.Message, func(w string) string {
|
||||
return markup.DescribeNode(document.BlockNodeType(w))
|
||||
})
|
||||
}
|
||||
|
||||
// JoinOpErrors renders per-operation failures as one message. The
|
||||
// index is left out: a tool sends a single operation, so naming its
|
||||
// position says nothing the reader can act on.
|
||||
func JoinOpErrors(errs []OpError) string {
|
||||
msgs := make([]string, 0, len(errs))
|
||||
|
||||
for _, e := range errs {
|
||||
msgs = append(msgs, e.describe())
|
||||
// Err renders the failed operation as an error, or returns nil when
|
||||
// every operation landed. When the batch held more than one operation,
|
||||
// the message says nothing was applied and numbers the failure from 1,
|
||||
// since the reader needs to know which one failed.
|
||||
func (r Result) Err(ops int) error {
|
||||
if len(r.Errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return strings.Join(msgs, "; ")
|
||||
e := r.Errors[0]
|
||||
if ops == 1 {
|
||||
return errors.New(e.describe())
|
||||
}
|
||||
|
||||
return fmt.Errorf("nothing was applied; operation %d: %s", e.Index+1, e.describe())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package edit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -25,13 +27,13 @@ func Test_OpError_describe(t *testing.T) {
|
|||
Msg: "reference_uid is inside the moved block: r1",
|
||||
Result: "reference block r1 sits inside the block being moved; choose a reference outside it",
|
||||
},
|
||||
"Operation kind named as its tool": {
|
||||
Msg: "update_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_text on the block holding the text.",
|
||||
Result: "update_block_text does not apply to callout: use replace_block to rewrite it whole, or update_block_text on the block holding the text.",
|
||||
"Node types named by their elements": {
|
||||
Msg: "paragraph is not allowed there in calloutBlock, which takes bulletList or orderedList at that point.",
|
||||
Result: "<p> is not allowed there in <callout>, which takes <ul> or <ol> at that point.",
|
||||
},
|
||||
"Node type without a canonical type named by its block": {
|
||||
Msg: "update_attrs does not apply to codeBlockTitle in nrk_7bSz-mJp",
|
||||
Result: "update_attrs does not apply to a titled_code's title in nrk_7bSz-mJp",
|
||||
"Node type without an element named by the element holding it": {
|
||||
Msg: "codeBlockTitle would end without text in nrk_7bSz-mJp",
|
||||
Result: "a titled <pre>'s title would end without text in nrk_7bSz-mJp",
|
||||
},
|
||||
"Unknown message passes through": {
|
||||
Msg: "cannot move a block relative to itself: a",
|
||||
|
|
@ -48,15 +50,39 @@ func Test_OpError_describe(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_JoinOpErrors(t *testing.T) {
|
||||
func Test_Result_Err(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := JoinOpErrors([]OpError{
|
||||
{Index: 0, Message: "block_uid not found: a"},
|
||||
{Index: 1, Message: "something else"},
|
||||
})
|
||||
errs := []OpError{{Index: 2, Message: "block_uid not found: a"}}
|
||||
|
||||
// each message is rewritten for the model and the index is left out,
|
||||
// since a tool ships one operation.
|
||||
assert.Equal(t, "no block with uid a in this document; call get_document for the current uids; something else", got)
|
||||
// the message is rewritten for the model. The index and the prefix
|
||||
// only say something when the batch held more than one operation.
|
||||
cc := map[string]struct {
|
||||
Result Result
|
||||
Ops int
|
||||
Err error
|
||||
}{
|
||||
"No errors": {
|
||||
Result: Result{},
|
||||
Ops: 3,
|
||||
},
|
||||
"Single operation": {
|
||||
Result: Result{Errors: errs},
|
||||
Ops: 1,
|
||||
Err: errors.New("no block with uid a in this document; call get_document for the current uids"),
|
||||
},
|
||||
"Several operations": {
|
||||
Result: Result{Errors: errs},
|
||||
Ops: 3,
|
||||
Err: errors.New("nothing was applied; operation 3: no block with uid a in this document; call get_document for the current uids"),
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testutil.AssertEqualError(t, c.Err, c.Result.Err(c.Ops))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// operations endpoint that mutates live Y.Docs. AI-generated edits
|
||||
// flow:
|
||||
//
|
||||
// block.Block --block.Expand--> document.Block (PM JSON)
|
||||
// markup --markup.Build--> document.Block (PM JSON)
|
||||
// |
|
||||
// v
|
||||
// edit.Operation --Apply--> POST /api/internal/.../operations
|
||||
|
|
@ -12,169 +12,22 @@
|
|||
// one transaction, broadcasts to subscribers, and disconnects.
|
||||
//
|
||||
// Use the package-level constructors (InsertAfter, Append,
|
||||
// UpdateText, …) to build operations; Client.Apply ships them to
|
||||
// Node and returns a per-op outcome.
|
||||
// Replace, …) to build operations; Client.Apply ships them to
|
||||
// Node, which applies the whole batch or none of it.
|
||||
package edit
|
||||
|
||||
import (
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// Operation is one edit to apply to a live document. An Operation
|
||||
// returns the JSON shape expected by the Node endpoint; a failure to
|
||||
// produce one is reported as an error so Apply can fail fast before
|
||||
// the round-trip. Operations are constructed via the package-level
|
||||
// helpers (InsertAfter, InsertBefore, Append, Prepend, Replace,
|
||||
// UpdateText, UpdateAttrs, Delete, SetName, SetIcon) and applied as a
|
||||
// batch via Client.Apply. A block-carrying operation takes the
|
||||
// expanded editor tree, uids already resolved, so the caller can
|
||||
// report the uids the write lands with.
|
||||
type Operation func() (wireOp, error)
|
||||
|
||||
// InsertAfter builds an operation that inserts b immediately after
|
||||
// the block identified by referenceUID. b is the expanded editor
|
||||
// tree, uids resolved, so the caller knows the uids the write lands
|
||||
// with.
|
||||
func InsertAfter(referenceUID string, b document.Block) Operation {
|
||||
return insert("after", referenceUID, b)
|
||||
}
|
||||
|
||||
// InsertBefore builds an operation that inserts b immediately
|
||||
// before the block identified by referenceUID.
|
||||
func InsertBefore(referenceUID string, b document.Block) Operation {
|
||||
return insert("before", referenceUID, b)
|
||||
}
|
||||
|
||||
// insert builds the shared wire closure behind InsertAfter and
|
||||
// InsertBefore.
|
||||
func insert(position, referenceUID string, b document.Block) Operation {
|
||||
return func() (wireOp, error) {
|
||||
return wireOp{
|
||||
Kind: "insert",
|
||||
Position: position,
|
||||
ReferenceUID: referenceUID,
|
||||
Block: &b,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Append builds an operation that adds b at the end of the
|
||||
// document's top-level content.
|
||||
func Append(b document.Block) Operation {
|
||||
return func() (wireOp, error) {
|
||||
return wireOp{Kind: "append", Block: &b}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend builds an operation that adds b at the start of the
|
||||
// document's top-level content.
|
||||
func Prepend(b document.Block) Operation {
|
||||
return func() (wireOp, error) {
|
||||
return wireOp{Kind: "prepend", Block: &b}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Replace builds an operation that replaces the block identified by
|
||||
// blockUID with b. The replacement preserves the original position
|
||||
// in its parent and lands with the uids b carries.
|
||||
func Replace(blockUID string, b document.Block) Operation {
|
||||
return func() (wireOp, error) {
|
||||
return wireOp{Kind: "replace", BlockUID: blockUID, Block: &b}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateText builds an operation that replaces the inline text of a
|
||||
// text-bearing block, leaving its type and attrs unchanged. content
|
||||
// is the inline content as block.TextContent parses it for the block.
|
||||
func UpdateText(blockUID string, content []document.Block) Operation {
|
||||
return func() (wireOp, error) {
|
||||
return wireOp{
|
||||
Kind: "update_text",
|
||||
BlockUID: blockUID,
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateAttrs builds an operation that sets the named attributes on
|
||||
// an existing block. Unmentioned attrs are preserved; the uid attr
|
||||
// cannot be changed and is silently ignored on the Node side.
|
||||
func UpdateAttrs(blockUID string, attrs map[string]any) Operation {
|
||||
return func() (wireOp, error) {
|
||||
return wireOp{
|
||||
Kind: "update_attrs",
|
||||
BlockUID: blockUID,
|
||||
Attrs: attrs,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Delete builds an operation that removes the block identified by
|
||||
// blockUID from the document.
|
||||
func Delete(blockUID string) Operation {
|
||||
return func() (wireOp, error) {
|
||||
return wireOp{
|
||||
Kind: "delete",
|
||||
BlockUID: blockUID,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// MoveAfter builds an operation that repositions the block identified
|
||||
// by blockUID to immediately after the block identified by
|
||||
// referenceUID, keeping the block's uid, attrs, and nested content.
|
||||
func MoveAfter(blockUID, referenceUID string) Operation {
|
||||
return move("after", blockUID, referenceUID)
|
||||
}
|
||||
|
||||
// MoveBefore builds an operation that repositions the block
|
||||
// identified by blockUID to immediately before the block identified
|
||||
// by referenceUID.
|
||||
func MoveBefore(blockUID, referenceUID string) Operation {
|
||||
return move("before", blockUID, referenceUID)
|
||||
}
|
||||
|
||||
// move builds the shared wire closure behind MoveAfter and
|
||||
// MoveBefore.
|
||||
func move(position, blockUID, referenceUID string) Operation {
|
||||
return func() (wireOp, error) {
|
||||
return wireOp{
|
||||
Kind: "move",
|
||||
Position: position,
|
||||
BlockUID: blockUID,
|
||||
ReferenceUID: referenceUID,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetName builds an operation that updates the document's display
|
||||
// name. The op is sent even when name is empty: omitempty drops the
|
||||
// name field from the wire form, and Node treats the missing name as
|
||||
// falsy and clears the title.
|
||||
func SetName(name string) Operation {
|
||||
return func() (wireOp, error) {
|
||||
return wireOp{
|
||||
Kind: "set_name",
|
||||
Name: name,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetIcon builds an operation that updates the document's icon
|
||||
// identifier (e.g. "lucide:rocket").
|
||||
func SetIcon(icon string) Operation {
|
||||
return func() (wireOp, error) {
|
||||
return wireOp{
|
||||
Kind: "set_icon",
|
||||
Icon: icon,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// wireOp is the JSON shape the Node endpoint expects for one
|
||||
// operation. Only the fields meaningful to a given kind are set;
|
||||
// omitempty keeps the wire payload tight.
|
||||
type wireOp struct {
|
||||
// Operation is one edit to apply to a live document, in the JSON shape
|
||||
// the Node endpoint expects. Only the fields meaningful to its kind are
|
||||
// set; omitempty keeps the wire payload tight. Operations are built with
|
||||
// the package-level constructors and applied as a batch via
|
||||
// Client.Apply. A block-carrying operation takes the expanded editor
|
||||
// tree, uids already resolved, so the caller can report the uids the
|
||||
// write lands with.
|
||||
type Operation struct {
|
||||
// Kind names the operation ("insert", "append", …); set on
|
||||
// every op.
|
||||
Kind string `json:"kind"`
|
||||
|
|
@ -188,17 +41,13 @@ type wireOp struct {
|
|||
ReferenceUID string `json:"reference_uid,omitempty"`
|
||||
|
||||
// BlockUID identifies the block the operation targets; replace,
|
||||
// update_text, update_attrs, delete, and move.
|
||||
// update_attrs, delete, and move.
|
||||
BlockUID string `json:"block_uid,omitempty"`
|
||||
|
||||
// Block is the expanded ProseMirror payload; insert, append,
|
||||
// prepend, and replace.
|
||||
Block *document.Block `json:"block,omitempty"`
|
||||
|
||||
// Content is the parsed inline replacement text; update_text
|
||||
// only.
|
||||
Content []document.Block `json:"content,omitempty"`
|
||||
|
||||
// Attrs holds the attributes to set; update_attrs only.
|
||||
Attrs map[string]any `json:"attrs,omitempty"`
|
||||
|
||||
|
|
@ -208,3 +57,118 @@ type wireOp struct {
|
|||
// Icon is the new document icon identifier; set_icon only.
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
// InsertAfter builds an operation that inserts b immediately after
|
||||
// the block identified by referenceUID.
|
||||
func InsertAfter(referenceUID string, b document.Block) Operation {
|
||||
return Operation{
|
||||
Kind: "insert",
|
||||
Position: "after",
|
||||
ReferenceUID: referenceUID,
|
||||
Block: &b,
|
||||
}
|
||||
}
|
||||
|
||||
// InsertBefore builds an operation that inserts b immediately
|
||||
// before the block identified by referenceUID.
|
||||
func InsertBefore(referenceUID string, b document.Block) Operation {
|
||||
return Operation{
|
||||
Kind: "insert",
|
||||
Position: "before",
|
||||
ReferenceUID: referenceUID,
|
||||
Block: &b,
|
||||
}
|
||||
}
|
||||
|
||||
// Append builds an operation that adds b at the end of the
|
||||
// document's top-level content.
|
||||
func Append(b document.Block) Operation {
|
||||
return Operation{
|
||||
Kind: "append",
|
||||
Block: &b,
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend builds an operation that adds b at the start of the
|
||||
// document's top-level content.
|
||||
func Prepend(b document.Block) Operation {
|
||||
return Operation{
|
||||
Kind: "prepend",
|
||||
Block: &b,
|
||||
}
|
||||
}
|
||||
|
||||
// Replace builds an operation that replaces the block identified by
|
||||
// blockUID with b. The replacement preserves the original position
|
||||
// in its parent and lands with the uids b carries.
|
||||
func Replace(blockUID string, b document.Block) Operation {
|
||||
return Operation{
|
||||
Kind: "replace",
|
||||
BlockUID: blockUID,
|
||||
Block: &b,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateAttrs builds an operation that sets the named attributes on
|
||||
// an existing block. Unmentioned attrs are preserved; the uid attr
|
||||
// cannot be changed and is silently ignored on the Node side.
|
||||
func UpdateAttrs(blockUID string, attrs map[string]any) Operation {
|
||||
return Operation{
|
||||
Kind: "update_attrs",
|
||||
BlockUID: blockUID,
|
||||
Attrs: attrs,
|
||||
}
|
||||
}
|
||||
|
||||
// Delete builds an operation that removes the block identified by
|
||||
// blockUID from the document.
|
||||
func Delete(blockUID string) Operation {
|
||||
return Operation{
|
||||
Kind: "delete",
|
||||
BlockUID: blockUID,
|
||||
}
|
||||
}
|
||||
|
||||
// MoveAfter builds an operation that repositions the block identified
|
||||
// by blockUID to immediately after the block identified by
|
||||
// referenceUID, keeping the block's uid, attrs, and nested content.
|
||||
func MoveAfter(blockUID, referenceUID string) Operation {
|
||||
return Operation{
|
||||
Kind: "move",
|
||||
Position: "after",
|
||||
BlockUID: blockUID,
|
||||
ReferenceUID: referenceUID,
|
||||
}
|
||||
}
|
||||
|
||||
// MoveBefore builds an operation that repositions the block
|
||||
// identified by blockUID to immediately before the block identified
|
||||
// by referenceUID.
|
||||
func MoveBefore(blockUID, referenceUID string) Operation {
|
||||
return Operation{
|
||||
Kind: "move",
|
||||
Position: "before",
|
||||
BlockUID: blockUID,
|
||||
ReferenceUID: referenceUID,
|
||||
}
|
||||
}
|
||||
|
||||
// SetName builds an operation that updates the document's display
|
||||
// name. The op is sent even when name is empty: omitempty drops the
|
||||
// name field from the wire form, and Node treats the missing name as
|
||||
// falsy and clears the title.
|
||||
func SetName(name string) Operation {
|
||||
return Operation{
|
||||
Kind: "set_name",
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// SetIcon builds an operation that updates the document's icon
|
||||
// identifier (e.g. "lucide:rocket").
|
||||
func SetIcon(icon string) Operation {
|
||||
return Operation{
|
||||
Kind: "set_icon",
|
||||
Icon: icon,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// paragraph is the block every operation that carries one is built with,
|
||||
|
|
@ -18,168 +17,80 @@ func paragraph() document.Block {
|
|||
}
|
||||
}
|
||||
|
||||
// wire runs the operation and returns the wire form it produced.
|
||||
func wire(t *testing.T, op Operation) wireOp {
|
||||
t.Helper()
|
||||
|
||||
w, err := op()
|
||||
require.NoError(t, err)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func Test_InsertAfter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, InsertAfter("ref-uid", paragraph()))
|
||||
b := paragraph()
|
||||
|
||||
assert.Equal(t, "insert", w.Kind)
|
||||
assert.Equal(t, "after", w.Position)
|
||||
assert.Equal(t, "ref-uid", w.ReferenceUID)
|
||||
require.NotNil(t, w.Block)
|
||||
assert.Equal(t, document.BlockNodeParagraph, w.Block.Type)
|
||||
// the block ships as handed over, uids included, so what the caller
|
||||
// reported is what lands.
|
||||
assert.Equal(t, Operation{Kind: "insert", Position: "after", ReferenceUID: "ref", Block: &b}, InsertAfter("ref", paragraph()))
|
||||
}
|
||||
|
||||
func Test_InsertBefore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, InsertBefore("ref", paragraph()))
|
||||
b := paragraph()
|
||||
|
||||
assert.Equal(t, "insert", w.Kind)
|
||||
assert.Equal(t, "before", w.Position)
|
||||
assert.Equal(t, "ref", w.ReferenceUID)
|
||||
}
|
||||
|
||||
func Test_insert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, insert("before", "ref-uid", paragraph()))
|
||||
|
||||
assert.Equal(t, "insert", w.Kind)
|
||||
assert.Equal(t, "before", w.Position)
|
||||
assert.Equal(t, "ref-uid", w.ReferenceUID)
|
||||
require.NotNil(t, w.Block)
|
||||
assert.Equal(t, document.BlockNodeParagraph, w.Block.Type)
|
||||
|
||||
// the block ships as handed over, uids included, so what the caller
|
||||
// reported is what lands.
|
||||
uid, ok := w.Block.UID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "p", uid)
|
||||
assert.Equal(t, Operation{Kind: "insert", Position: "before", ReferenceUID: "ref", Block: &b}, InsertBefore("ref", paragraph()))
|
||||
}
|
||||
|
||||
func Test_Append(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, Append(paragraph()))
|
||||
b := paragraph()
|
||||
|
||||
assert.Equal(t, "append", w.Kind)
|
||||
assert.Empty(t, w.Position)
|
||||
assert.Empty(t, w.ReferenceUID)
|
||||
require.NotNil(t, w.Block)
|
||||
assert.Equal(t, Operation{Kind: "append", Block: &b}, Append(paragraph()))
|
||||
}
|
||||
|
||||
func Test_Prepend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, Prepend(paragraph()))
|
||||
b := paragraph()
|
||||
|
||||
assert.Equal(t, "prepend", w.Kind)
|
||||
require.NotNil(t, w.Block)
|
||||
assert.Equal(t, Operation{Kind: "prepend", Block: &b}, Prepend(paragraph()))
|
||||
}
|
||||
|
||||
func Test_Replace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, Replace("target", paragraph()))
|
||||
b := paragraph()
|
||||
|
||||
assert.Equal(t, "replace", w.Kind)
|
||||
assert.Equal(t, "target", w.BlockUID)
|
||||
require.NotNil(t, w.Block)
|
||||
}
|
||||
|
||||
func Test_UpdateText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := []document.Block{{Type: document.BlockNodeText, Text: "my_var"}}
|
||||
|
||||
w := wire(t, UpdateText("target", content))
|
||||
|
||||
assert.Equal(t, "update_text", w.Kind)
|
||||
assert.Equal(t, "target", w.BlockUID)
|
||||
assert.Nil(t, w.Block)
|
||||
assert.Equal(t, content, w.Content)
|
||||
assert.Equal(t, Operation{Kind: "replace", BlockUID: "target", Block: &b}, Replace("target", paragraph()))
|
||||
}
|
||||
|
||||
func Test_UpdateAttrs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, UpdateAttrs("target", map[string]any{"level": 3}))
|
||||
|
||||
assert.Equal(t, "update_attrs", w.Kind)
|
||||
assert.Equal(t, "target", w.BlockUID)
|
||||
assert.Equal(t, 3, w.Attrs["level"])
|
||||
assert.Equal(t, Operation{Kind: "update_attrs", BlockUID: "target", Attrs: map[string]any{"level": 3}}, UpdateAttrs("target", map[string]any{"level": 3}))
|
||||
}
|
||||
|
||||
func Test_Delete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, Delete("target"))
|
||||
|
||||
assert.Equal(t, "delete", w.Kind)
|
||||
assert.Equal(t, "target", w.BlockUID)
|
||||
assert.Nil(t, w.Block)
|
||||
assert.Equal(t, Operation{Kind: "delete", BlockUID: "target"}, Delete("target"))
|
||||
}
|
||||
|
||||
func Test_MoveAfter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, MoveAfter("target", "ref-uid"))
|
||||
|
||||
assert.Equal(t, "move", w.Kind)
|
||||
assert.Equal(t, "after", w.Position)
|
||||
assert.Equal(t, "target", w.BlockUID)
|
||||
assert.Equal(t, "ref-uid", w.ReferenceUID)
|
||||
assert.Nil(t, w.Block)
|
||||
assert.Equal(t, Operation{Kind: "move", Position: "after", BlockUID: "target", ReferenceUID: "ref"}, MoveAfter("target", "ref"))
|
||||
}
|
||||
|
||||
func Test_MoveBefore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, MoveBefore("target", "ref-uid"))
|
||||
|
||||
assert.Equal(t, "move", w.Kind)
|
||||
assert.Equal(t, "before", w.Position)
|
||||
assert.Equal(t, "target", w.BlockUID)
|
||||
assert.Equal(t, "ref-uid", w.ReferenceUID)
|
||||
}
|
||||
|
||||
func Test_move(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, move("before", "target", "ref-uid"))
|
||||
|
||||
assert.Equal(t, "move", w.Kind)
|
||||
assert.Equal(t, "before", w.Position)
|
||||
assert.Equal(t, "target", w.BlockUID)
|
||||
assert.Equal(t, "ref-uid", w.ReferenceUID)
|
||||
assert.Nil(t, w.Block)
|
||||
assert.Equal(t, Operation{Kind: "move", Position: "before", BlockUID: "target", ReferenceUID: "ref"}, MoveBefore("target", "ref"))
|
||||
}
|
||||
|
||||
func Test_SetName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, SetName("New title"))
|
||||
|
||||
assert.Equal(t, "set_name", w.Kind)
|
||||
assert.Equal(t, "New title", w.Name)
|
||||
assert.Equal(t, Operation{Kind: "set_name", Name: "New title"}, SetName("New title"))
|
||||
}
|
||||
|
||||
func Test_SetIcon(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := wire(t, SetIcon("lucide:file-text"))
|
||||
|
||||
assert.Equal(t, "set_icon", w.Kind)
|
||||
assert.Equal(t, "lucide:file-text", w.Icon)
|
||||
assert.Equal(t, Operation{Kind: "set_icon", Icon: "lucide:file-text"}, SetIcon("lucide:file-text"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -357,11 +357,11 @@ func Test_Manager_ToolSet(t *testing.T) {
|
|||
s := m.ToolSet("org1", "user1")
|
||||
require.NotNil(t, s)
|
||||
|
||||
// the set carries the full registry — the thirteen document tools,
|
||||
// the seven tag tools, the five hook tools, the nine data-source
|
||||
// the set carries the full registry — the ten document tools,
|
||||
// the five tag tools, the four hook tools, the five data-source
|
||||
// tools and the offloaded-result reader — wired from the manager's
|
||||
// shared dependencies and scoped to the requested pair.
|
||||
assert.Len(t, s.Tools(), 35)
|
||||
assert.Len(t, s.Tools(), 25)
|
||||
}
|
||||
|
||||
func Test_Manager_claimTurn(t *testing.T) {
|
||||
|
|
|
|||
540
server/core/internal/assistant/markup/build.go
Normal file
540
server/core/internal/assistant/markup/build.go
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/strutil"
|
||||
)
|
||||
|
||||
// _defaultCalloutIcon is the icon a callout written without one gets,
|
||||
// as in the editor.
|
||||
const _defaultCalloutIcon = "lucide:text"
|
||||
|
||||
// _maxBlocks caps the blocks one write may hold at the top level, each
|
||||
// of which is one operation for the realtime service.
|
||||
const _maxBlocks = 500
|
||||
|
||||
// _containers are the elements that only hold blocks, each with the
|
||||
// node it writes and the one element it holds, if it holds only one.
|
||||
var _containers = map[string]struct {
|
||||
// Type is the node the element writes.
|
||||
Type document.BlockNodeType
|
||||
|
||||
// Child is the one element it holds; empty when it holds any block.
|
||||
Child string
|
||||
}{
|
||||
_elemBlockquote: {document.BlockNodeBlockquote, ""},
|
||||
_elemCallout: {document.BlockNodeCalloutBlock, ""},
|
||||
_elemBullets: {document.BlockNodeBulletList, _elemEntry},
|
||||
_elemOrdered: {document.BlockNodeOrderedList, _elemEntry},
|
||||
_elemTasks: {document.BlockNodeTaskList, _elemTask},
|
||||
_elemMetrics: {document.BlockNodeMetricGrid, _elemMetric},
|
||||
}
|
||||
|
||||
// _atoms are the elements that hold nothing but attributes: the node
|
||||
// each writes, its string attributes and its positive integer ones.
|
||||
var _atoms = map[string]struct {
|
||||
// Type is the node the element writes.
|
||||
Type document.BlockNodeType
|
||||
|
||||
// Strings are its string attributes.
|
||||
Strings []string
|
||||
|
||||
// Ints are its positive integer attributes.
|
||||
Ints []string
|
||||
}{
|
||||
_elemImage: {document.BlockNodeImageBlock, []string{document.AttrSrc, document.AttrAlt, document.AttrTitle}, []string{document.AttrWidth}},
|
||||
_elemFigma: {document.BlockNodeFigmaBlock, []string{document.AttrSrc}, []string{document.AttrWidth, document.AttrHeight}},
|
||||
}
|
||||
|
||||
// Build turns markup into the blocks it describes, to be written into
|
||||
// stored. An element whose id stored holds replaces that block: it keeps
|
||||
// the block's uid and block comment, and is the stored block itself when
|
||||
// its visible content is unchanged, so what the markup does not show
|
||||
// survives. Every other element gets a new uid.
|
||||
func Build(markup string, stored document.RootBlock) ([]document.Block, error) {
|
||||
nodes, err := parse(markup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b := builder{stored: stored, used: map[string]bool{}}
|
||||
|
||||
blocks, err := b.blocks(nodes, "the markup")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
return nil, errors.New("the markup holds no block; to remove one, use delete_block")
|
||||
}
|
||||
|
||||
if len(blocks) > _maxBlocks {
|
||||
return nil, fmt.Errorf("%d blocks are over the limit of %d per call; send them in several calls", len(blocks), _maxBlocks)
|
||||
}
|
||||
|
||||
// an element may name a block that another element in the markup
|
||||
// also keeps as a hidden part, such as a list entry's paragraph.
|
||||
if uid, found := findDuplicateUID(blocks, map[string]bool{}); found {
|
||||
return nil, fmt.Errorf("id %q is kept twice: by its own element and as part of another one; leave it out of one of them", uid)
|
||||
}
|
||||
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// builder turns parsed elements into blocks.
|
||||
type builder struct {
|
||||
// stored is the document being written into.
|
||||
stored document.RootBlock
|
||||
|
||||
// used holds the ids the markup's elements have named, since one id
|
||||
// names one block.
|
||||
used map[string]bool
|
||||
}
|
||||
|
||||
// blocks builds an element per block. where names the container for
|
||||
// the message about text between the elements.
|
||||
func (b *builder) blocks(nodes []node, where string) ([]document.Block, error) {
|
||||
elems, err := elements(nodes, where, "an element such as <p>")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []document.Block
|
||||
|
||||
for _, n := range elems {
|
||||
stored, err := b.replaced(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blk, err := b.build(n, stored)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = append(out, keepIfUnchanged(stored, blk))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// replaced returns the stored block n's id names, or the zero block for
|
||||
// an element without an id.
|
||||
func (b *builder) replaced(n node) (document.Block, error) {
|
||||
id, ok := n.attrs[_attrID]
|
||||
if !ok {
|
||||
return document.Block{}, nil
|
||||
}
|
||||
|
||||
if b.used[id] {
|
||||
return document.Block{}, fmt.Errorf("line %d: id %q is used twice; leave it out of the copy", n.line, id)
|
||||
}
|
||||
|
||||
stored, found := b.stored.FindByUID(id)
|
||||
if !found {
|
||||
return document.Block{}, fmt.Errorf("line %d: id %q is not a block this write can keep; leave id out for a new block", n.line, id)
|
||||
}
|
||||
|
||||
b.used[id] = true
|
||||
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
// build turns one element into its block, nested ones included. stored
|
||||
// is the block it replaces, or the zero block.
|
||||
func (b *builder) build(n node, stored document.Block) (document.Block, error) {
|
||||
if c, ok := _containers[n.name]; ok {
|
||||
return b.buildContainer(n, c.Type, c.Child, stored)
|
||||
}
|
||||
|
||||
if a, ok := _atoms[n.name]; ok {
|
||||
return buildAtom(n, a.Type, a.Strings, a.Ints, stored)
|
||||
}
|
||||
|
||||
switch n.name {
|
||||
case _elemParagraph, "h1", "h2", "h3":
|
||||
return buildText(n, stored)
|
||||
case _elemEntry, _elemTask:
|
||||
return b.buildEntry(n, stored)
|
||||
case _elemCode:
|
||||
return buildCode(n, stored), nil
|
||||
case _elemMermaid:
|
||||
return newBlock(document.BlockNodeMermaidBlock, stored, textNodes([]run{{text: n.text}}, stored.Content)), nil
|
||||
case _elemRule:
|
||||
return newBlock(document.BlockNodeHorizontalRule, stored, nil), nil
|
||||
case _elemMetric:
|
||||
return buildMetric(n, stored)
|
||||
case _elemSplitDoc:
|
||||
return b.buildSplitDoc(n, stored)
|
||||
case _elemParams:
|
||||
return b.buildParams(n, stored)
|
||||
case _elemFile, _elemUnsupported:
|
||||
// neither can be written: an uploaded file or a node this model
|
||||
// does not know is only ever kept as it is, by its own element.
|
||||
_, known := _nodeNames[stored.Type]
|
||||
if stored.Type != document.BlockNodeFileBlock && (known || stored.Type == "") {
|
||||
return document.Block{}, fmt.Errorf("line %d: <%s> can only keep a block get_document showed as <%s>, by its id", n.line, n.name, n.name)
|
||||
}
|
||||
|
||||
return stored, nil
|
||||
case _elemLeft, _elemRight:
|
||||
return document.Block{}, fmt.Errorf("line %d: <%s> belongs inside <%s>", n.line, n.name, _elemSplitDoc)
|
||||
case _elemParam:
|
||||
return document.Block{}, fmt.Errorf("line %d: <%s> belongs inside <%s>", n.line, n.name, _elemParams)
|
||||
default:
|
||||
return document.Block{}, fmt.Errorf("line %d: <%s> is inline; put it inside a <p>", n.line, n.name)
|
||||
}
|
||||
}
|
||||
|
||||
// buildText builds a paragraph or a heading. A heading takes plain text,
|
||||
// its level from the element name.
|
||||
func buildText(n node, stored document.Block) (document.Block, error) {
|
||||
paragraph := n.name == _elemParagraph
|
||||
|
||||
for _, c := range n.children {
|
||||
if _, isTag := _tagMarks[c.name]; isTag && !paragraph {
|
||||
return document.Block{}, fmt.Errorf("line %d: a heading takes plain text, so <%s> is not allowed in it", c.line, c.name)
|
||||
}
|
||||
}
|
||||
|
||||
content, err := inline(n.children, stored.Content)
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
if paragraph {
|
||||
return newBlock(document.BlockNodeParagraph, stored, content), nil
|
||||
}
|
||||
|
||||
blk := newBlock(document.BlockNodeHeading, stored, content)
|
||||
blk.Attrs[document.AttrLevel] = int(n.name[1] - '0')
|
||||
|
||||
return blk, nil
|
||||
}
|
||||
|
||||
// buildContainer builds an element holding blocks. child, when set, is
|
||||
// the one element name the container holds.
|
||||
func (b *builder) buildContainer(n node, typ document.BlockNodeType, child string, stored document.Block) (document.Block, error) {
|
||||
if child != "" {
|
||||
for _, c := range n.children {
|
||||
if c.name != "" && c.name != child {
|
||||
return document.Block{}, fmt.Errorf("line %d: <%s> holds only <%s>, not <%s>", c.line, n.name, child, c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content, err := b.blocks(n.children, "<"+n.name+">")
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
blk := newBlock(typ, stored, content)
|
||||
|
||||
switch typ {
|
||||
case document.BlockNodeCalloutBlock:
|
||||
blk.Attrs[document.AttrIcon] = cmp.Or(n.attrs[document.AttrIcon], _defaultCalloutIcon)
|
||||
case document.BlockNodeOrderedList:
|
||||
if start, err := strconv.Atoi(n.attrs[document.AttrStart]); err == nil && start > 1 {
|
||||
blk.Attrs[document.AttrStart] = start
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
return blk, nil
|
||||
}
|
||||
|
||||
// buildEntry builds a list or task entry: the text before its first
|
||||
// element is the entry's paragraph, and the elements after it are nested
|
||||
// under it.
|
||||
func (b *builder) buildEntry(n node, stored document.Block) (document.Block, error) {
|
||||
split := len(n.children)
|
||||
blank := true
|
||||
|
||||
for i, c := range n.children {
|
||||
if _, inlineTag := _tagMarks[c.name]; c.name != "" && !inlineTag {
|
||||
split = i
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
blank = blank && c.name == "" && strings.TrimSpace(c.text) == ""
|
||||
}
|
||||
|
||||
textNodes, nestedNodes := n.children[:split], n.children[split:]
|
||||
|
||||
// models used to HTML often wrap an entry's text in a <p>. A <p>
|
||||
// with an id is a block the markup keeps, so it stays nested.
|
||||
if blank && split < len(n.children) {
|
||||
first := n.children[split]
|
||||
|
||||
if _, hasID := first.attrs[_attrID]; first.name == _elemParagraph && !hasID {
|
||||
textNodes, nestedNodes = first.children, n.children[split+1:]
|
||||
}
|
||||
}
|
||||
|
||||
typ := document.BlockNodeListItem
|
||||
if n.name == _elemTask {
|
||||
typ = document.BlockNodeTaskItem
|
||||
}
|
||||
|
||||
storedParagraph := stored.FirstChild(document.BlockNodeParagraph)
|
||||
|
||||
text, err := inline(textNodes, storedParagraph.Content)
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
nested, err := b.blocks(nestedNodes, "<"+n.name+"> after its text")
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
blk := newBlock(typ, stored, append([]document.Block{newBlock(document.BlockNodeParagraph, storedParagraph, text)}, nested...))
|
||||
|
||||
if typ == document.BlockNodeTaskItem {
|
||||
blk.Attrs[document.AttrChecked] = n.attrs[document.AttrChecked] == "true"
|
||||
}
|
||||
|
||||
return blk, nil
|
||||
}
|
||||
|
||||
// buildCode builds a pre: a titled code block when it carries a title,
|
||||
// a code block otherwise.
|
||||
func buildCode(n node, stored document.Block) document.Block {
|
||||
title, titled := n.attrs[document.AttrTitle]
|
||||
|
||||
storedCode := stored
|
||||
if titled {
|
||||
storedCode = stored.FirstChild(document.BlockNodeCodeBlock)
|
||||
}
|
||||
|
||||
codeBlock := newBlock(document.BlockNodeCodeBlock, storedCode, textNodes([]run{{text: n.text}}, storedCode.Content))
|
||||
if lang := n.attrs[document.AttrLanguage]; lang != "" {
|
||||
codeBlock.Attrs[document.AttrLanguage] = lang
|
||||
}
|
||||
|
||||
if !titled {
|
||||
return codeBlock
|
||||
}
|
||||
|
||||
return newBlock(document.BlockNodeTitledCodeBlock, stored, []document.Block{
|
||||
titleBlock(document.BlockNodeCodeBlockTitle, title, stored),
|
||||
codeBlock,
|
||||
})
|
||||
}
|
||||
|
||||
// buildSplitDoc builds a split documentation block from its left and
|
||||
// right sides.
|
||||
func (b *builder) buildSplitDoc(n node, stored document.Block) (document.Block, error) {
|
||||
sides, err := elements(n.children, "<split_doc>", "<left> or <right>")
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
if len(sides) != 2 || sides[0].name != _elemLeft || sides[1].name != _elemRight {
|
||||
return document.Block{}, fmt.Errorf("line %d: <split_doc> holds a <left> and then a <right>", n.line)
|
||||
}
|
||||
|
||||
left, err := b.blocks(sides[0].children, "<left>")
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
right, err := b.blocks(sides[1].children, "<right>")
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
blk := newBlock(document.BlockNodeSplitDoc, stored, []document.Block{
|
||||
newBlock(document.BlockNodeSplitDocLeft, stored.FirstChild(document.BlockNodeSplitDocLeft), left),
|
||||
newBlock(document.BlockNodeSplitDocRight, stored.FirstChild(document.BlockNodeSplitDocRight), right),
|
||||
})
|
||||
|
||||
blk.Attrs[document.AttrInversed] = n.attrs[document.AttrInversed] == "true"
|
||||
|
||||
return blk, nil
|
||||
}
|
||||
|
||||
// buildParams builds a parameter list from its header and its param
|
||||
// rows.
|
||||
func (b *builder) buildParams(n node, stored document.Block) (document.Block, error) {
|
||||
content := []document.Block{titleBlock(document.BlockNodeParamListHeader, n.attrs[_attrHeader], stored)}
|
||||
|
||||
rows, err := elements(n.children, "<params>", "a <param>")
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
for _, c := range rows {
|
||||
if c.name != _elemParam {
|
||||
return document.Block{}, fmt.Errorf("line %d: <params> holds only <param>, not <%s>", c.line, c.name)
|
||||
}
|
||||
|
||||
storedRow, err := b.replaced(c)
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
row, err := buildParam(c, storedRow)
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
content = append(content, keepIfUnchanged(storedRow, row))
|
||||
}
|
||||
|
||||
return newBlock(document.BlockNodeParamList, stored, content), nil
|
||||
}
|
||||
|
||||
// buildParam builds one parameter row: a header holding its name and
|
||||
// type cells, then its description.
|
||||
func buildParam(n node, stored document.Block) (document.Block, error) {
|
||||
storedHeader := stored.FirstChild(document.BlockNodeParamListItemHeader)
|
||||
storedDescription := stored.FirstChild(document.BlockNodeParagraph)
|
||||
|
||||
description, err := inline(n.children, storedDescription.Content)
|
||||
if err != nil {
|
||||
return document.Block{}, err
|
||||
}
|
||||
|
||||
return newBlock(document.BlockNodeParamListItem, stored, []document.Block{
|
||||
newBlock(document.BlockNodeParamListItemHeader, storedHeader, []document.Block{
|
||||
titleBlock(document.BlockNodeParamListItemTitle, n.attrs[document.AttrName], storedHeader),
|
||||
titleBlock(document.BlockNodeParamListItemType, n.attrs[_attrType], storedHeader),
|
||||
}),
|
||||
newBlock(document.BlockNodeParagraph, storedDescription, description),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// buildMetric builds a metric block from its JSON attributes.
|
||||
func buildMetric(n node, stored document.Block) (document.Block, error) {
|
||||
attrs, err := decodeMetricAttrs(n.text)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("line %d: %w", n.line, err)
|
||||
}
|
||||
|
||||
for k := range _hiddenMetricAttrs {
|
||||
delete(attrs, k)
|
||||
}
|
||||
|
||||
setSimulation(attrs, stored)
|
||||
|
||||
blk := newBlock(document.BlockNodeMetricBlock, stored, nil)
|
||||
maps.Copy(blk.Attrs, attrs)
|
||||
|
||||
return blk, nil
|
||||
}
|
||||
|
||||
// buildAtom builds an element that holds nothing but attributes.
|
||||
func buildAtom(n node, typ document.BlockNodeType, strs, ints []string, stored document.Block) (document.Block, error) {
|
||||
blk := newBlock(typ, stored, nil)
|
||||
|
||||
for _, k := range strs {
|
||||
if v := n.attrs[k]; v != "" {
|
||||
blk.Attrs[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range ints {
|
||||
v, ok := n.attrs[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
i, err := strconv.Atoi(v)
|
||||
if err != nil || i <= 0 {
|
||||
return document.Block{}, fmt.Errorf("line %d: <%s> %s has to be a positive whole number", n.line, n.name, k)
|
||||
}
|
||||
|
||||
blk.Attrs[k] = i
|
||||
}
|
||||
|
||||
return blk, nil
|
||||
}
|
||||
|
||||
// elements returns the elements among nodes. Text between them may only
|
||||
// be white space; where and hint name the container and what the text
|
||||
// has to sit in, for the message.
|
||||
func elements(nodes []node, where, hint string) ([]node, error) {
|
||||
var out []node
|
||||
|
||||
for _, n := range nodes {
|
||||
if n.name != "" {
|
||||
out = append(out, n)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.TrimSpace(n.text) != "" {
|
||||
return nil, fmt.Errorf("line %d: text in %s has to sit in %s", n.line, where, hint)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newBlock returns a block of type typ holding content. It keeps the uid
|
||||
// and block comment of stored, the block it replaces, and gets a new uid
|
||||
// when stored is the zero block. The markup shows neither the uid of a
|
||||
// hidden part nor any block comment, so this is what keeps them.
|
||||
func newBlock(typ document.BlockNodeType, stored document.Block, content []document.Block) document.Block {
|
||||
uid, _ := stored.UID()
|
||||
if uid == "" {
|
||||
uid = strutil.NanoID()
|
||||
}
|
||||
|
||||
attrs := document.Attributes{document.AttrUID: uid}
|
||||
|
||||
if comment, ok := stored.Attrs.Value(document.AttrCommentID); ok {
|
||||
attrs[document.AttrCommentID] = comment
|
||||
}
|
||||
|
||||
return document.Block{Type: typ, Attrs: attrs, Content: content}
|
||||
}
|
||||
|
||||
// titleBlock builds the part of type typ that holds one line of plain
|
||||
// text, such as a titled pre's title. It keeps the uid, block comment
|
||||
// and comment marks of the part of that type parent holds.
|
||||
func titleBlock(typ document.BlockNodeType, text string, parent document.Block) document.Block {
|
||||
stored := parent.FirstChild(typ)
|
||||
|
||||
return newBlock(typ, stored, textNodes([]run{{text: text}}, stored.Content))
|
||||
}
|
||||
|
||||
// keepIfUnchanged returns stored when built renders the same, and built
|
||||
// otherwise. The markup shows everything a writer can change, so the same
|
||||
// markup means the stored block is still right, hidden parts and all.
|
||||
func keepIfUnchanged(stored, built document.Block) document.Block {
|
||||
if stored.Type == built.Type && Render([]document.Block{stored}) == Render([]document.Block{built}) {
|
||||
return stored
|
||||
}
|
||||
|
||||
return built
|
||||
}
|
||||
|
||||
// findDuplicateUID returns a uid that blocks and their nested blocks
|
||||
// hold more than once. seen holds the uids met so far.
|
||||
func findDuplicateUID(blocks []document.Block, seen map[string]bool) (string, bool) {
|
||||
for _, blk := range blocks {
|
||||
if uid, ok := blk.UID(); ok {
|
||||
if seen[uid] {
|
||||
return uid, true
|
||||
}
|
||||
|
||||
seen[uid] = true
|
||||
}
|
||||
|
||||
if uid, found := findDuplicateUID(blk.Content, seen); found {
|
||||
return uid, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
398
server/core/internal/assistant/markup/build_test.go
Normal file
398
server/core/internal/assistant/markup/build_test.go
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"maps"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// _idAttr matches an id attribute in rendered markup.
|
||||
var _idAttr = regexp.MustCompile(` id="[^"]*"`)
|
||||
|
||||
// _keptOnly matches the elements that can only be kept by id.
|
||||
var _keptOnly = regexp.MustCompile(`(?m)^\s*<(file|unsupported) [^\n]*/>\n`)
|
||||
|
||||
func Test_Build(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := loadFixture(t, "all_blocks")
|
||||
|
||||
// edit renders the stored block id names, changed by replacing old
|
||||
// with replacement.
|
||||
edit := func(id, old, replacement string) string {
|
||||
stored, ok := root.FindByUID(id)
|
||||
require.True(t, ok)
|
||||
|
||||
return strings.Replace(Render([]document.Block{stored}), old, replacement, 1)
|
||||
}
|
||||
|
||||
stored := func(id string) document.Block {
|
||||
b, ok := root.FindByUID(id)
|
||||
require.True(t, ok)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
uids := func(blocks ...document.Block) []string {
|
||||
out := make([]string, 0, len(blocks))
|
||||
|
||||
for _, b := range blocks {
|
||||
uid, _ := b.UID()
|
||||
out = append(out, uid)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
type tcase struct {
|
||||
Markup string
|
||||
Stored document.RootBlock
|
||||
|
||||
// Result is the built blocks rendered without ids; empty skips
|
||||
// the check.
|
||||
Result string
|
||||
|
||||
Check func(t *testing.T, got []document.Block)
|
||||
Err error
|
||||
}
|
||||
|
||||
cc := map[string]tcase{
|
||||
"Comment survives on text the edit left": {
|
||||
Markup: edit("p1", "Plain ", "Simple "),
|
||||
Stored: root,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
|
||||
var commented []string
|
||||
|
||||
for _, n := range got[0].Content {
|
||||
for _, m := range n.Marks {
|
||||
if m.Type == "comment" {
|
||||
commented = append(commented, n.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"p1"}, uids(got[0]))
|
||||
assert.Equal(t, "Simple ", got[0].Content[0].Text)
|
||||
assert.Equal(t, []string{"a comment"}, commented)
|
||||
},
|
||||
},
|
||||
"Block comment survives an edit": {
|
||||
Markup: edit("p1", "Plain ", "Simple "),
|
||||
Stored: root,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "nc-p1", got[0].Attrs[document.AttrCommentID])
|
||||
},
|
||||
},
|
||||
"Titled code keeps its parts' uids and comments": {
|
||||
Markup: edit("tc1", "POST /login", "POST /signin"),
|
||||
Stored: root,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
assert.Equal(t, []string{"tct", "tcc"}, uids(got[0].Content...))
|
||||
assert.Equal(t, "POST /signin", got[0].Content[0].Content[0].Text)
|
||||
assert.Equal(t, "nc-tcc", got[0].Content[1].Attrs[document.AttrCommentID])
|
||||
},
|
||||
},
|
||||
"Entry keeps its paragraph uid": {
|
||||
Markup: edit("li1", "One", "Uno"),
|
||||
Stored: root,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
assert.Equal(t, []string{"li1p"}, uids(got[0].Content[0]))
|
||||
assert.Equal(t, "Uno", got[0].Content[0].Content[0].Text)
|
||||
},
|
||||
},
|
||||
"Split doc keeps its sides and unchanged children": {
|
||||
Markup: edit("sd1", "Issues a token.", "Issues a session."),
|
||||
Stored: root,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
assert.Equal(t, []string{"sl", "sr"}, uids(got[0].Content...))
|
||||
assert.Equal(t, stored("sr"), got[0].Content[1])
|
||||
},
|
||||
},
|
||||
"Parameter row keeps its parts' uids": {
|
||||
Markup: edit("pl1", `type="string"`, `type="email"`),
|
||||
Stored: root,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
|
||||
row := got[0].Content[1]
|
||||
assert.Equal(t, []string{"pi1", "pih", "pity"}, uids(row, row.Content[0], row.Content[0].Content[1]))
|
||||
assert.Equal(t, "email", row.Content[0].Content[1].Content[0].Text)
|
||||
},
|
||||
},
|
||||
"Unchanged param row stays as stored": {
|
||||
Markup: edit("pl1", `header="`, `header="New `),
|
||||
Stored: root,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
assert.Equal(t, stored("pi1"), got[0].Content[1])
|
||||
},
|
||||
},
|
||||
"Metric keeps its simulation flag with the same preset": {
|
||||
Markup: edit("mb1", `"width":"standard"`, `"width":"wide"`),
|
||||
Stored: root,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
assert.Equal(t, true, got[0].Attrs[document.AttrSimulationActive])
|
||||
assert.Equal(t, "wide", got[0].Attrs[document.AttrWidth])
|
||||
},
|
||||
},
|
||||
"Metric drops a simulation flag the markup sent": {
|
||||
Markup: `<metric>{"simulationActive":true,"uid":"x","nodeCommentId":"c"}</metric>`,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
assert.NotContains(t, got[0].Attrs, document.AttrSimulationActive)
|
||||
assert.NotEqual(t, "x", got[0].Attrs[document.AttrUID])
|
||||
assert.NotContains(t, got[0].Attrs, document.AttrCommentID)
|
||||
},
|
||||
},
|
||||
"Entry with a kept p keeps it nested": {
|
||||
Markup: `<li id="li1">One<p id="p1">Plain</p></li>`,
|
||||
Stored: root,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
assert.Equal(t, []string{"li1p", "p1"}, uids(got[0].Content...))
|
||||
},
|
||||
},
|
||||
"Line breaks in text read as spaces": {
|
||||
Markup: "<p>\n Wrapped\n text with <strong>strong</strong>\n words.\n</p>",
|
||||
Result: "<p>Wrapped text with <b>strong</b> words.</p>\n",
|
||||
},
|
||||
"Spaces on the text's own line stay": {
|
||||
Markup: "<p> a b </p>",
|
||||
Result: "<p> a b </p>\n",
|
||||
},
|
||||
"Same tag nested is one mark": {
|
||||
Markup: "<p><b>a<b>b</b></b></p>",
|
||||
Result: "<p><b>ab</b></p>\n",
|
||||
},
|
||||
"Entry text wrapped in a p is the entry's text": {
|
||||
Markup: "<ul><li>\n <p>x</p>\n</li></ul>",
|
||||
Result: "<ul>\n <li>x</li>\n</ul>\n",
|
||||
},
|
||||
"Line breaks around raw text are layout": {
|
||||
Markup: "<pre>\nx\n</pre>",
|
||||
Result: "<pre>x</pre>\n",
|
||||
},
|
||||
"Malformed markup": {Markup: "<p>x", Err: errors.New("line 1: <p> is never closed")},
|
||||
"Unknown id": {Markup: `<p id="nope">x</p>`, Stored: root, Err: errors.New(`line 1: id "nope" is not a block this write can keep; leave id out for a new block`)},
|
||||
"Id used twice": {Markup: `<p id="p2">x</p><p id="p2">y</p>`, Stored: root, Err: errors.New(`line 1: id "p2" is used twice; leave it out of the copy`)},
|
||||
"Text outside an element": {Markup: "hello", Err: errors.New("line 1: text in the markup has to sit in an element such as <p>")},
|
||||
"Text in a container": {Markup: "<callout>hi</callout>", Err: errors.New("line 1: text in <callout> has to sit in an element such as <p>")},
|
||||
"Tag in a heading": {Markup: "<h2>a <b>b</b></h2>", Err: errors.New("line 1: a heading takes plain text, so <b> is not allowed in it")},
|
||||
"Block inside text": {Markup: "<p>a <hr/></p>", Err: errors.New("line 1: <hr> is a block; it cannot sit inside text")},
|
||||
"Link to a script": {Markup: `<p><a href=" Java Script:alert(1)">x</a></p>`, Err: errors.New("line 1: a link cannot use javascript:; link to a web address instead")},
|
||||
// links a person stored may use any other scheme, or not even
|
||||
// parse, and a write that keeps them has to go through.
|
||||
"Links of other schemes stay": {
|
||||
Markup: `<p><a href="tel:+15551234">call</a> <a href="https://x.test/50%off">sale</a></p>`,
|
||||
Result: `<p><a href="tel:+15551234">call</a> <a href="https://x.test/50%off">sale</a></p>` + "\n",
|
||||
},
|
||||
"Entry text ending in a space keeps it before nested blocks": {
|
||||
Markup: "<ul><li>Item \n <ul><li>x</li></ul>\n</li></ul>",
|
||||
Result: "<ul>\n <li>Item \n <ul>\n <li>x</li>\n </ul>\n </li>\n</ul>\n",
|
||||
},
|
||||
"List of the wrong entries": {Markup: "<ul><task>x</task></ul>", Err: errors.New("line 1: <ul> holds only <li>, not <task>")},
|
||||
"Split doc without sides": {Markup: "<split_doc><right/></split_doc>", Err: errors.New("line 1: <split_doc> holds a <left> and then a <right>")},
|
||||
"Text in a split doc": {Markup: "<split_doc>x</split_doc>", Err: errors.New("line 1: text in <split_doc> has to sit in <left> or <right>")},
|
||||
"Params of the wrong rows": {Markup: `<params header="h"><p>x</p></params>`, Err: errors.New("line 1: <params> holds only <param>, not <p>")},
|
||||
"Text in params": {Markup: `<params header="h">x</params>`, Err: errors.New("line 1: text in <params> has to sit in a <param>")},
|
||||
"Param with an unknown id": {Markup: `<params header="h"><param id="nope" name="a"/></params>`, Stored: root, Err: errors.New(`line 1: id "nope" is not a block this write can keep; leave id out for a new block`)},
|
||||
"Width that is not a number": {Markup: `<img src="x" width="wide"/>`, Err: errors.New("line 1: <img> width has to be a positive whole number")},
|
||||
"Metric that is not JSON": {Markup: "<metric>{</metric>", Err: assert.AnError},
|
||||
"No block": {Markup: " \n ", Err: errors.New("the markup holds no block; to remove one, use delete_block")},
|
||||
"More blocks than one call takes": {
|
||||
Markup: strings.Repeat("<hr/>", _maxBlocks+1),
|
||||
Err: errors.New("501 blocks are over the limit of 500 per call; send them in several calls"),
|
||||
},
|
||||
"Metric with a bad value": {Markup: `<metric>{"width":"huge"}</metric>`, Err: assert.AnError},
|
||||
"New file": {Markup: `<file src="x"/>`, Err: errors.New("line 1: <file> can only keep a block get_document showed as <file>, by its id")},
|
||||
"Unsupported keeping a known block": {
|
||||
Markup: `<unsupported id="p1"/>`,
|
||||
Stored: root,
|
||||
Err: errors.New("line 1: <unsupported> can only keep a block get_document showed as <unsupported>, by its id"),
|
||||
},
|
||||
"Id kept as an element and as a part": {
|
||||
Markup: `<li id="li1">One</li><p id="li1p">x</p>`,
|
||||
Stored: root,
|
||||
Err: errors.New(`id "li1p" is kept twice: by its own element and as part of another one; leave it out of one of them`),
|
||||
},
|
||||
"Side outside a split doc": {Markup: "<left></left>", Err: errors.New("line 1: <left> belongs inside <split_doc>")},
|
||||
"Param outside params": {Markup: `<param name="a"/>`, Err: errors.New("line 1: <param> belongs inside <params>")},
|
||||
"Inline tag at the top": {Markup: "<b>x</b>", Err: errors.New("line 1: <b> is inline; put it inside a <p>")},
|
||||
"Entry with text after a list": {
|
||||
Markup: "<ul><li>a<ul><li>b</li></ul>c</li></ul>",
|
||||
Err: errors.New("line 1: text in <li> after its text has to sit in an element such as <p>"),
|
||||
},
|
||||
}
|
||||
|
||||
// markup read and written back unchanged is the stored document
|
||||
// itself, hidden marks and parts included. Without ids it builds
|
||||
// new blocks that read back the same.
|
||||
for _, name := range _fixtures {
|
||||
fixture := loadFixture(t, name)
|
||||
rendered := Render(fixture.Content)
|
||||
withoutIDs := _keptOnly.ReplaceAllString(_idAttr.ReplaceAllString(rendered, ""), "")
|
||||
|
||||
maps.Copy(cc, map[string]tcase{
|
||||
name + " kept as it is": {
|
||||
Markup: rendered,
|
||||
Stored: fixture,
|
||||
Check: func(t *testing.T, got []document.Block) {
|
||||
t.Helper()
|
||||
assert.Equal(t, fixture.Content, got)
|
||||
},
|
||||
},
|
||||
name + " written as new": {
|
||||
Markup: withoutIDs,
|
||||
Result: withoutIDs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := Build(c.Markup, c.Stored)
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.Result != "" {
|
||||
assert.Equal(t, c.Result, _idAttr.ReplaceAllString(Render(got), ""))
|
||||
}
|
||||
|
||||
if c.Check != nil {
|
||||
c.Check(t, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_elements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Markup string
|
||||
Result string
|
||||
Err error
|
||||
}{
|
||||
"Elements in order, blank text dropped": {
|
||||
Markup: "<p>a</p>\n <hr/>\n",
|
||||
Result: `p@1["a"] hr@2`,
|
||||
},
|
||||
"Nothing at all": {},
|
||||
"Text that is not blank": {
|
||||
Markup: "<p>a</p> b",
|
||||
Err: errors.New("line 1: text in <x> has to sit in a <y>"),
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodes, err := parse(c.Markup)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := elements(nodes, "<x>", "a <y>")
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Result, dump(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_newBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stored := document.Block{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: document.Attributes{document.AttrUID: "p1", document.AttrCommentID: "c1", "align": "left"},
|
||||
}
|
||||
|
||||
got := newBlock(document.BlockNodeHeading, stored, nil)
|
||||
assert.Equal(t, document.Block{
|
||||
Type: document.BlockNodeHeading,
|
||||
Attrs: document.Attributes{document.AttrUID: "p1", document.AttrCommentID: "c1"},
|
||||
}, got)
|
||||
|
||||
got = newBlock(document.BlockNodeParagraph, document.Block{}, nil)
|
||||
uid, _ := got.UID()
|
||||
assert.NotEmpty(t, uid)
|
||||
assert.Len(t, got.Attrs, 1)
|
||||
}
|
||||
|
||||
func Test_titleBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
comment := document.Mark{Type: "comment", Attrs: document.Attributes{"commentId": "c1"}}
|
||||
parent := document.Block{Content: []document.Block{{
|
||||
Type: document.BlockNodeCodeBlockTitle,
|
||||
Attrs: document.Attributes{document.AttrUID: "t1"},
|
||||
Content: []document.Block{{Type: document.BlockNodeText, Text: "GET /a", Marks: []document.Mark{comment}}},
|
||||
}}}
|
||||
|
||||
// the part keeps its uid and its comment on the text left as it was.
|
||||
got := titleBlock(document.BlockNodeCodeBlockTitle, "GET /a/b", parent)
|
||||
assert.Equal(t, document.Block{
|
||||
Type: document.BlockNodeCodeBlockTitle,
|
||||
Attrs: document.Attributes{document.AttrUID: "t1"},
|
||||
Content: []document.Block{
|
||||
{Type: document.BlockNodeText, Text: "GET /a", Marks: []document.Mark{comment}},
|
||||
{Type: document.BlockNodeText, Text: "/b"},
|
||||
},
|
||||
}, got)
|
||||
|
||||
assert.Nil(t, titleBlock(document.BlockNodeCodeBlockTitle, "", document.Block{}).Content)
|
||||
}
|
||||
|
||||
func Test_keepIfUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stored := document.Block{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: document.Attributes{document.AttrUID: "p", "textAlign": "center"},
|
||||
Content: []document.Block{{Type: document.BlockNodeText, Text: "a"}},
|
||||
}
|
||||
same := document.Block{Type: document.BlockNodeParagraph, Attrs: document.Attributes{document.AttrUID: "p"}, Content: stored.Content}
|
||||
changed := document.Block{Type: document.BlockNodeParagraph, Attrs: document.Attributes{document.AttrUID: "p"}}
|
||||
|
||||
assert.Equal(t, stored, keepIfUnchanged(stored, same))
|
||||
assert.Equal(t, changed, keepIfUnchanged(stored, changed))
|
||||
assert.Equal(t, same, keepIfUnchanged(document.Block{}, same))
|
||||
}
|
||||
|
||||
func Test_findDuplicateUID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
block := func(uid string, content ...document.Block) document.Block {
|
||||
return document.Block{Attrs: document.Attributes{document.AttrUID: uid}, Content: content}
|
||||
}
|
||||
|
||||
_, found := findDuplicateUID([]document.Block{block("a", block("b")), block("c")}, map[string]bool{})
|
||||
assert.False(t, found)
|
||||
|
||||
uid, found := findDuplicateUID([]document.Block{block("a", block("b")), block("c", block("b"))}, map[string]bool{})
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, "b", uid)
|
||||
}
|
||||
100
server/core/internal/assistant/markup/describe.go
Normal file
100
server/core/internal/assistant/markup/describe.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// _nodeNames names each ProseMirror node type the way a message to the
|
||||
// model refers to it: by the element it reads and writes.
|
||||
var _nodeNames = map[document.BlockNodeType]string{
|
||||
document.BlockNodeDoc: "the document root",
|
||||
document.BlockNodeParagraph: "<p>",
|
||||
document.BlockNodeHeading: "a heading",
|
||||
document.BlockNodeBlockquote: "<blockquote>",
|
||||
document.BlockNodeBulletList: "<ul>",
|
||||
document.BlockNodeOrderedList: "<ol>",
|
||||
document.BlockNodeListItem: "<li>",
|
||||
document.BlockNodeTaskList: "<tasks>",
|
||||
document.BlockNodeTaskItem: "<task>",
|
||||
document.BlockNodeCalloutBlock: "<callout>",
|
||||
document.BlockNodeCodeBlock: "<pre>",
|
||||
document.BlockNodeTitledCodeBlock: "a titled <pre>",
|
||||
document.BlockNodeCodeBlockTitle: "a titled <pre>'s title",
|
||||
document.BlockNodeMermaidBlock: "<mermaid>",
|
||||
document.BlockNodeHorizontalRule: "<hr>",
|
||||
document.BlockNodeImageBlock: "<img>",
|
||||
document.BlockNodeFileBlock: "<file>",
|
||||
document.BlockNodeFigmaBlock: "<figma>",
|
||||
document.BlockNodeMetricGrid: "<metrics>",
|
||||
document.BlockNodeMetricBlock: "<metric>",
|
||||
document.BlockNodeSplitDoc: "<split_doc>",
|
||||
document.BlockNodeSplitDocLeft: "<left>",
|
||||
document.BlockNodeSplitDocRight: "<right>",
|
||||
document.BlockNodeParamList: "<params>",
|
||||
document.BlockNodeParamListHeader: "a <params> header",
|
||||
document.BlockNodeParamListItem: "<param>",
|
||||
document.BlockNodeParamListItemHeader: "<param>",
|
||||
document.BlockNodeParamListItemTitle: "a <param> name",
|
||||
document.BlockNodeParamListItemType: "a <param> type",
|
||||
}
|
||||
|
||||
// _hiddenTypes are the node types with no element of their own; they
|
||||
// read as part of the element holding them.
|
||||
var _hiddenTypes = map[document.BlockNodeType]bool{
|
||||
document.BlockNodeCodeBlockTitle: true,
|
||||
document.BlockNodeSplitDocLeft: true,
|
||||
document.BlockNodeSplitDocRight: true,
|
||||
document.BlockNodeParamListHeader: true,
|
||||
document.BlockNodeParamListItemHeader: true,
|
||||
document.BlockNodeParamListItemTitle: true,
|
||||
document.BlockNodeParamListItemType: true,
|
||||
}
|
||||
|
||||
// DescribeNode returns how a message to the model names a ProseMirror
|
||||
// node type. An unknown node type is returned as it is.
|
||||
func DescribeNode(pm document.BlockNodeType) string {
|
||||
return cmp.Or(_nodeNames[pm], string(pm))
|
||||
}
|
||||
|
||||
// Readable finds the block uid names among blocks, or the nearest block
|
||||
// holding it when uid names a part with no element of its own, such as
|
||||
// a titled pre's title row, or a param, which only its params holds.
|
||||
func Readable(blocks []document.Block, uid string) (document.Block, bool) {
|
||||
for _, b := range blocks {
|
||||
if id, ok := b.UID(); ok && id == uid {
|
||||
return b, true
|
||||
}
|
||||
|
||||
inner, ok := Readable(b.Content, uid)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if standsAlone(b, inner) {
|
||||
return inner, true
|
||||
}
|
||||
|
||||
// b may itself be a part, so the level above checks it again.
|
||||
return b, true
|
||||
}
|
||||
|
||||
return document.Block{}, false
|
||||
}
|
||||
|
||||
// standsAlone reports whether child, inside parent, is an element that
|
||||
// can be read and replaced on its own. A param cannot: it is only ever
|
||||
// written inside its params.
|
||||
func standsAlone(parent, child document.Block) bool {
|
||||
if _hiddenTypes[child.Type] {
|
||||
return false
|
||||
}
|
||||
|
||||
switch parent.Type {
|
||||
case document.BlockNodeTitledCodeBlock, document.BlockNodeParamList, document.BlockNodeParamListItem:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
53
server/core/internal/assistant/markup/describe_test.go
Normal file
53
server/core/internal/assistant/markup/describe_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_DescribeNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "<p>", DescribeNode(document.BlockNodeParagraph))
|
||||
assert.Equal(t, "<left>", DescribeNode(document.BlockNodeSplitDocLeft))
|
||||
assert.Equal(t, "the document root", DescribeNode(document.BlockNodeDoc))
|
||||
assert.Equal(t, "mystery", DescribeNode("mystery"))
|
||||
}
|
||||
|
||||
func Test_Readable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := loadFixture(t, "all_blocks")
|
||||
|
||||
cc := map[string]struct {
|
||||
UID string
|
||||
Expected string
|
||||
Found bool
|
||||
}{
|
||||
"Block named by its id": {UID: "p1", Expected: "p1", Found: true},
|
||||
"Nested block named by its id": {UID: "li3", Expected: "li3", Found: true},
|
||||
"Entry paragraph reads as itself": {UID: "li1p", Expected: "li1p", Found: true},
|
||||
"Titled pre's title reads the pre": {UID: "tct", Expected: "tc1", Found: true},
|
||||
"Titled pre's code reads the pre": {UID: "tcc", Expected: "tc1", Found: true},
|
||||
"Split doc side reads the split doc": {UID: "sr", Expected: "sd1", Found: true},
|
||||
"Param reads the params": {UID: "pi1", Expected: "pl1", Found: true},
|
||||
"Param cell reads the params": {UID: "pity", Expected: "pl1", Found: true},
|
||||
"Param description reads the params": {UID: "pip", Expected: "pl1", Found: true},
|
||||
"Params header reads the params": {UID: "plh", Expected: "pl1", Found: true},
|
||||
"Id the document does not hold": {UID: "nope"},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, ok := Readable(root.Content, c.UID)
|
||||
assert.Equal(t, c.Found, ok)
|
||||
|
||||
uid, _ := got.UID()
|
||||
assert.Equal(t, c.Expected, uid)
|
||||
})
|
||||
}
|
||||
}
|
||||
82
server/core/internal/assistant/markup/inline.go
Normal file
82
server/core/internal/assistant/markup/inline.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// tagMark is one visible mark as a tag.
|
||||
type tagMark struct {
|
||||
// tag is the element name.
|
||||
tag string
|
||||
|
||||
// open is the tag that opens the mark, a link's href included.
|
||||
open string
|
||||
}
|
||||
|
||||
// renderInline renders inline content as text with tags. Neighbouring
|
||||
// text nodes share the tags they have in common, which nest in the
|
||||
// _tagNesting order.
|
||||
func renderInline(content []document.Block) string {
|
||||
var (
|
||||
sb strings.Builder
|
||||
openMarks []tagMark
|
||||
)
|
||||
|
||||
for _, n := range content {
|
||||
if n.Type != document.BlockNodeText {
|
||||
continue
|
||||
}
|
||||
|
||||
want := visibleMarks(n.Marks)
|
||||
|
||||
kept := 0
|
||||
for kept < len(openMarks) && kept < len(want) && openMarks[kept] == want[kept] {
|
||||
kept++
|
||||
}
|
||||
|
||||
for i := len(openMarks) - 1; i >= kept; i-- {
|
||||
sb.WriteString(endTag(openMarks[i].tag))
|
||||
}
|
||||
|
||||
openMarks = openMarks[:kept]
|
||||
|
||||
for _, m := range want[kept:] {
|
||||
sb.WriteString(m.open)
|
||||
openMarks = append(openMarks, m)
|
||||
}
|
||||
|
||||
sb.WriteString(_textEscaper.Replace(n.Text))
|
||||
}
|
||||
|
||||
for _, o := range slices.Backward(openMarks) {
|
||||
sb.WriteString(endTag(o.tag))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// visibleMarks returns the marks the model sees, in nesting order.
|
||||
func visibleMarks(marks []document.Mark) []tagMark {
|
||||
var out []tagMark
|
||||
|
||||
for _, tag := range _tagNesting {
|
||||
for _, m := range marks {
|
||||
if m.Type != _tagMarks[tag] {
|
||||
continue
|
||||
}
|
||||
|
||||
tm := tagMark{tag: tag, open: "<" + tag + ">"}
|
||||
|
||||
if tag == _tagLink {
|
||||
tm.open = `<a href="` + _attrEscaper.Replace(m.Attrs.Get(_attrHref).String()) + `">`
|
||||
}
|
||||
|
||||
out = append(out, tm)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
77
server/core/internal/assistant/markup/inline_test.go
Normal file
77
server/core/internal/assistant/markup/inline_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_renderInline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
text := func(s string, marks ...string) document.Block {
|
||||
b := document.Block{Type: document.BlockNodeText, Text: s}
|
||||
|
||||
for _, m := range marks {
|
||||
b.Marks = append(b.Marks, document.Mark{Type: m})
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
Content []document.Block
|
||||
Expected string
|
||||
}{
|
||||
"Nothing at all": {},
|
||||
"Plain text is escaped": {
|
||||
Content: []document.Block{text("a < b & c")},
|
||||
Expected: "a < b & c",
|
||||
},
|
||||
"Neighbours share the tags they have in common": {
|
||||
Content: []document.Block{text("a", "bold"), text("b", "bold", "italic"), text("c")},
|
||||
Expected: "<b>a<i>b</i></b>c",
|
||||
},
|
||||
"Tags nest in a fixed order whatever the mark order": {
|
||||
Content: []document.Block{text("a", "italic", "bold")},
|
||||
Expected: "<b><i>a</i></b>",
|
||||
},
|
||||
"Hidden marks render nothing": {
|
||||
Content: []document.Block{text("a", "comment")},
|
||||
Expected: "a",
|
||||
},
|
||||
"Tags still open at the end are closed": {
|
||||
Content: []document.Block{text("a", "bold", "code")},
|
||||
Expected: "<b><code>a</code></b>",
|
||||
},
|
||||
"Nodes that are not text are skipped": {
|
||||
Content: []document.Block{{Type: document.BlockNodeParagraph}, text("a")},
|
||||
Expected: "a",
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Expected, renderInline(c.Content))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_visibleMarks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := visibleMarks([]document.Mark{
|
||||
{Type: "comment"},
|
||||
{Type: "italic"},
|
||||
{Type: "link", Attrs: document.Attributes{_attrHref: `https://x.test/?a=1&b="2"`}},
|
||||
})
|
||||
|
||||
assert.Equal(t, []tagMark{
|
||||
{tag: _tagLink, open: `<a href="https://x.test/?a=1&b="2"">`},
|
||||
{tag: _tagItalic, open: "<i>"},
|
||||
}, got)
|
||||
assert.Nil(t, visibleMarks(nil))
|
||||
}
|
||||
113
server/core/internal/assistant/markup/markup.go
Normal file
113
server/core/internal/assistant/markup/markup.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Package markup renders documents as the XML the assistant reads and
|
||||
// writes, and builds ProseMirror blocks back from it.
|
||||
//
|
||||
// Each block is one element carrying its uid as id. Inline formatting is
|
||||
// tags (b, i, u, s, code, a href); pre, mermaid and metric hold raw text.
|
||||
// Parts the editor needs but nobody addresses, such as a list entry's
|
||||
// paragraph or a split_doc's sides, have no id of their own and keep
|
||||
// their stored uids through the element they belong to.
|
||||
package markup
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// Element names, as the model reads and writes them.
|
||||
const (
|
||||
_elemParagraph = "p"
|
||||
_elemBlockquote = "blockquote"
|
||||
_elemBullets = "ul"
|
||||
_elemOrdered = "ol"
|
||||
_elemEntry = "li"
|
||||
_elemTasks = "tasks"
|
||||
_elemTask = "task"
|
||||
_elemCallout = "callout"
|
||||
_elemCode = "pre"
|
||||
_elemMermaid = "mermaid"
|
||||
_elemRule = "hr"
|
||||
_elemImage = "img"
|
||||
_elemFigma = "figma"
|
||||
_elemFile = "file"
|
||||
_elemMetrics = "metrics"
|
||||
_elemMetric = "metric"
|
||||
_elemSplitDoc = "split_doc"
|
||||
_elemLeft = "left"
|
||||
_elemRight = "right"
|
||||
_elemParams = "params"
|
||||
_elemParam = "param"
|
||||
|
||||
// _elemUnsupported stands in for a node type this package does not
|
||||
// know, so a document holding one still reads. It can be kept but not
|
||||
// written.
|
||||
_elemUnsupported = "unsupported"
|
||||
)
|
||||
|
||||
// Attribute names that are not ProseMirror attribute names.
|
||||
const (
|
||||
// _attrID carries a block's uid.
|
||||
_attrID = "id"
|
||||
|
||||
// _attrHeader is a parameter list's header text.
|
||||
_attrHeader = "header"
|
||||
|
||||
// _attrType is a parameter's type, or an unsupported node's type.
|
||||
_attrType = "type"
|
||||
|
||||
// _attrHref is a link's target.
|
||||
_attrHref = "href"
|
||||
)
|
||||
|
||||
// Inline tags, by the ProseMirror mark they write.
|
||||
const (
|
||||
_tagBold = "b"
|
||||
_tagItalic = "i"
|
||||
_tagUnderline = "u"
|
||||
_tagStrike = "s"
|
||||
_tagCode = "code"
|
||||
_tagLink = "a"
|
||||
)
|
||||
|
||||
// _tagMarks maps each inline tag to the ProseMirror mark it writes.
|
||||
// Marks not listed, such as comments, stay hidden and are kept by the
|
||||
// build step.
|
||||
var _tagMarks = map[string]string{
|
||||
_tagLink: "link",
|
||||
_tagBold: "bold",
|
||||
_tagItalic: "italic",
|
||||
_tagUnderline: "underline",
|
||||
_tagStrike: "strike",
|
||||
_tagCode: "code",
|
||||
}
|
||||
|
||||
// _tagNesting is the order inline tags nest in when several open
|
||||
// together.
|
||||
var _tagNesting = []string{_tagLink, _tagBold, _tagItalic, _tagUnderline, _tagStrike, _tagCode}
|
||||
|
||||
// _rawElements hold raw text, up to their closing tag. Each maps to a
|
||||
// match of that closing tag escaped once or more in the text, capturing
|
||||
// the extra amp; levels and the name as written. A closing tag reads in
|
||||
// any case, so the name matches in any case too.
|
||||
var _rawElements = map[string]*regexp.Regexp{
|
||||
_elemCode: regexp.MustCompile(`&((?:amp;)*)lt;/((?i:` + _elemCode + `))`),
|
||||
_elemMermaid: regexp.MustCompile(`&((?:amp;)*)lt;/((?i:` + _elemMermaid + `))`),
|
||||
_elemMetric: regexp.MustCompile(`&((?:amp;)*)lt;/((?i:` + _elemMetric + `))`),
|
||||
}
|
||||
|
||||
// _rawCloseTags match the start of a raw element's closing tag in any
|
||||
// case, which is what its text has to escape.
|
||||
var _rawCloseTags = map[string]*regexp.Regexp{
|
||||
_elemCode: regexp.MustCompile(`</((?i:` + _elemCode + `))`),
|
||||
_elemMermaid: regexp.MustCompile(`</((?i:` + _elemMermaid + `))`),
|
||||
_elemMetric: regexp.MustCompile(`</((?i:` + _elemMetric + `))`),
|
||||
}
|
||||
|
||||
// _hiddenMetricAttrs are metric attributes the model never sees: the
|
||||
// uid, which is the id, the block comment, and the simulation flag,
|
||||
// which core derives.
|
||||
var _hiddenMetricAttrs = map[string]bool{
|
||||
document.AttrUID: true,
|
||||
document.AttrCommentID: true,
|
||||
document.AttrSimulationActive: true,
|
||||
}
|
||||
215
server/core/internal/assistant/markup/metric.go
Normal file
215
server/core/internal/assistant/markup/metric.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
jsonv2 "encoding/json/v2"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// Metric enum values. They mirror the editor's enums in
|
||||
// web/app/components/editor/blocks/metrics/utils.ts (TimeRangePreset,
|
||||
// RefreshInterval, the Visualization*Unit enums, MetricBlockWidth,
|
||||
// MetricSimulationPreset) and web/app/utils/api/data-source/generic-query.ts
|
||||
// (GenericQueryChartType); a value added there has to be added here or the
|
||||
// assistant cannot author it.
|
||||
var (
|
||||
// _metricVisualizationTypes are the chart kinds; they equal the
|
||||
// datasource processor's ChartType values, which only the tests
|
||||
// import, so this package stays free of the processors.
|
||||
_metricVisualizationTypes = []string{"line_chart", "bar_chart", "gauge_chart"}
|
||||
|
||||
// _metricTimeRanges are the time-window presets.
|
||||
_metricTimeRanges = []string{
|
||||
"last_5_minutes", "last_15_minutes", "last_30_minutes",
|
||||
"last_1_hour", "last_3_hours", "last_6_hours", "last_12_hours", "last_24_hours",
|
||||
"last_2_days", "last_7_days", "last_30_days", "last_90_days",
|
||||
"last_6_months", "last_1_year", "last_2_years", "last_5_years",
|
||||
"today", "yesterday", "today_so_far",
|
||||
"this_week", "this_week_so_far", "this_month", "this_month_so_far",
|
||||
"this_year", "this_year_so_far",
|
||||
"previous_week", "previous_month", "previous_year",
|
||||
}
|
||||
|
||||
// _metricRefreshIntervals are the re-query periods.
|
||||
_metricRefreshIntervals = []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"}
|
||||
|
||||
// _metricUnitTypes are the value units: custom, time, data and percent.
|
||||
_metricUnitTypes = []string{
|
||||
"custom",
|
||||
"nanoseconds", "microseconds", "milliseconds", "seconds", "minutes", "hours", "days",
|
||||
"bytes", "kilobytes", "megabytes", "gigabytes", "terabytes",
|
||||
"bits", "kilobits", "megabits", "gigabits", "terabits",
|
||||
"percent0to100", "percent0to1",
|
||||
}
|
||||
|
||||
// _metricWidths are the block widths inside a metric_grid.
|
||||
_metricWidths = []string{"compact", "standard", "wide"}
|
||||
|
||||
// _metricSimulationPresets are the generated series a block draws
|
||||
// while the metric it documents has no real data to answer with.
|
||||
_metricSimulationPresets = []string{
|
||||
"cpu_usage", "memory_usage", "disk_usage",
|
||||
"http_requests", "http_latency", "error_rate",
|
||||
}
|
||||
|
||||
// _metricEnums maps each enum-valued attribute to its values, which
|
||||
// is the table decodeMetricAttrs checks against and MetricEnums
|
||||
// publishes.
|
||||
_metricEnums = map[string][]string{
|
||||
document.AttrVisualizationType: _metricVisualizationTypes,
|
||||
document.AttrTimeRange: _metricTimeRanges,
|
||||
document.AttrRefreshInterval: _metricRefreshIntervals,
|
||||
document.AttrUnitType: _metricUnitTypes,
|
||||
document.AttrWidth: _metricWidths,
|
||||
document.AttrSimulationPreset: _metricSimulationPresets,
|
||||
}
|
||||
)
|
||||
|
||||
// MetricEnums returns every enum-valued metric attribute with its
|
||||
// allowed values, keyed by attribute name.
|
||||
func MetricEnums() map[string][]string {
|
||||
out := make(map[string][]string, len(_metricEnums))
|
||||
|
||||
for k, v := range _metricEnums {
|
||||
out[k] = slices.Clone(v)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// MetricReference describes the attributes a <metric> holds, its enum
|
||||
// values taken from the validator so the two cannot drift apart.
|
||||
func MetricReference() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("dataSourceId (an id from list_data_sources), queries ([{name, query, legendFormat}], PromQL or SQL as the data source takes)")
|
||||
|
||||
for _, key := range slices.Sorted(maps.Keys(_metricEnums)) {
|
||||
sb.WriteString(", " + key + " (" + strings.Join(_metricEnums[key], "|") + ")")
|
||||
}
|
||||
|
||||
sb.WriteString(", and optionally title, unitCustom (the label when unitType is custom), decimals, thresholds ([{value, label, color}]), baseThresholdColor, axisBoundsMin, axisBoundsMax")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// metricAttrs holds the metric attributes that are not enums. Decoding
|
||||
// a metric's JSON into it checks the type of each one.
|
||||
type metricAttrs struct {
|
||||
// Title is the metric's heading.
|
||||
Title string `json:"title"`
|
||||
|
||||
// DataSourceID names the data source the queries run against.
|
||||
DataSourceID string `json:"dataSourceId"`
|
||||
|
||||
// UnitCustom is the unit label when unitType is custom.
|
||||
UnitCustom string `json:"unitCustom"`
|
||||
|
||||
// BaseThresholdColor is the colour below the first threshold.
|
||||
BaseThresholdColor string `json:"baseThresholdColor"`
|
||||
|
||||
// Decimals is how many decimals values show with.
|
||||
Decimals float64 `json:"decimals"`
|
||||
|
||||
// AxisBoundsMin is the lower bound of the value axis.
|
||||
AxisBoundsMin float64 `json:"axisBoundsMin"`
|
||||
|
||||
// AxisBoundsMax is the upper bound of the value axis.
|
||||
AxisBoundsMax float64 `json:"axisBoundsMax"`
|
||||
|
||||
// Queries are the queries the metric draws.
|
||||
Queries []metricQuery `json:"queries"`
|
||||
|
||||
// Thresholds are the values the chart marks.
|
||||
Thresholds []metricThreshold `json:"thresholds"`
|
||||
}
|
||||
|
||||
// metricQuery is one row of a metric's queries.
|
||||
type metricQuery struct {
|
||||
// Name is the query's display name. Required.
|
||||
Name *string `json:"name"`
|
||||
|
||||
// Query is the query text. Required.
|
||||
Query *string `json:"query"`
|
||||
|
||||
// LegendFormat is the legend template.
|
||||
LegendFormat string `json:"legendFormat"`
|
||||
}
|
||||
|
||||
// metricThreshold is one row of a metric's thresholds.
|
||||
type metricThreshold struct {
|
||||
// Value is where the threshold sits.
|
||||
Value float64 `json:"value"`
|
||||
|
||||
// Label names the threshold.
|
||||
Label string `json:"label"`
|
||||
|
||||
// Color is the colour above it.
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// decodeMetricAttrs reads a metric's JSON attributes and checks them:
|
||||
// every enum holds one of its values, every other attribute has its
|
||||
// type, and every query has a name and a query.
|
||||
func decodeMetricAttrs(text string) (document.Attributes, error) {
|
||||
attrs := document.Attributes{}
|
||||
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
if err := jsonv2.Unmarshal([]byte(text), &attrs); err != nil {
|
||||
return nil, fmt.Errorf("<metric> holds a JSON object of its attributes: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range slices.Sorted(maps.Keys(_metricEnums)) {
|
||||
v, ok := attrs.Value(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if s, isString := v.(string); !isString || !slices.Contains(_metricEnums[key], s) {
|
||||
return nil, fmt.Errorf("metric %s must be one of: %s", key, strings.Join(_metricEnums[key], ", "))
|
||||
}
|
||||
}
|
||||
|
||||
var typed metricAttrs
|
||||
|
||||
if err := jsonv2.Unmarshal([]byte(text), &typed); err != nil {
|
||||
return nil, fmt.Errorf("metric attributes: %w", err)
|
||||
}
|
||||
|
||||
for i, q := range typed.Queries {
|
||||
if q.Name == nil || q.Query == nil {
|
||||
return nil, fmt.Errorf("metric query %d: name and query are required", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
// setSimulation sets the simulation flag of a metric built from markup.
|
||||
// A metric that keeps its stored preset keeps its stored flag, so one
|
||||
// re-sent after its data arrived does not simulate again; otherwise the
|
||||
// flag is on exactly when a preset is named. stored is the metric the
|
||||
// element's id names, or the zero block.
|
||||
func setSimulation(attrs document.Attributes, stored document.Block) {
|
||||
preset, named := attrs.Value(document.AttrSimulationPreset)
|
||||
|
||||
if stored.Type == document.BlockNodeMetricBlock && named &&
|
||||
preset == stored.Attrs[document.AttrSimulationPreset] {
|
||||
if active, set := stored.Attrs.Value(document.AttrSimulationActive); set {
|
||||
attrs[document.AttrSimulationActive] = active
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if named {
|
||||
attrs[document.AttrSimulationActive] = true
|
||||
}
|
||||
}
|
||||
223
server/core/internal/assistant/markup/metric_test.go
Normal file
223
server/core/internal/assistant/markup/metric_test.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_MetricEnums(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := MetricEnums()
|
||||
|
||||
require.Len(t, got, 6)
|
||||
assert.Equal(t, []string{"line_chart", "bar_chart", "gauge_chart"}, got[document.AttrVisualizationType])
|
||||
assert.Equal(t, []string{"compact", "standard", "wide"}, got[document.AttrWidth])
|
||||
assert.Len(t, got[document.AttrSimulationPreset], 6)
|
||||
assert.Len(t, got[document.AttrTimeRange], 28)
|
||||
assert.Len(t, got[document.AttrRefreshInterval], 10)
|
||||
assert.Len(t, got[document.AttrUnitType], 20)
|
||||
|
||||
// the copy protects the package's own table from a caller that
|
||||
// sorts or appends to what it was handed.
|
||||
got[document.AttrVisualizationType][0] = "wibble"
|
||||
assert.Equal(t, "line_chart", MetricEnums()[document.AttrVisualizationType][0])
|
||||
}
|
||||
|
||||
func Test_MetricReference(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := MetricReference()
|
||||
|
||||
// every enum the decoder checks is published with its values.
|
||||
for key, values := range _metricEnums {
|
||||
assert.Contains(t, got, key+" (")
|
||||
|
||||
for _, v := range values {
|
||||
assert.Contains(t, got, v)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Contains(t, got, "unitCustom")
|
||||
assert.Contains(t, got, "dataSourceId")
|
||||
|
||||
// the chart types are the processor's, which this package does not
|
||||
// import, so only this check keeps the two lists equal.
|
||||
assert.Equal(t, []string{
|
||||
string(processor.ChartTypeLine),
|
||||
string(processor.ChartTypeBar),
|
||||
string(processor.ChartTypeGauge),
|
||||
}, _metricVisualizationTypes)
|
||||
}
|
||||
|
||||
func Test_decodeMetricAttrs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Text string
|
||||
Result document.Attributes
|
||||
Err error
|
||||
}{
|
||||
"No attrs at all": {
|
||||
Text: " ",
|
||||
Result: document.Attributes{},
|
||||
},
|
||||
"A fully configured metric": {
|
||||
Text: `{"title":"Request rate","dataSourceId":"d1qbc8kv2vg000cnb6ag","visualizationType":"line_chart",` +
|
||||
`"queries":[{"name":"Query 1","query":"up","legendFormat":"{{job}}"}],"timeRange":"last_1_hour","refreshInterval":"5m",` +
|
||||
`"thresholds":[{"value":90,"label":"warn","color":"#f00"}],"baseThresholdColor":"#0f0","decimals":2,` +
|
||||
`"unitType":"percent0to100","unitCustom":"rps","axisBoundsMin":0,"axisBoundsMax":100.5,"width":"wide","simulationPreset":"cpu_usage"}`,
|
||||
Result: document.Attributes{
|
||||
document.AttrTitle: "Request rate",
|
||||
document.AttrDataSourceID: "d1qbc8kv2vg000cnb6ag",
|
||||
document.AttrVisualizationType: "line_chart",
|
||||
document.AttrQueries: []any{map[string]any{"name": "Query 1", "query": "up", "legendFormat": "{{job}}"}},
|
||||
document.AttrTimeRange: "last_1_hour",
|
||||
document.AttrRefreshInterval: "5m",
|
||||
"thresholds": []any{map[string]any{"value": 90.0, "label": "warn", "color": "#f00"}},
|
||||
"baseThresholdColor": "#0f0",
|
||||
"decimals": 2.0,
|
||||
document.AttrUnitType: "percent0to100",
|
||||
"unitCustom": "rps",
|
||||
"axisBoundsMin": 0.0,
|
||||
"axisBoundsMax": 100.5,
|
||||
document.AttrWidth: "wide",
|
||||
document.AttrSimulationPreset: "cpu_usage",
|
||||
},
|
||||
},
|
||||
"Nulls are as absent as missing keys": {
|
||||
Text: `{"visualizationType":null,"queries":null,"thresholds":null,"decimals":null}`,
|
||||
Result: document.Attributes{document.AttrVisualizationType: nil, document.AttrQueries: nil, "thresholds": nil, "decimals": nil},
|
||||
},
|
||||
"An unknown attr passes through": {
|
||||
Text: `{"wibble":1}`,
|
||||
Result: document.Attributes{"wibble": 1.0},
|
||||
},
|
||||
"A threshold row with nothing set": {
|
||||
Text: `{"thresholds":[{}]}`,
|
||||
Result: document.Attributes{"thresholds": []any{map[string]any{}}},
|
||||
},
|
||||
"Not JSON": {
|
||||
Text: "{",
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Non-string width": {
|
||||
Text: `{"width":1}`,
|
||||
Err: errors.New("metric width must be one of: compact, standard, wide"),
|
||||
},
|
||||
"Unknown visualization type": {
|
||||
Text: `{"visualizationType":"pie_chart"}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Non-string title": {
|
||||
Text: `{"title":1.5}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Non-numeric decimals": {
|
||||
Text: `{"decimals":true}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Queries that are not an array": {
|
||||
Text: `{"queries":"up"}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"A query row with a non-string legend format": {
|
||||
Text: `{"queries":[{"name":"a","query":"up","legendFormat":1}]}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"A query row missing its query": {
|
||||
Text: `{"queries":[{"name":"a","query":"up"},{"name":"b"}]}`,
|
||||
Err: errors.New("metric query 2: name and query are required"),
|
||||
},
|
||||
"Thresholds that are not an array": {
|
||||
Text: `{"thresholds":90}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"A threshold row with a non-numeric value": {
|
||||
Text: `{"thresholds":[{"value":"90"}]}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := decodeMetricAttrs(c.Text)
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Result, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_setSimulation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metric := func(attrs document.Attributes) document.Block {
|
||||
return document.Block{Type: document.BlockNodeMetricBlock, Attrs: attrs}
|
||||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
Attrs document.Attributes
|
||||
Stored document.Block
|
||||
Expected document.Attributes
|
||||
}{
|
||||
"A new metric naming a preset simulates": {
|
||||
Attrs: document.Attributes{"simulationPreset": "cpu_usage"},
|
||||
Expected: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true},
|
||||
},
|
||||
"A new metric naming no preset has no flag": {
|
||||
Attrs: document.Attributes{"title": "x"},
|
||||
Expected: document.Attributes{"title": "x"},
|
||||
},
|
||||
"The stored preset keeps a flag that was switched off": {
|
||||
Attrs: document.Attributes{"simulationPreset": "cpu_usage"},
|
||||
Stored: metric(document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}),
|
||||
Expected: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false},
|
||||
},
|
||||
"The stored preset stored without a flag stays without one": {
|
||||
Attrs: document.Attributes{"simulationPreset": "cpu_usage"},
|
||||
Stored: metric(document.Attributes{"simulationPreset": "cpu_usage"}),
|
||||
Expected: document.Attributes{"simulationPreset": "cpu_usage"},
|
||||
},
|
||||
"Another preset simulates": {
|
||||
Attrs: document.Attributes{"simulationPreset": "error_rate"},
|
||||
Stored: metric(document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false}),
|
||||
Expected: document.Attributes{"simulationPreset": "error_rate", "simulationActive": true},
|
||||
},
|
||||
"A dropped preset drops the flag": {
|
||||
Attrs: document.Attributes{},
|
||||
Stored: metric(document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true}),
|
||||
Expected: document.Attributes{},
|
||||
},
|
||||
"An id given to another kind of block counts as new": {
|
||||
Attrs: document.Attributes{"simulationPreset": "cpu_usage"},
|
||||
Stored: document.Block{
|
||||
Type: document.BlockNodeParagraph,
|
||||
Attrs: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": false},
|
||||
},
|
||||
Expected: document.Attributes{"simulationPreset": "cpu_usage", "simulationActive": true},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setSimulation(c.Attrs, c.Stored)
|
||||
|
||||
assert.Equal(t, c.Expected, c.Attrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
366
server/core/internal/assistant/markup/parse.go
Normal file
366
server/core/internal/assistant/markup/parse.go
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// _knownTags are the element names the parser reads as tags. Anything
|
||||
// else that looks like a tag is literal text, so a stray < in prose
|
||||
// never fails a write.
|
||||
var _knownTags = map[string]bool{
|
||||
_elemParagraph: true, "h1": true, "h2": true, "h3": true,
|
||||
_elemBlockquote: true, _elemBullets: true, _elemOrdered: true, _elemEntry: true,
|
||||
_elemTasks: true, _elemTask: true, _elemCallout: true, _elemCode: true,
|
||||
_elemMermaid: true, _elemRule: true, _elemImage: true, _elemFigma: true,
|
||||
_elemFile: true, _elemMetrics: true, _elemMetric: true, _elemSplitDoc: true,
|
||||
_elemLeft: true, _elemRight: true, _elemParams: true, _elemParam: true,
|
||||
_elemUnsupported: true,
|
||||
_tagBold: true, _tagItalic: true, _tagUnderline: true, _tagStrike: true,
|
||||
_tagCode: true, _tagLink: true,
|
||||
}
|
||||
|
||||
// _tagAliases maps the HTML spellings models often write to the tag
|
||||
// they mean.
|
||||
var _tagAliases = map[string]string{
|
||||
"strong": _tagBold,
|
||||
"em": _tagItalic,
|
||||
"del": _tagStrike,
|
||||
}
|
||||
|
||||
// _entityDecoder decodes the character references text and attribute
|
||||
// values may hold. Any other & stays literal.
|
||||
var _entityDecoder = strings.NewReplacer("&", "&", "<", "<", ">", ">", """, `"`, "'", "'")
|
||||
|
||||
// _nameBytes are the bytes element and attribute names are made of.
|
||||
const _nameBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
|
||||
|
||||
// _maxMarkupLength caps the markup one write may send, in bytes.
|
||||
const _maxMarkupLength = 1 << 20
|
||||
|
||||
// _maxDepth caps how deep elements nest, tags included. Building walks
|
||||
// the tree recursively, so an unbounded depth could exhaust the stack.
|
||||
const _maxDepth = 64
|
||||
|
||||
// node is one piece of parsed markup: an element, or a run of text when
|
||||
// name is empty.
|
||||
type node struct {
|
||||
// name is the element name; empty for text.
|
||||
name string
|
||||
|
||||
// attrs holds the element's attributes, decoded.
|
||||
attrs map[string]string
|
||||
|
||||
// children holds an element's content.
|
||||
children []node
|
||||
|
||||
// text is a text run, or a raw element's content.
|
||||
text string
|
||||
|
||||
// line is the line the node starts on, for messages.
|
||||
line int
|
||||
}
|
||||
|
||||
// tag is an opening tag.
|
||||
type tag struct {
|
||||
// name is the element name.
|
||||
name string
|
||||
|
||||
// attrs holds the tag's attributes, decoded.
|
||||
attrs map[string]string
|
||||
|
||||
// end is the position after the tag.
|
||||
end int
|
||||
|
||||
// selfClosing indicates that the tag closes itself, as in <hr/>.
|
||||
selfClosing bool
|
||||
}
|
||||
|
||||
// parse reads markup into its top-level nodes.
|
||||
func parse(s string) ([]node, error) {
|
||||
if len(s) > _maxMarkupLength {
|
||||
return nil, fmt.Errorf("the markup is %d bytes, over the limit of %d; send it in several calls", len(s), _maxMarkupLength)
|
||||
}
|
||||
|
||||
p := parser{s: s}
|
||||
|
||||
return p.nodes("", 0)
|
||||
}
|
||||
|
||||
// parser walks markup once, front to back.
|
||||
type parser struct {
|
||||
// s is the markup.
|
||||
s string
|
||||
|
||||
// i is the position of the next unread byte.
|
||||
i int
|
||||
|
||||
// counted is the position line has counted newlines up to, and
|
||||
// lines the newlines before it. p.i only moves forward, so counting
|
||||
// on from there keeps the parse linear.
|
||||
counted, lines int
|
||||
|
||||
// depth is the number of elements open at p.i.
|
||||
depth int
|
||||
}
|
||||
|
||||
// nodes reads nodes up to the closing tag of the element named
|
||||
// closeName, opened on line opened, or to the end when closeName is
|
||||
// empty.
|
||||
func (p *parser) nodes(closeName string, opened int) ([]node, error) {
|
||||
var out []node
|
||||
|
||||
for p.i < len(p.s) {
|
||||
if name, end, ok := p.closeTag(); ok {
|
||||
if name != closeName {
|
||||
expected := "nothing"
|
||||
if closeName != "" {
|
||||
expected = endTag(closeName)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("line %d: </%s> closes nothing open; expected %s", p.line(), name, expected)
|
||||
}
|
||||
|
||||
p.i = end
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if t, ok := p.openTag(); ok {
|
||||
el, err := p.element(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = append(out, el)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, p.text())
|
||||
}
|
||||
|
||||
if closeName != "" {
|
||||
return nil, fmt.Errorf("line %d: <%s> is never closed", opened, closeName)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// element reads the element whose opening tag t starts at p.i.
|
||||
func (p *parser) element(t tag) (node, error) {
|
||||
el := node{name: t.name, attrs: t.attrs, line: p.line()}
|
||||
p.i = t.end
|
||||
|
||||
if t.selfClosing {
|
||||
return el, nil
|
||||
}
|
||||
|
||||
if _, raw := _rawElements[t.name]; raw {
|
||||
start := p.i
|
||||
|
||||
for {
|
||||
k := strings.Index(p.s[p.i:], "</")
|
||||
if k < 0 {
|
||||
return node{}, fmt.Errorf("line %d: <%s> is never closed", el.line, t.name)
|
||||
}
|
||||
|
||||
p.i += k
|
||||
|
||||
if name, end, ok := p.closeTag(); ok && name == t.name {
|
||||
// a line break right after the opening tag or right
|
||||
// before the closing one is layout, as after <pre> in
|
||||
// HTML.
|
||||
el.text = strings.TrimSuffix(strings.TrimPrefix(unescapeRaw(p.s[start:p.i], t.name), "\n"), "\n")
|
||||
p.i = end
|
||||
|
||||
return el, nil
|
||||
}
|
||||
|
||||
p.i += len("</")
|
||||
}
|
||||
}
|
||||
|
||||
if p.depth == _maxDepth {
|
||||
return node{}, fmt.Errorf("line %d: elements nest more than %d deep", el.line, _maxDepth)
|
||||
}
|
||||
|
||||
p.depth++
|
||||
|
||||
children, err := p.nodes(t.name, el.line)
|
||||
if err != nil {
|
||||
return node{}, err
|
||||
}
|
||||
|
||||
p.depth--
|
||||
el.children = children
|
||||
|
||||
return el, nil
|
||||
}
|
||||
|
||||
// text reads text up to the next known tag, decoding entities. A < that
|
||||
// starts no known tag is part of the text.
|
||||
func (p *parser) text() node {
|
||||
start, line := p.i, p.line()
|
||||
|
||||
// the first byte is text: nodes calls this only where no known tag
|
||||
// starts.
|
||||
p.i++
|
||||
|
||||
for {
|
||||
k := strings.IndexByte(p.s[p.i:], '<')
|
||||
if k < 0 {
|
||||
p.i = len(p.s)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
p.i += k
|
||||
|
||||
if _, _, ok := p.closeTag(); ok {
|
||||
break
|
||||
}
|
||||
|
||||
if _, ok := p.openTag(); ok {
|
||||
break
|
||||
}
|
||||
|
||||
p.i++
|
||||
}
|
||||
|
||||
return node{text: _entityDecoder.Replace(p.s[start:p.i]), line: line}
|
||||
}
|
||||
|
||||
// openTag reads the opening tag of a known element at p.i without moving
|
||||
// past it. ok is false when none starts there.
|
||||
func (p *parser) openTag() (tag, bool) {
|
||||
if p.s[p.i] != '<' {
|
||||
return tag{}, false
|
||||
}
|
||||
|
||||
name, i := p.tagName(p.i + len("<"))
|
||||
if !_knownTags[name] {
|
||||
return tag{}, false
|
||||
}
|
||||
|
||||
t := tag{name: name, attrs: map[string]string{}}
|
||||
|
||||
for {
|
||||
i = p.skipSpace(i)
|
||||
|
||||
switch {
|
||||
case i >= len(p.s):
|
||||
return tag{}, false
|
||||
case p.s[i] == '>':
|
||||
t.end = i + len(">")
|
||||
|
||||
return t, true
|
||||
case strings.HasPrefix(p.s[i:], "/>"):
|
||||
t.end = i + len("/>")
|
||||
t.selfClosing = true
|
||||
|
||||
return t, true
|
||||
}
|
||||
|
||||
key, value, next, ok := p.attribute(i)
|
||||
if !ok {
|
||||
return tag{}, false
|
||||
}
|
||||
|
||||
t.attrs[key] = value
|
||||
i = next
|
||||
}
|
||||
}
|
||||
|
||||
// attribute reads the attribute at i: its key, its decoded value and the
|
||||
// position after it. A bare attribute, such as checked, is true.
|
||||
func (p *parser) attribute(i int) (string, string, int, bool) {
|
||||
start := i
|
||||
i = p.nameEnd(i)
|
||||
|
||||
if i == start {
|
||||
return "", "", 0, false
|
||||
}
|
||||
|
||||
key := p.s[start:i]
|
||||
|
||||
if i >= len(p.s) || p.s[i] != '=' {
|
||||
return key, "true", i, true
|
||||
}
|
||||
|
||||
i++
|
||||
|
||||
if i >= len(p.s) || (p.s[i] != '"' && p.s[i] != '\'') {
|
||||
return "", "", 0, false
|
||||
}
|
||||
|
||||
k := strings.IndexByte(p.s[i+1:], p.s[i])
|
||||
if k < 0 {
|
||||
return "", "", 0, false
|
||||
}
|
||||
|
||||
// the value, and the quotes either side of it.
|
||||
return key, _entityDecoder.Replace(p.s[i+1 : i+1+k]), i + k + len(`""`), true
|
||||
}
|
||||
|
||||
// closeTag reads the closing tag of a known element at p.i without
|
||||
// moving past it: its name and the position after it.
|
||||
func (p *parser) closeTag() (string, int, bool) {
|
||||
if !strings.HasPrefix(p.s[p.i:], "</") {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
name, i := p.tagName(p.i + len("</"))
|
||||
i = p.skipSpace(i)
|
||||
|
||||
if !_knownTags[name] || i >= len(p.s) || p.s[i] != '>' {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
return name, i + len(">"), true
|
||||
}
|
||||
|
||||
// tagName reads the element name at i, lowercased and with any alias
|
||||
// resolved, and returns it with the position after it.
|
||||
func (p *parser) tagName(i int) (string, int) {
|
||||
end := p.nameEnd(i)
|
||||
|
||||
name := strings.ToLower(p.s[i:end])
|
||||
if alias, ok := _tagAliases[name]; ok {
|
||||
name = alias
|
||||
}
|
||||
|
||||
return name, end
|
||||
}
|
||||
|
||||
// nameEnd returns the position after the element or attribute name at
|
||||
// i.
|
||||
func (p *parser) nameEnd(i int) int {
|
||||
return len(p.s) - len(strings.TrimLeft(p.s[i:], _nameBytes))
|
||||
}
|
||||
|
||||
// skipSpace returns the position of the first byte from i on that is
|
||||
// not white space.
|
||||
func (p *parser) skipSpace(i int) int {
|
||||
return len(p.s) - len(strings.TrimLeft(p.s[i:], " \t\n\r"))
|
||||
}
|
||||
|
||||
// line returns the line of p.i, counting from 1.
|
||||
func (p *parser) line() int {
|
||||
p.lines += strings.Count(p.s[p.counted:p.i], "\n")
|
||||
p.counted = p.i
|
||||
|
||||
return p.lines + 1
|
||||
}
|
||||
|
||||
// unescapeRaw undoes escapeRaw: an escaped closing tag reads as the tag,
|
||||
// and one with more amp; loses one.
|
||||
func unescapeRaw(s, name string) string {
|
||||
return _rawElements[name].ReplaceAllStringFunc(s, func(m string) string {
|
||||
if tagName, ok := strings.CutPrefix(m, "</"); ok {
|
||||
return "</" + tagName
|
||||
}
|
||||
|
||||
return strings.Replace(m, "amp;", "", 1)
|
||||
})
|
||||
}
|
||||
174
server/core/internal/assistant/markup/parse_test.go
Normal file
174
server/core/internal/assistant/markup/parse_test.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// dump writes parsed nodes in a compact form that tests compare
|
||||
// against: text as a quoted string, an element as name@line, its sorted
|
||||
// attributes in parentheses and its content in brackets.
|
||||
func dump(nodes []node) string {
|
||||
parts := make([]string, 0, len(nodes))
|
||||
|
||||
for _, n := range nodes {
|
||||
if n.name == "" {
|
||||
parts = append(parts, strconv.Quote(n.text))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(n.name + "@" + strconv.Itoa(n.line))
|
||||
|
||||
if len(n.attrs) > 0 {
|
||||
kv := make([]string, 0, len(n.attrs))
|
||||
for _, k := range slices.Sorted(maps.Keys(n.attrs)) {
|
||||
kv = append(kv, k+"="+n.attrs[k])
|
||||
}
|
||||
|
||||
sb.WriteString("(" + strings.Join(kv, " ") + ")")
|
||||
}
|
||||
|
||||
if n.text != "" {
|
||||
sb.WriteString("{" + strconv.Quote(n.text) + "}")
|
||||
}
|
||||
|
||||
if len(n.children) > 0 {
|
||||
sb.WriteString("[" + dump(n.children) + "]")
|
||||
}
|
||||
|
||||
parts = append(parts, sb.String())
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func Test_parse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Input string
|
||||
Result string
|
||||
|
||||
// Count, when set, is the number of top-level nodes expected in
|
||||
// place of Result.
|
||||
Count int
|
||||
|
||||
Err error
|
||||
}{
|
||||
"Paragraph with inline tags": {
|
||||
Input: `<p id="a">Hi <b>bold <i>both</i></b> and <a href="https://x.test/?a=1&b=2">link</a></p>`,
|
||||
Result: `p@1(id=a)["Hi " b@1["bold " i@1["both"]] " and " a@1(href=https://x.test/?a=1&b=2)["link"]]`,
|
||||
},
|
||||
"Blocks on their own lines": {
|
||||
Input: "<ul id=\"l\">\n <li id=\"a\">One</li>\n</ul>\n<hr/>",
|
||||
Result: `ul@1(id=l)["\n " li@2(id=a)["One"] "\n"] "\n" hr@4`,
|
||||
},
|
||||
"Raw element keeps markup and entities as written": {
|
||||
Input: `<pre language="go">if a < b && c { return "</p>" } & </pre></pre>`,
|
||||
Result: `pre@1(language=go){"if a < b && c { return \"</p>\" } & </pre>"}`,
|
||||
},
|
||||
"Stray angle bracket and unknown tag are text": {
|
||||
Input: `<p>1 < 2 and <br> and <div>x</div></p>`,
|
||||
Result: `p@1["1 < 2 and <br> and <div>x</div>"]`,
|
||||
},
|
||||
"Unknown entity is text": {
|
||||
Input: `<p>a b & c < d</p>`,
|
||||
Result: `p@1["a b & c < d"]`,
|
||||
},
|
||||
"HTML spellings of bold, italic and strike": {
|
||||
Input: `<p><strong>x</strong> <em>y</em> <DEL>z</DEL></p>`,
|
||||
Result: `p@1[b@1["x"] " " i@1["y"] " " s@1["z"]]`,
|
||||
},
|
||||
"Bare, single quoted and self-closing attributes": {
|
||||
Input: `<task checked id='t"1'>Done</task><img src="x.png" width="2"/>`,
|
||||
Result: `task@1(checked=true id=t"1)["Done"] img@1(src=x.png width=2)`,
|
||||
},
|
||||
"Malformed tag is text": {
|
||||
Input: `<p>see <b class=x>this</p>`,
|
||||
Result: `p@1["see <b class=x>this"]`,
|
||||
},
|
||||
"Entity longer than any known one is text": {
|
||||
Input: `<p>a &ersand; b</p>`,
|
||||
Result: `p@1["a &ersand; b"]`,
|
||||
},
|
||||
"Elements nested to the limit": {
|
||||
Input: strings.Repeat("<b>", _maxDepth) + "x" + strings.Repeat("</b>", _maxDepth),
|
||||
Result: strings.Repeat("b@1[", _maxDepth) + `"x"` + strings.Repeat("]", _maxDepth),
|
||||
},
|
||||
"Elements nested past the limit are refused": {
|
||||
Input: strings.Repeat("<b>", _maxDepth+1) + "x" + strings.Repeat("</b>", _maxDepth+1),
|
||||
Err: errors.New("line 1: elements nest more than 64 deep"),
|
||||
},
|
||||
"Markup over the size limit is refused": {
|
||||
Input: strings.Repeat("a", _maxMarkupLength+1),
|
||||
Err: errors.New("the markup is 1048577 bytes, over the limit of 1048576; send it in several calls"),
|
||||
},
|
||||
// many elements and many stray brackets both read in one pass.
|
||||
"Large input": {
|
||||
Input: strings.Repeat("<p>a <b>b</b> c < d</p>\n", 20000),
|
||||
Count: 40000,
|
||||
},
|
||||
"Unclosed element is refused with its line": {
|
||||
Input: "<p>a</p>\n<callout>\n<p>b</p>",
|
||||
Err: errors.New("line 2: <callout> is never closed"),
|
||||
},
|
||||
"Raw element closes in any case and with space before >": {
|
||||
Input: "<PRE>a</PRE>\n<p>b</p>\n<pre>c</pre >",
|
||||
Result: `pre@1{"a"} "\n" p@2["b"] "\n" pre@3{"c"}`,
|
||||
},
|
||||
"Raw element runs past a closing tag of another element": {
|
||||
Input: "<pre>a</p></Pre>",
|
||||
Result: `pre@1{"a</p>"}`,
|
||||
},
|
||||
"Unclosed raw element is refused": {
|
||||
Input: "<pre>x",
|
||||
Err: errors.New("line 1: <pre> is never closed"),
|
||||
},
|
||||
"Closing tag of another element is refused": {
|
||||
Input: "<p>a\n</li>",
|
||||
Err: errors.New("line 2: </li> closes nothing open; expected </p>"),
|
||||
},
|
||||
"Closing tag with nothing open is refused": {
|
||||
Input: "a</p>",
|
||||
Err: errors.New("line 1: </p> closes nothing open; expected nothing"),
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parse(c.Input)
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.Count > 0 {
|
||||
assert.Len(t, got, c.Count)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Result, dump(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_unescapeRaw(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "a </pre> </pre> &lt;/pre> </p>", unescapeRaw("a </pre> &lt;/pre> &amp;lt;/pre> </p>", _elemCode))
|
||||
assert.Equal(t, "a </PRE> </Pre>", unescapeRaw("a </PRE> &lt;/Pre>", _elemCode))
|
||||
}
|
||||
297
server/core/internal/assistant/markup/render.go
Normal file
297
server/core/internal/assistant/markup/render.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
jsonv2 "encoding/json/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// _indent is one level of nesting in rendered markup.
|
||||
const _indent = " "
|
||||
|
||||
// _plainContainers names the elements of the nodes that hold blocks and
|
||||
// carry no attribute but their id.
|
||||
var _plainContainers = map[document.BlockNodeType]string{
|
||||
document.BlockNodeBlockquote: _elemBlockquote,
|
||||
document.BlockNodeBulletList: _elemBullets,
|
||||
document.BlockNodeTaskList: _elemTasks,
|
||||
document.BlockNodeMetricGrid: _elemMetrics,
|
||||
}
|
||||
|
||||
// _textEscaper escapes the characters that would start markup in text.
|
||||
var _textEscaper = strings.NewReplacer("&", "&", "<", "<")
|
||||
|
||||
// _attrEscaper escapes the characters that would end or start markup in
|
||||
// an attribute value.
|
||||
var _attrEscaper = strings.NewReplacer("&", "&", "<", "<", `"`, """)
|
||||
|
||||
// Render returns blocks as markup: one element per block, nested
|
||||
// elements indented one level deeper than their parent.
|
||||
func Render(blocks []document.Block) string {
|
||||
var r renderer
|
||||
|
||||
for _, b := range blocks {
|
||||
r.block(b, 0)
|
||||
}
|
||||
|
||||
return r.sb.String()
|
||||
}
|
||||
|
||||
// renderer accumulates rendered markup.
|
||||
type renderer struct {
|
||||
// sb holds the markup written so far.
|
||||
sb strings.Builder
|
||||
}
|
||||
|
||||
// block renders one block and everything nested in it.
|
||||
func (r *renderer) block(b document.Block, depth int) {
|
||||
if name, ok := _plainContainers[b.Type]; ok {
|
||||
r.container(name, startTag(name, b), b.Content, depth)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch b.Type {
|
||||
case document.BlockNodeParagraph:
|
||||
r.line(depth, startTag(_elemParagraph, b)+renderInline(b.Content)+endTag(_elemParagraph))
|
||||
case document.BlockNodeHeading:
|
||||
name := "h" + strconv.Itoa(max(1, b.Attrs.Get(document.AttrLevel).Int()))
|
||||
r.line(depth, startTag(name, b)+_textEscaper.Replace(b.PlainText())+endTag(name))
|
||||
case document.BlockNodeCalloutBlock:
|
||||
r.container(_elemCallout, startTag(_elemCallout, b, blockAttr(b, document.AttrIcon)), b.Content, depth)
|
||||
case document.BlockNodeOrderedList:
|
||||
start := attr{key: document.AttrStart}
|
||||
if n := b.Attrs.Get(document.AttrStart).Int(); n > 1 {
|
||||
start.value = n
|
||||
}
|
||||
|
||||
r.container(_elemOrdered, startTag(_elemOrdered, b, start), b.Content, depth)
|
||||
case document.BlockNodeListItem:
|
||||
r.entry(_elemEntry, startTag(_elemEntry, b), b, depth)
|
||||
case document.BlockNodeTaskItem:
|
||||
r.entry(_elemTask, startTag(_elemTask, b, blockAttr(b, document.AttrChecked)), b, depth)
|
||||
case document.BlockNodeCodeBlock:
|
||||
r.raw(depth, startTag(_elemCode, b, blockAttr(b, document.AttrLanguage)), _elemCode, b.PlainText())
|
||||
case document.BlockNodeTitledCodeBlock:
|
||||
code := b.FirstChild(document.BlockNodeCodeBlock)
|
||||
|
||||
// an empty title still renders, since a pre without one is plain
|
||||
// code, which is another block.
|
||||
title := attr{key: document.AttrTitle, value: b.FirstChild(document.BlockNodeCodeBlockTitle).PlainText(), keepEmpty: true}
|
||||
r.raw(depth, startTag(_elemCode, b, title, blockAttr(code, document.AttrLanguage)), _elemCode, code.PlainText())
|
||||
case document.BlockNodeMermaidBlock:
|
||||
r.raw(depth, startTag(_elemMermaid, b), _elemMermaid, b.PlainText())
|
||||
case document.BlockNodeHorizontalRule:
|
||||
r.line(depth, emptyTag(_elemRule, b))
|
||||
case document.BlockNodeImageBlock:
|
||||
r.line(depth, emptyTag(_elemImage, b,
|
||||
blockAttr(b, document.AttrSrc),
|
||||
blockAttr(b, document.AttrAlt),
|
||||
blockAttr(b, document.AttrTitle),
|
||||
blockAttr(b, document.AttrWidth),
|
||||
))
|
||||
case document.BlockNodeFigmaBlock:
|
||||
r.line(depth, emptyTag(_elemFigma, b,
|
||||
blockAttr(b, document.AttrSrc),
|
||||
blockAttr(b, document.AttrWidth),
|
||||
blockAttr(b, document.AttrHeight),
|
||||
))
|
||||
case document.BlockNodeFileBlock:
|
||||
r.line(depth, emptyTag(_elemFile, b,
|
||||
blockAttr(b, document.AttrSrc),
|
||||
blockAttr(b, document.AttrName),
|
||||
blockAttr(b, document.AttrContentType),
|
||||
blockAttr(b, document.AttrSize),
|
||||
))
|
||||
case document.BlockNodeMetricBlock:
|
||||
r.raw(depth, startTag(_elemMetric, b), _elemMetric, metricJSON(b))
|
||||
case document.BlockNodeSplitDoc:
|
||||
r.container(_elemSplitDoc, startTag(_elemSplitDoc, b, blockAttr(b, document.AttrInversed)), b.Content, depth)
|
||||
case document.BlockNodeSplitDocLeft:
|
||||
r.container(_elemLeft, "<"+_elemLeft+">", b.Content, depth)
|
||||
case document.BlockNodeSplitDocRight:
|
||||
r.container(_elemRight, "<"+_elemRight+">", b.Content, depth)
|
||||
case document.BlockNodeParamList:
|
||||
header := attr{key: _attrHeader, value: b.FirstChild(document.BlockNodeParamListHeader).PlainText()}
|
||||
r.container(_elemParams, startTag(_elemParams, b, header), b.Content, depth)
|
||||
case document.BlockNodeParamListHeader:
|
||||
// its text is the params' header attribute.
|
||||
case document.BlockNodeParamListItem:
|
||||
r.param(b, depth)
|
||||
default:
|
||||
r.line(depth, emptyTag(_elemUnsupported, b, attr{key: _attrType, value: string(b.Type)}))
|
||||
}
|
||||
}
|
||||
|
||||
// line writes one line of markup at the given depth.
|
||||
func (r *renderer) line(depth int, s string) {
|
||||
r.sb.WriteString(strings.Repeat(_indent, depth))
|
||||
r.sb.WriteString(s)
|
||||
r.sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
// container writes an element whose content is blocks, each on its
|
||||
// own lines one level deeper.
|
||||
func (r *renderer) container(name, openTag string, children []document.Block, depth int) {
|
||||
r.line(depth, openTag)
|
||||
|
||||
for _, c := range children {
|
||||
r.block(c, depth+1)
|
||||
}
|
||||
|
||||
r.line(depth, endTag(name))
|
||||
}
|
||||
|
||||
// entry writes a list or task entry: the text of its leading paragraph,
|
||||
// then whatever is nested under it.
|
||||
func (r *renderer) entry(name, openTag string, b document.Block, depth int) {
|
||||
children := b.Content
|
||||
|
||||
var text string
|
||||
|
||||
if len(children) > 0 && children[0].Type == document.BlockNodeParagraph {
|
||||
text = renderInline(children[0].Content)
|
||||
children = children[1:]
|
||||
}
|
||||
|
||||
if len(children) == 0 {
|
||||
r.line(depth, openTag+text+endTag(name))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
r.container(name, openTag+text, children, depth)
|
||||
}
|
||||
|
||||
// raw writes an element whose content is raw text, kept as it is but
|
||||
// for its own closing tag, which would end it early.
|
||||
func (r *renderer) raw(depth int, openTag, name, text string) {
|
||||
// parsing drops one line break after the opening tag and one before
|
||||
// the closing tag, so text that starts or ends with one gets another.
|
||||
if strings.HasPrefix(text, "\n") {
|
||||
text = "\n" + text
|
||||
}
|
||||
|
||||
if strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
|
||||
r.line(depth, openTag+escapeRaw(text, name)+endTag(name))
|
||||
}
|
||||
|
||||
// param writes one parameter row with its name, type and description.
|
||||
func (r *renderer) param(row document.Block, depth int) {
|
||||
header := row.FirstChild(document.BlockNodeParamListItemHeader)
|
||||
name := attr{key: document.AttrName, value: header.FirstChild(document.BlockNodeParamListItemTitle).PlainText()}
|
||||
typ := attr{key: _attrType, value: header.FirstChild(document.BlockNodeParamListItemType).PlainText()}
|
||||
description := row.FirstChild(document.BlockNodeParagraph).Content
|
||||
|
||||
r.line(depth, startTag(_elemParam, row, name, typ)+renderInline(description)+endTag(_elemParam))
|
||||
}
|
||||
|
||||
// metricJSON returns a metric's attributes as JSON, leaving out the
|
||||
// hidden ones and those without a value.
|
||||
func metricJSON(b document.Block) string {
|
||||
attrs := make(map[string]any, len(b.Attrs))
|
||||
|
||||
for k, v := range b.Attrs {
|
||||
if v != nil && !_hiddenMetricAttrs[k] {
|
||||
attrs[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// keys are sorted so that the same attributes always render the
|
||||
// same. json/v2 leaves the < and & of queries unescaped.
|
||||
out, err := jsonv2.Marshal(attrs, jsonv2.Deterministic(true))
|
||||
if err != nil {
|
||||
// NOCOV: attributes decoded from JSON always encode back.
|
||||
return "{}"
|
||||
}
|
||||
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// attr is one attribute of a rendered element.
|
||||
type attr struct {
|
||||
// key is the attribute name.
|
||||
key string
|
||||
|
||||
// value is the attribute value: a string, a bool or a number.
|
||||
value any
|
||||
|
||||
// keepEmpty indicates that the attribute renders even when empty.
|
||||
keepEmpty bool
|
||||
}
|
||||
|
||||
// text returns the value as written: a string as it is, a bool only
|
||||
// when true, a number only when positive. Any other value is "".
|
||||
func (a attr) text() string {
|
||||
switch v := a.value.(type) {
|
||||
case string:
|
||||
return v
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
case int:
|
||||
if v > 0 {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
case float64:
|
||||
if n := int(v); n > 0 {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// blockAttr returns b's attribute key as a rendered attribute.
|
||||
func blockAttr(b document.Block, key string) attr {
|
||||
return attr{key: key, value: b.Attrs[key]}
|
||||
}
|
||||
|
||||
// startTag returns the start tag of the element for b, its id first.
|
||||
func startTag(name string, b document.Block, attrs ...attr) string {
|
||||
return "<" + name + renderAttrs(b, attrs) + ">"
|
||||
}
|
||||
|
||||
// emptyTag returns the tag of an element for b that holds nothing.
|
||||
func emptyTag(name string, b document.Block, attrs ...attr) string {
|
||||
return "<" + name + renderAttrs(b, attrs) + "/>"
|
||||
}
|
||||
|
||||
// endTag returns the end tag of the named element.
|
||||
func endTag(name string) string {
|
||||
return "</" + name + ">"
|
||||
}
|
||||
|
||||
// renderAttrs renders b's id and the given attributes. One without a
|
||||
// value is left out unless it is kept.
|
||||
func renderAttrs(b document.Block, attrs []attr) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if uid, _ := b.UID(); uid != "" {
|
||||
sb.WriteString(` id="` + _attrEscaper.Replace(uid) + `"`)
|
||||
}
|
||||
|
||||
for _, a := range attrs {
|
||||
if text := a.text(); text != "" || a.keepEmpty {
|
||||
sb.WriteString(" " + a.key + `="` + _attrEscaper.Replace(text) + `"`)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// escapeRaw escapes the one sequence raw text cannot hold as is: its
|
||||
// element's closing tag, in any case. A closing tag the text already
|
||||
// holds escaped gets one more amp; so that reading it back undoes
|
||||
// exactly one level.
|
||||
func escapeRaw(s, name string) string {
|
||||
s = _rawElements[name].ReplaceAllString(s, "&${1}lt;/${2}")
|
||||
|
||||
return _rawCloseTags[name].ReplaceAllString(s, "</${1}")
|
||||
}
|
||||
135
server/core/internal/assistant/markup/render_test.go
Normal file
135
server/core/internal/assistant/markup/render_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
// _fixtures are the documents in testdata, each with a golden rendering
|
||||
// beside it.
|
||||
var _fixtures = []string{"all_blocks", "welcome", "scratch", "empty_parts"}
|
||||
|
||||
// loadFixture reads a document fixture from testdata.
|
||||
func loadFixture(t *testing.T, name string) document.RootBlock {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile("testdata/" + name + ".json") //nolint:gosec // the name is one of this package's own fixtures
|
||||
require.NoError(t, err)
|
||||
|
||||
var root document.RootBlock
|
||||
require.NoError(t, json.Unmarshal(data, &root))
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func Test_Render(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, name := range _fixtures {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Render(loadFixture(t, name).Content)
|
||||
|
||||
// a rendering change is meant to be reviewed as a golden
|
||||
// diff, so the file is rewritten on request.
|
||||
if os.Getenv("UPDATE_GOLDEN") != "" {
|
||||
require.NoError(t, os.WriteFile("testdata/"+name+".xml", []byte(got), 0o600))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
want, err := os.ReadFile("testdata/" + name + ".xml") //nolint:gosec // the name is one of this package's own fixtures
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(want), got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_renderer_entry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// an entry whose first child is not a paragraph has no text of its
|
||||
// own, and its children render nested.
|
||||
got := Render([]document.Block{{
|
||||
Type: document.BlockNodeBulletList,
|
||||
Attrs: document.Attributes{document.AttrUID: "l"},
|
||||
Content: []document.Block{{
|
||||
Type: document.BlockNodeListItem,
|
||||
Attrs: document.Attributes{document.AttrUID: "li"},
|
||||
Content: []document.Block{{
|
||||
Type: document.BlockNodeCodeBlock,
|
||||
Attrs: document.Attributes{document.AttrUID: "c"},
|
||||
Content: []document.Block{{Type: document.BlockNodeText, Text: "x"}},
|
||||
}},
|
||||
}},
|
||||
}})
|
||||
|
||||
assert.Equal(t, strings.Join([]string{
|
||||
`<ul id="l">`,
|
||||
` <li id="li">`,
|
||||
` <pre id="c">x</pre>`,
|
||||
` </li>`,
|
||||
`</ul>`,
|
||||
``,
|
||||
}, "\n"), got)
|
||||
}
|
||||
|
||||
func Test_renderer_param(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// a param read or moved alone renders as itself.
|
||||
row, ok := loadFixture(t, "all_blocks").FindByUID("pi1")
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, "<param id=\"pi1\" name=\"email\" type=\"string\">The <b>user</b> email</param>\n", Render([]document.Block{row}))
|
||||
}
|
||||
|
||||
func Test_attr_text(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Value any
|
||||
Result string
|
||||
}{
|
||||
"String": {Value: "a", Result: "a"},
|
||||
"True": {Value: true, Result: "true"},
|
||||
"False": {Value: false},
|
||||
"Positive int": {Value: 3, Result: "3"},
|
||||
"Zero int": {Value: 0},
|
||||
"Positive float": {Value: 640.0, Result: "640"},
|
||||
"Negative float": {Value: -1.0},
|
||||
"Unset": {},
|
||||
"Unsupported value": {Value: []any{"a"}},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, attr{key: "k", value: c.Value}.text())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_escapeRaw(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// every level of escaping reads back as it was written.
|
||||
for _, raw := range []string{"a </pre> b", "a </pre> b", "a &lt;/pre> b", "</p> </p>", "a </PRE> </Pre> b"} {
|
||||
escaped := escapeRaw(raw, _elemCode)
|
||||
assert.NotContains(t, strings.ToLower(escaped), "</pre")
|
||||
assert.Equal(t, raw, unescapeRaw(escaped, _elemCode))
|
||||
}
|
||||
}
|
||||
632
server/core/internal/assistant/markup/testdata/all_blocks.json
vendored
Normal file
632
server/core/internal/assistant/markup/testdata/all_blocks.json
vendored
Normal file
|
|
@ -0,0 +1,632 @@
|
|||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"uid": "h1",
|
||||
"level": 2
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Heading & <stuff>"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "noted",
|
||||
"marks": [
|
||||
{
|
||||
"type": "comment",
|
||||
"attrs": {
|
||||
"commentId": "c1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "p1",
|
||||
"nodeCommentId": "nc-p1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Plain "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "bold",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "both",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
},
|
||||
{
|
||||
"type": "italic"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "under",
|
||||
"marks": [
|
||||
{
|
||||
"type": "underline"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "gone",
|
||||
"marks": [
|
||||
{
|
||||
"type": "strike"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "code <x>",
|
||||
"marks": [
|
||||
{
|
||||
"type": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " and a "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "link",
|
||||
"marks": [
|
||||
{
|
||||
"type": "link",
|
||||
"attrs": {
|
||||
"href": "https://x.test/a?b=1&c=\"2\""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " with "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "a comment",
|
||||
"marks": [
|
||||
{
|
||||
"type": "comment",
|
||||
"attrs": {
|
||||
"commentId": "c1"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " & 1 < 2 > 0."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "p2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "blockquote",
|
||||
"attrs": {
|
||||
"uid": "q1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "q1p"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Quoted"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "bulletList",
|
||||
"attrs": {
|
||||
"uid": "l1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"attrs": {
|
||||
"uid": "li1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "li1p"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "One"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "listItem",
|
||||
"attrs": {
|
||||
"uid": "li2"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "li2p"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Two"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "orderedList",
|
||||
"attrs": {
|
||||
"uid": "l2",
|
||||
"start": 3
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"attrs": {
|
||||
"uid": "li3"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "li3p"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Three"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": {
|
||||
"uid": "lc",
|
||||
"language": null
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "nested code"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskList",
|
||||
"attrs": {
|
||||
"uid": "t1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "ti1",
|
||||
"checked": true
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "ti1p"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Done"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "ti2",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "ti2p"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Open"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "calloutBlock",
|
||||
"attrs": {
|
||||
"uid": "c1",
|
||||
"icon": "lucide:info"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "c1p"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Callout"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "bulletList",
|
||||
"attrs": {
|
||||
"uid": "cl"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"attrs": {
|
||||
"uid": "cli"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "clip"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "In callout"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": {
|
||||
"uid": "code1",
|
||||
"language": "go"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "if a < b && c {\n\treturn \"</pre>\"\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "mermaidBlock",
|
||||
"attrs": {
|
||||
"uid": "m1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "graph TD; A-->B;"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontalRule",
|
||||
"attrs": {
|
||||
"uid": "hr1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "imageBlock",
|
||||
"attrs": {
|
||||
"uid": "img1",
|
||||
"src": "https://x.test/i.png",
|
||||
"alt": "An \"image\"",
|
||||
"title": null,
|
||||
"width": 320
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "figmaBlock",
|
||||
"attrs": {
|
||||
"uid": "fig1",
|
||||
"src": "https://figma.com/x",
|
||||
"width": 800,
|
||||
"height": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "fileBlock",
|
||||
"attrs": {
|
||||
"uid": "file1",
|
||||
"src": "/api/documents/d/files/f",
|
||||
"name": "a.pdf",
|
||||
"size": 1234,
|
||||
"contentType": "application/pdf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "metricGrid",
|
||||
"attrs": {
|
||||
"uid": "g1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "metricBlock",
|
||||
"attrs": {
|
||||
"uid": "mb1",
|
||||
"dataSourceId": "ds1",
|
||||
"visualizationType": "line_chart",
|
||||
"queries": [
|
||||
{
|
||||
"name": "Q",
|
||||
"query": "up",
|
||||
"legendFormat": ""
|
||||
}
|
||||
],
|
||||
"simulationActive": true,
|
||||
"simulationPreset": "cpu_usage",
|
||||
"width": "standard",
|
||||
"title": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentation",
|
||||
"attrs": {
|
||||
"uid": "sd1",
|
||||
"inversed": true
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentationLeftSide",
|
||||
"attrs": {
|
||||
"uid": "sl"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"uid": "sh",
|
||||
"level": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "POST /login"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "sp"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Issues a token."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentationParameterList",
|
||||
"attrs": {
|
||||
"uid": "pl1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentationParameterListHeader",
|
||||
"attrs": {
|
||||
"uid": "plh"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Request body"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentationParameterListItem",
|
||||
"attrs": {
|
||||
"uid": "pi1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentationParameterListItemHeader",
|
||||
"attrs": {
|
||||
"uid": "pih"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentationParameterListItemHeaderTitle",
|
||||
"attrs": {
|
||||
"uid": "pit"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "email"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentationParameterListItemHeaderType",
|
||||
"attrs": {
|
||||
"uid": "pity"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "pip"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "The "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "user",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " email"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentationRightSide",
|
||||
"attrs": {
|
||||
"uid": "sr"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "titledCodeBlock",
|
||||
"attrs": {
|
||||
"uid": "tc1"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "codeBlockTitle",
|
||||
"attrs": {
|
||||
"uid": "tct"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "POST /login"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": {
|
||||
"uid": "tcc",
|
||||
"language": "json",
|
||||
"nodeCommentId": "nc-tcc"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "{\"email\": \"a@b\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "mermaidBlock",
|
||||
"attrs": {
|
||||
"uid": "m2"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "graph LR; X-->Y;"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "mysteryBlock",
|
||||
"attrs": {
|
||||
"uid": "u1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
50
server/core/internal/assistant/markup/testdata/all_blocks.xml
vendored
Normal file
50
server/core/internal/assistant/markup/testdata/all_blocks.xml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<h2 id="h1">Heading & <stuff>noted</h2>
|
||||
<p id="p1">Plain <b>bold</b> <b><i>both</i></b> <u>under</u> <s>gone</s> <code>code <x></code> and a <a href="https://x.test/a?b=1&c="2"">link</a> with a comment & 1 < 2 > 0.</p>
|
||||
<p id="p2"></p>
|
||||
<blockquote id="q1">
|
||||
<p id="q1p">Quoted</p>
|
||||
</blockquote>
|
||||
<ul id="l1">
|
||||
<li id="li1">One</li>
|
||||
<li id="li2">Two
|
||||
<ol id="l2" start="3">
|
||||
<li id="li3">Three</li>
|
||||
</ol>
|
||||
<pre id="lc">nested code</pre>
|
||||
</li>
|
||||
</ul>
|
||||
<tasks id="t1">
|
||||
<task id="ti1" checked="true">Done</task>
|
||||
<task id="ti2">Open</task>
|
||||
</tasks>
|
||||
<callout id="c1" icon="lucide:info">
|
||||
<p id="c1p">Callout</p>
|
||||
<ul id="cl">
|
||||
<li id="cli">In callout</li>
|
||||
</ul>
|
||||
</callout>
|
||||
<pre id="code1" language="go">if a < b && c {
|
||||
return "</pre>"
|
||||
}</pre>
|
||||
<mermaid id="m1">graph TD; A-->B;</mermaid>
|
||||
<hr id="hr1"/>
|
||||
<img id="img1" src="https://x.test/i.png" alt="An "image"" width="320"/>
|
||||
<figma id="fig1" src="https://figma.com/x" width="800"/>
|
||||
<file id="file1" src="/api/documents/d/files/f" name="a.pdf" contentType="application/pdf" size="1234"/>
|
||||
<metrics id="g1">
|
||||
<metric id="mb1">{"dataSourceId":"ds1","queries":[{"legendFormat":"","name":"Q","query":"up"}],"simulationPreset":"cpu_usage","visualizationType":"line_chart","width":"standard"}</metric>
|
||||
</metrics>
|
||||
<split_doc id="sd1" inversed="true">
|
||||
<left>
|
||||
<h1 id="sh">POST /login</h1>
|
||||
<p id="sp">Issues a token.</p>
|
||||
<params id="pl1" header="Request body">
|
||||
<param id="pi1" name="email" type="string">The <b>user</b> email</param>
|
||||
</params>
|
||||
</left>
|
||||
<right>
|
||||
<pre id="tc1" title="POST /login" language="json">{"email": "a@b"}</pre>
|
||||
<mermaid id="m2">graph LR; X-->Y;</mermaid>
|
||||
</right>
|
||||
</split_doc>
|
||||
<unsupported id="u1" type="mysteryBlock"/>
|
||||
131
server/core/internal/assistant/markup/testdata/empty_parts.json
vendored
Normal file
131
server/core/internal/assistant/markup/testdata/empty_parts.json
vendored
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentation",
|
||||
"attrs": {
|
||||
"uid": "sd",
|
||||
"inversed": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentationLeftSide",
|
||||
"attrs": {
|
||||
"uid": "sl"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"uid": "sh",
|
||||
"level": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "sp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentationParameterList",
|
||||
"attrs": {
|
||||
"uid": "pl"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentationParameterListHeader",
|
||||
"attrs": {
|
||||
"uid": "plh"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentationParameterListItem",
|
||||
"attrs": {
|
||||
"uid": "pi"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentationParameterListItemHeader",
|
||||
"attrs": {
|
||||
"uid": "pih"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentationParameterListItemHeaderTitle",
|
||||
"attrs": {
|
||||
"uid": "pit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentationParameterListItemHeaderType",
|
||||
"attrs": {
|
||||
"uid": "pity"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "pip"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentationRightSide",
|
||||
"attrs": {
|
||||
"uid": "sr"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "titledCodeBlock",
|
||||
"attrs": {
|
||||
"uid": "tc"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "codeBlockTitle",
|
||||
"attrs": {
|
||||
"uid": "tct"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": {
|
||||
"uid": "tcc",
|
||||
"language": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "figmaBlock",
|
||||
"attrs": {
|
||||
"uid": "fg",
|
||||
"src": null,
|
||||
"width": null,
|
||||
"height": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "imageBlock",
|
||||
"attrs": {
|
||||
"uid": "im",
|
||||
"src": null,
|
||||
"alt": null,
|
||||
"title": null,
|
||||
"width": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
14
server/core/internal/assistant/markup/testdata/empty_parts.xml
vendored
Normal file
14
server/core/internal/assistant/markup/testdata/empty_parts.xml
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<split_doc id="sd">
|
||||
<left>
|
||||
<h1 id="sh"></h1>
|
||||
<p id="sp"></p>
|
||||
<params id="pl">
|
||||
<param id="pi"></param>
|
||||
</params>
|
||||
</left>
|
||||
<right>
|
||||
<pre id="tc" title=""></pre>
|
||||
</right>
|
||||
</split_doc>
|
||||
<figma id="fg"/>
|
||||
<img id="im"/>
|
||||
480
server/core/internal/assistant/markup/testdata/scratch.json
vendored
Normal file
480
server/core/internal/assistant/markup/testdata/scratch.json
vendored
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"uid": "WOAr8ZaSbaFew7_ugmvxK",
|
||||
"level": 2
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "The **my_var_name** flag",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "HdfMSky3maGyef_T4_Vbi"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Second pass with ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "bold_marker",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " and ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "italic",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "italic"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " word and a ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "link",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "link",
|
||||
"attrs": {
|
||||
"href": "https://example.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " end",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": {
|
||||
"uid": "UG8VIL-zNciPGxfp0gPfA",
|
||||
"language": "python"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "my_var = \"a\\\\b\"\ny := c\\d **1**",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentation",
|
||||
"attrs": {
|
||||
"uid": "q_3p-1UdgWnCfyIlv6GA7",
|
||||
"inversed": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentationLeftSide",
|
||||
"attrs": {
|
||||
"uid": "0lPCicY6DRzy6DWCIGiMp"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"uid": "yVBz026-JufM6iiR9TTdW",
|
||||
"level": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Endpoint",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "zYx2SazSIz7QZyQwddDf_"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Does a thing",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentationRightSide",
|
||||
"attrs": {
|
||||
"uid": "m_iE4spKIzb6lXjxVrs-W"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "titledCodeBlock",
|
||||
"attrs": {
|
||||
"uid": "aXFWclOCXyds1ThmflO6M"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "codeBlockTitle",
|
||||
"attrs": {
|
||||
"uid": "MWlt_8Fpgpo3Jv97CK6JD"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "POST /b",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": {
|
||||
"uid": "ENzCHcOZ4uMSwRF6LF5vE",
|
||||
"language": "shell"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "curl -H \"x_api_key: a\\b\" https://example.com/_v1",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "bulletList",
|
||||
"attrs": {
|
||||
"uid": "S8KVkt4dojC640CXXmHl6"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"attrs": {
|
||||
"uid": "KuWZxVFFOnYCceB4xqSFw"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "uQyJIo3o3o3dXnnixiavX"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Item one edited",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "bulletList",
|
||||
"attrs": {
|
||||
"uid": "xvL483uqtOrs1tq_KMIV5"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"attrs": {
|
||||
"uid": "JgTZj0a4e4-BebOkGdx3P"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "24_UMFxVM0aCT7POu_ynY"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Nested",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "blockquote",
|
||||
"attrs": {
|
||||
"uid": "RuJ2JUhwQkBmncEQcBoCT"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "cEGig7n188sAPxRaWzf50"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Quote p1",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "tYzjYlvJFJMHD_IVpQ-KG"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Inserted after bold",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "SHFcJ4_F-8MLNnZsXn_Sa"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Quote p2",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "SGHmcRGGr1bTPIoO9b5Wq"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Set my",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "var_name, then foo_bar",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "underline"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " and “",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "term",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "underline"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "” and ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "plain",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "underline"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " and ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "bold",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " end.",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "0JjNEqS7SYawYOu0VedqG"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Recheck my_var_name, then foo_bar_ and “",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "term",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "underline"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "” and ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "plain",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "underline"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " and ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "bold",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " end.",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "PdZPH3-weP3SPd1tGKU6K"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Underline foo",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "bar",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "underline"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "baz, “",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "quoted",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "underline"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "” and ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "plain",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "underline"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " next to my_var_name.",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "bulletList",
|
||||
"attrs": {
|
||||
"uid": "_zKatPriWHqifKnhtlbuB"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"attrs": {
|
||||
"uid": "Xag_oG5OM6eKNGGWHDKZo"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "iUjgFmPnMqerJGDu6azsq"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Entry with code",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": {
|
||||
"uid": "OdAG6QSLs4e2EFQS5EqZe"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "x := 1",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "6VRJevl29sBVQgICKHej5"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
34
server/core/internal/assistant/markup/testdata/scratch.xml
vendored
Normal file
34
server/core/internal/assistant/markup/testdata/scratch.xml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<h2 id="WOAr8ZaSbaFew7_ugmvxK">The **my_var_name** flag</h2>
|
||||
<p id="HdfMSky3maGyef_T4_Vbi">Second pass with <b>bold_marker</b> and <i>italic</i> word and a <a href="https://example.com">link</a> end</p>
|
||||
<pre id="UG8VIL-zNciPGxfp0gPfA" language="python">my_var = "a\\b"
|
||||
y := c\d **1**</pre>
|
||||
<split_doc id="q_3p-1UdgWnCfyIlv6GA7">
|
||||
<left>
|
||||
<h1 id="yVBz026-JufM6iiR9TTdW">Endpoint</h1>
|
||||
<p id="zYx2SazSIz7QZyQwddDf_">Does a thing</p>
|
||||
</left>
|
||||
<right>
|
||||
<pre id="aXFWclOCXyds1ThmflO6M" title="POST /b" language="shell">curl -H "x_api_key: a\b" https://example.com/_v1</pre>
|
||||
</right>
|
||||
</split_doc>
|
||||
<ul id="S8KVkt4dojC640CXXmHl6">
|
||||
<li id="KuWZxVFFOnYCceB4xqSFw">Item one edited
|
||||
<ul id="xvL483uqtOrs1tq_KMIV5">
|
||||
<li id="JgTZj0a4e4-BebOkGdx3P">Nested</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<blockquote id="RuJ2JUhwQkBmncEQcBoCT">
|
||||
<p id="cEGig7n188sAPxRaWzf50">Quote p1</p>
|
||||
<p id="tYzjYlvJFJMHD_IVpQ-KG">Inserted after bold</p>
|
||||
<p id="SHFcJ4_F-8MLNnZsXn_Sa">Quote p2</p>
|
||||
</blockquote>
|
||||
<p id="SGHmcRGGr1bTPIoO9b5Wq">Set my<u>var_name, then foo_bar</u> and “<u>term</u>” and <u>plain</u> and <b>bold</b> end.</p>
|
||||
<p id="0JjNEqS7SYawYOu0VedqG">Recheck my_var_name, then foo_bar_ and “<u>term</u>” and <u>plain</u> and <b>bold</b> end.</p>
|
||||
<p id="PdZPH3-weP3SPd1tGKU6K">Underline foo<u>bar</u>baz, “<u>quoted</u>” and <u>plain</u> next to my_var_name.</p>
|
||||
<ul id="_zKatPriWHqifKnhtlbuB">
|
||||
<li id="Xag_oG5OM6eKNGGWHDKZo">Entry with code
|
||||
<pre id="OdAG6QSLs4e2EFQS5EqZe">x := 1</pre>
|
||||
</li>
|
||||
</ul>
|
||||
<p id="6VRJevl29sBVQgICKHej5"></p>
|
||||
682
server/core/internal/assistant/markup/testdata/welcome.json
vendored
Normal file
682
server/core/internal/assistant/markup/testdata/welcome.json
vendored
Normal file
|
|
@ -0,0 +1,682 @@
|
|||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentation",
|
||||
"attrs": {
|
||||
"uid": "pDVIfa12tOO7poZBmoKLP",
|
||||
"inversed": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "splitDocumentationLeftSide",
|
||||
"attrs": {
|
||||
"uid": "B6Mf6jxnN2jRR55TwKU5c"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"uid": "CDR7z6fQNMl6Brvzvyvus",
|
||||
"level": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Getting Started",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskList",
|
||||
"attrs": {
|
||||
"uid": "aOB6ca0bN8WZXsRACNBzT"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "TD4irKrS-GWLBiFtSkfwt",
|
||||
"checked": true
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "gMRWtVZsQQH11kdiWA8Oc"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Create your Oxynote account",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "I_kT5w5JIyTAAu9ow9VZ2",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "2KWXCbOkMeShmx492jvSe"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Try the editor",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskList",
|
||||
"attrs": {
|
||||
"uid": "gOnQBksnQTGNMcIcoiBu2"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "3Uu2wEk6NXhgfKWjRJhlC",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "VDAWuzn3ANGgjO4Spw9GU"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Create a new line and and ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "type",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "/",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "code",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " to add blocks (headings, code, checklists, etc.)",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "gnkK1soT9WjwHcxevn7Cy",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "McGqlO7o-_XLu1NzSEp2x"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Use the ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "sidebar",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " to create and organize pages",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "KXs0x_NWUiWq3Km8p7Lf_",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "wD9K2S3o1rnzjG3ZRBHY9"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Use the ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "Main + Draft",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " workflow (the built-in review process)",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskList",
|
||||
"attrs": {
|
||||
"uid": "oAz9cARyIOu8IwLBYLkxF"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "RejEnZTe8X2SUpi3TeLWQ",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "coH_TtLR_qQBmZY_wiGQQ"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Make a page reviewable: ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "click",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " ⋯ (top right) → ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "Make reviewable",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "KzaOz88xAYAEVjFW3HeEG",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "AndkH9gXb06H4P5WY9JJ9"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Edit in Draft, then merge into Main with ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "Approve",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " and ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "Merge",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " buttons",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "pbzPd-Vdch7jcLypiA9PT",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "MuY5K5I9TteooN7O5zOJ_"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Add ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "power blocks",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " to a page",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskList",
|
||||
"attrs": {
|
||||
"uid": "TGtvTcxbLNrqnqOcDVaYd"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "QCezsMtxYgYGFlHJ1AEeQ",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "pGgMJDXD4mrFw8fWLNQer"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Type ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "||",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "code",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " for a split documentation block (clean, Stripe-style docs layout)",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "8m71AA9ZVH1ykFuAX7yL0",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "vSp-sgg8M2EHkkXefPOVS"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Type ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "%%",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "code",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " for a live metrics block (connects to your Prometheus)",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "scH2pn8EuPPTEtZzq7frt",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "2fy64K0QdnS8BB0NvfMWr"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Inspect the metric blocks on this page to see a working setup",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "So-9BWjfxQ_8CWo9MBc_Q",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "PArEUKTuS3IOeDkCzVM7k"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Drag metric blocks around to build a mini dashboard",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "_kqGnHXPkME3EFQRZUcJW",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "XGgXHcU_ul0JGU66UHKVw"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Keep pages trustworthy with ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "freshness hooks",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskList",
|
||||
"attrs": {
|
||||
"uid": "woMF0liZNReyTN3Iwh_o8"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "JX_PDEYcwMXA7HZYcqvZ7",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "PsFZeQj_aF6Lhd0FXB_9v"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Add hooks via ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "Hooks",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " at the top of the page or hover on a block and click the ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "leaf icon",
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold",
|
||||
"attrs": {
|
||||
"uid": null,
|
||||
"nodeCommentId": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " on the left",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "taskItem",
|
||||
"attrs": {
|
||||
"uid": "yrGnBVBqx9mj92PnSxbd1",
|
||||
"checked": false
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "0jsTcBx_fT3YKWqbHXlfM"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Use hooks to track external changes (GitHub pushes, Docker image updates, etc.)",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "splitDocumentationRightSide",
|
||||
"attrs": {
|
||||
"uid": "jy0TUSu5MqIA_f8zzV2VJ"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "metricBlock",
|
||||
"attrs": {
|
||||
"uid": "iSCnvMtVcU1rBtE3anBP1",
|
||||
"title": "Pull Requests Merged on Friday Afternoon",
|
||||
"width": "compact",
|
||||
"queries": [
|
||||
{
|
||||
"name": "Query 1",
|
||||
"query": "engineering_prs_merged_friday_afternoon_count",
|
||||
"legendFormat": "{{confidence}}"
|
||||
}
|
||||
],
|
||||
"unitType": "custom",
|
||||
"timeRange": "last_3_hours",
|
||||
"dataSourceId": "daq1invl0rd000d01ra0",
|
||||
"refreshInterval": "30s",
|
||||
"visualizationType": "line_chart",
|
||||
"baseThresholdColor": "oklch(62.7% .194 149.214)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "metricBlock",
|
||||
"attrs": {
|
||||
"uid": "O9E-oQmk8GWBBlE4udYnF",
|
||||
"title": "\"Works on My Machine\" Rate",
|
||||
"width": "compact",
|
||||
"queries": [
|
||||
{
|
||||
"name": "Query 1",
|
||||
"query": "engineering_works_on_my_machine_rate",
|
||||
"legendFormat": "{{platform}}"
|
||||
}
|
||||
],
|
||||
"unitType": "custom",
|
||||
"timeRange": "last_3_hours",
|
||||
"dataSourceId": "daq1invl0rd000d01ra0",
|
||||
"refreshInterval": "30s",
|
||||
"visualizationType": "line_chart",
|
||||
"baseThresholdColor": "oklch(62.7% .194 149.214)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "CMUMTue1LmJQELbrD7taL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": {
|
||||
"uid": "XMP2zIPCZnRvINIR6iSt0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
40
server/core/internal/assistant/markup/testdata/welcome.xml
vendored
Normal file
40
server/core/internal/assistant/markup/testdata/welcome.xml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<split_doc id="pDVIfa12tOO7poZBmoKLP">
|
||||
<left>
|
||||
<h1 id="CDR7z6fQNMl6Brvzvyvus">Getting Started</h1>
|
||||
<tasks id="aOB6ca0bN8WZXsRACNBzT">
|
||||
<task id="TD4irKrS-GWLBiFtSkfwt" checked="true">Create your Oxynote account</task>
|
||||
<task id="I_kT5w5JIyTAAu9ow9VZ2">Try the editor
|
||||
<tasks id="gOnQBksnQTGNMcIcoiBu2">
|
||||
<task id="3Uu2wEk6NXhgfKWjRJhlC">Create a new line and and <b>type</b> <code>/</code> to add blocks (headings, code, checklists, etc.)</task>
|
||||
<task id="gnkK1soT9WjwHcxevn7Cy">Use the <b>sidebar</b> to create and organize pages</task>
|
||||
</tasks>
|
||||
</task>
|
||||
<task id="KXs0x_NWUiWq3Km8p7Lf_">Use the <b>Main + Draft</b> workflow (the built-in review process)
|
||||
<tasks id="oAz9cARyIOu8IwLBYLkxF">
|
||||
<task id="RejEnZTe8X2SUpi3TeLWQ">Make a page reviewable: <b>click</b> ⋯ (top right) → <b>Make reviewable</b></task>
|
||||
<task id="KzaOz88xAYAEVjFW3HeEG">Edit in Draft, then merge into Main with <b>Approve</b> and <b>Merge</b> buttons</task>
|
||||
</tasks>
|
||||
</task>
|
||||
<task id="pbzPd-Vdch7jcLypiA9PT">Add <b>power blocks</b> to a page
|
||||
<tasks id="TGtvTcxbLNrqnqOcDVaYd">
|
||||
<task id="QCezsMtxYgYGFlHJ1AEeQ">Type <code>||</code> for a split documentation block (clean, Stripe-style docs layout)</task>
|
||||
<task id="8m71AA9ZVH1ykFuAX7yL0">Type <code>%%</code> for a live metrics block (connects to your Prometheus)</task>
|
||||
<task id="scH2pn8EuPPTEtZzq7frt">Inspect the metric blocks on this page to see a working setup</task>
|
||||
<task id="So-9BWjfxQ_8CWo9MBc_Q">Drag metric blocks around to build a mini dashboard</task>
|
||||
</tasks>
|
||||
</task>
|
||||
<task id="_kqGnHXPkME3EFQRZUcJW">Keep pages trustworthy with <b>freshness hooks</b>
|
||||
<tasks id="woMF0liZNReyTN3Iwh_o8">
|
||||
<task id="JX_PDEYcwMXA7HZYcqvZ7">Add hooks via <b>Hooks</b> at the top of the page or hover on a block and click the <b>leaf icon</b> on the left</task>
|
||||
<task id="yrGnBVBqx9mj92PnSxbd1">Use hooks to track external changes (GitHub pushes, Docker image updates, etc.)</task>
|
||||
</tasks>
|
||||
</task>
|
||||
</tasks>
|
||||
</left>
|
||||
<right>
|
||||
<metric id="iSCnvMtVcU1rBtE3anBP1">{"baseThresholdColor":"oklch(62.7% .194 149.214)","dataSourceId":"daq1invl0rd000d01ra0","queries":[{"legendFormat":"{{confidence}}","name":"Query 1","query":"engineering_prs_merged_friday_afternoon_count"}],"refreshInterval":"30s","timeRange":"last_3_hours","title":"Pull Requests Merged on Friday Afternoon","unitType":"custom","visualizationType":"line_chart","width":"compact"}</metric>
|
||||
<metric id="O9E-oQmk8GWBBlE4udYnF">{"baseThresholdColor":"oklch(62.7% .194 149.214)","dataSourceId":"daq1invl0rd000d01ra0","queries":[{"legendFormat":"{{platform}}","name":"Query 1","query":"engineering_works_on_my_machine_rate"}],"refreshInterval":"30s","timeRange":"last_3_hours","title":"\"Works on My Machine\" Rate","unitType":"custom","visualizationType":"line_chart","width":"compact"}</metric>
|
||||
</right>
|
||||
</split_doc>
|
||||
<p id="CMUMTue1LmJQELbrD7taL"></p>
|
||||
<p id="XMP2zIPCZnRvINIR6iSt0"></p>
|
||||
217
server/core/internal/assistant/markup/text.go
Normal file
217
server/core/internal/assistant/markup/text.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// _lineBreak matches a line break with the indentation around it, which
|
||||
// inline text reads as one space, as HTML does.
|
||||
var _lineBreak = regexp.MustCompile(`[ \t]*\r?\n[ \t\r\n]*`)
|
||||
|
||||
// _leadingBreak and _trailingBreak match a line break at the start or
|
||||
// end of a block's text, which is the markup's layout, not text.
|
||||
var (
|
||||
_leadingBreak = regexp.MustCompile(`^[ \t]*\r?\n[ \t\r\n]*`)
|
||||
_trailingBreak = regexp.MustCompile(`\r?\n[ \t\r\n]*$`)
|
||||
)
|
||||
|
||||
// _linkScheme matches the scheme a link starts with.
|
||||
var _linkScheme = regexp.MustCompile(`^([a-z][a-z0-9+.-]*):`)
|
||||
|
||||
// _unsafeLinkSchemes are the schemes a written link may not use: the
|
||||
// editor opens a link as it is, so these would run code or open a local
|
||||
// file on a click. Any other scheme is a link someone may have stored.
|
||||
var _unsafeLinkSchemes = map[string]bool{"javascript": true, "vbscript": true, "data": true, "file": true}
|
||||
|
||||
// run is a stretch of text sharing its visible marks.
|
||||
type run struct {
|
||||
// text is the stretch's text.
|
||||
text string
|
||||
|
||||
// marks are its visible marks, in nesting order.
|
||||
marks []document.Mark
|
||||
}
|
||||
|
||||
// char is one rune of text with its marks.
|
||||
type char struct {
|
||||
// r is the rune.
|
||||
r rune
|
||||
|
||||
// marks are the rune's marks.
|
||||
marks []document.Mark
|
||||
}
|
||||
|
||||
// inline builds the text nodes of a block from text and inline tags.
|
||||
// stored is the content of the block being replaced: its hidden marks,
|
||||
// such as comments, carry over to the text this edit leaves unchanged.
|
||||
func inline(nodes []node, stored []document.Block) ([]document.Block, error) {
|
||||
runs, err := textRuns(nodes, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return textNodes(collapseWhiteSpace(runs), stored), nil
|
||||
}
|
||||
|
||||
// textRuns returns the text under nodes as runs, each with the marks of
|
||||
// the tags around it on top of marks.
|
||||
func textRuns(nodes []node, marks []document.Mark) ([]run, error) {
|
||||
var runs []run
|
||||
|
||||
for _, n := range nodes {
|
||||
if n.name == "" {
|
||||
runs = append(runs, run{text: n.text, marks: marks})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
markType, inlineTag := _tagMarks[n.name]
|
||||
if !inlineTag {
|
||||
return nil, fmt.Errorf("line %d: <%s> is a block; it cannot sit inside text", n.line, n.name)
|
||||
}
|
||||
|
||||
m := document.Mark{Type: markType}
|
||||
|
||||
if n.name == _tagLink {
|
||||
if scheme := linkScheme(n.attrs[_attrHref]); _unsafeLinkSchemes[scheme] {
|
||||
return nil, fmt.Errorf("line %d: a link cannot use %s:; link to a web address instead", n.line, scheme)
|
||||
}
|
||||
|
||||
m.Attrs = document.Attributes{_attrHref: n.attrs[_attrHref]}
|
||||
}
|
||||
|
||||
// a tag inside the same tag adds nothing: a node holds a mark
|
||||
// type once.
|
||||
inner := slices.DeleteFunc(slices.Clone(marks), func(o document.Mark) bool { return o.Type == markType })
|
||||
inner = append(inner, m)
|
||||
|
||||
// the same formatting has to come out as the same mark list,
|
||||
// whatever order the tags were written in.
|
||||
slices.SortStableFunc(inner, func(a, b document.Mark) int { return tagOrder(a.Type) - tagOrder(b.Type) })
|
||||
|
||||
innerRuns, err := textRuns(n.children, inner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runs = append(runs, innerRuns...)
|
||||
}
|
||||
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
// linkScheme returns the lowercase scheme href starts with, or "" for a
|
||||
// path. Browsers ignore white space and control characters in a scheme,
|
||||
// so they are dropped before reading it.
|
||||
func linkScheme(href string) string {
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
if r <= ' ' {
|
||||
return -1
|
||||
}
|
||||
|
||||
return r
|
||||
}, strings.ToLower(href))
|
||||
|
||||
if m := _linkScheme.FindStringSubmatch(cleaned); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// collapseWhiteSpace drops a line break at either end of the text and
|
||||
// reads one inside it, with the indentation around it, as one space.
|
||||
// Spaces before a closing line break are text and stay, like any other
|
||||
// spaces on the text's own line.
|
||||
func collapseWhiteSpace(runs []run) []run {
|
||||
if len(runs) == 0 {
|
||||
return runs
|
||||
}
|
||||
|
||||
runs[0].text = _leadingBreak.ReplaceAllString(runs[0].text, "")
|
||||
runs[len(runs)-1].text = _trailingBreak.ReplaceAllString(runs[len(runs)-1].text, "")
|
||||
|
||||
for i := range runs {
|
||||
runs[i].text = _lineBreak.ReplaceAllString(runs[i].text, " ")
|
||||
}
|
||||
|
||||
return runs
|
||||
}
|
||||
|
||||
// textNodes turns runs into text nodes. The hidden marks of stored are
|
||||
// laid over the text this edit kept: the start and the end the old text
|
||||
// and the new have in common.
|
||||
func textNodes(runs []run, stored []document.Block) []document.Block {
|
||||
var next, old []char
|
||||
|
||||
for _, r := range runs {
|
||||
for _, c := range r.text {
|
||||
next = append(next, char{c, r.marks})
|
||||
}
|
||||
}
|
||||
|
||||
for _, n := range stored {
|
||||
if n.Type != document.BlockNodeText {
|
||||
continue
|
||||
}
|
||||
|
||||
var hidden []document.Mark
|
||||
|
||||
for _, m := range n.Marks {
|
||||
if tagOrder(m.Type) < 0 {
|
||||
hidden = append(hidden, m)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range n.Text {
|
||||
old = append(old, char{c, hidden})
|
||||
}
|
||||
}
|
||||
|
||||
// a rune with no hidden marks shares its run's mark list, so only
|
||||
// the few that carry hidden marks get a list of their own.
|
||||
start := 0
|
||||
for start < len(next) && start < len(old) && next[start].r == old[start].r {
|
||||
if len(old[start].marks) > 0 {
|
||||
next[start].marks = append(slices.Clone(next[start].marks), old[start].marks...)
|
||||
}
|
||||
|
||||
start++
|
||||
}
|
||||
|
||||
for end := 1; end <= len(next)-start && end <= len(old)-start && next[len(next)-end].r == old[len(old)-end].r; end++ {
|
||||
if hidden := old[len(old)-end].marks; len(hidden) > 0 {
|
||||
at := len(next) - end
|
||||
next[at].marks = append(slices.Clone(next[at].marks), hidden...)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
out []document.Block
|
||||
text []rune
|
||||
)
|
||||
|
||||
for i, c := range next {
|
||||
text = append(text, c.r)
|
||||
|
||||
if i+1 < len(next) && slices.EqualFunc(c.marks, next[i+1].marks, document.Mark.Equal) {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, document.Block{Type: document.BlockNodeText, Text: string(text), Marks: c.marks})
|
||||
text = text[:0]
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// tagOrder returns the position of markType's tag in _tagNesting, or -1
|
||||
// for a mark the model does not see.
|
||||
func tagOrder(markType string) int {
|
||||
return slices.IndexFunc(_tagNesting, func(tag string) bool { return _tagMarks[tag] == markType })
|
||||
}
|
||||
138
server/core/internal/assistant/markup/text_test.go
Normal file
138
server/core/internal/assistant/markup/text_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package markup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_inline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodes, err := parse("<p>\n Hi <b>there</b>\n</p>")
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := inline(nodes[0].children, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []document.Block{
|
||||
{Type: document.BlockNodeText, Text: "Hi "},
|
||||
{Type: document.BlockNodeText, Text: "there", Marks: []document.Mark{{Type: "bold"}}},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func Test_textRuns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Markup string
|
||||
Result []run
|
||||
Err error
|
||||
}{
|
||||
"Plain text": {
|
||||
Markup: "<p>a</p>",
|
||||
Result: []run{{text: "a"}},
|
||||
},
|
||||
"Tags in any order give the same marks": {
|
||||
Markup: `<p><i><a href="x">a</a></i></p>`,
|
||||
Result: []run{{text: "a", marks: []document.Mark{{Type: "link", Attrs: document.Attributes{_attrHref: "x"}}, {Type: "italic"}}}},
|
||||
},
|
||||
"Block inside text": {
|
||||
Markup: "<p><hr/></p>",
|
||||
Err: assert.AnError,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodes, err := parse(c.Markup)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := textRuns(nodes[0].children, nil)
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Result, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_linkScheme(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "https", linkScheme("HTTPS://x.test"))
|
||||
assert.Equal(t, "javascript", linkScheme(" java\nscript:alert(1)"))
|
||||
assert.Equal(t, "tel", linkScheme("tel:+1"))
|
||||
assert.Empty(t, linkScheme("/docs/a:b"))
|
||||
assert.Empty(t, linkScheme("#top"))
|
||||
}
|
||||
|
||||
func Test_collapseWhiteSpace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := collapseWhiteSpace([]run{{text: "\n a\n b "}, {text: " c \n "}})
|
||||
assert.Equal(t, []run{{text: "a b "}, {text: " c "}}, got)
|
||||
assert.Empty(t, collapseWhiteSpace(nil))
|
||||
}
|
||||
|
||||
func Test_textNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
comment := document.Mark{Type: "comment", Attrs: document.Attributes{"commentId": "c1"}}
|
||||
bold := document.Mark{Type: "bold"}
|
||||
|
||||
cc := map[string]struct {
|
||||
Runs []run
|
||||
Stored []document.Block
|
||||
Expected []document.Block
|
||||
}{
|
||||
"Nothing stored": {
|
||||
Runs: []run{{text: "ab"}, {text: "c", marks: []document.Mark{bold}}},
|
||||
Expected: []document.Block{{Type: document.BlockNodeText, Text: "ab"}, {Type: document.BlockNodeText, Text: "c", Marks: []document.Mark{bold}}},
|
||||
},
|
||||
"Comment kept on the unchanged start": {
|
||||
Runs: []run{{text: "hello world"}},
|
||||
Stored: []document.Block{{Type: document.BlockNodeText, Text: "hello", Marks: []document.Mark{comment}}, {Type: document.BlockNodeText, Text: " there"}},
|
||||
Expected: []document.Block{
|
||||
{Type: document.BlockNodeText, Text: "hello", Marks: []document.Mark{comment}},
|
||||
{Type: document.BlockNodeText, Text: " world"},
|
||||
},
|
||||
},
|
||||
"Comment kept on the unchanged end": {
|
||||
Runs: []run{{text: "big cat"}},
|
||||
Stored: []document.Block{{Type: document.BlockNodeText, Text: "a "}, {Type: document.BlockNodeText, Text: "cat", Marks: []document.Mark{comment, bold}}},
|
||||
Expected: []document.Block{
|
||||
{Type: document.BlockNodeText, Text: "big "},
|
||||
{Type: document.BlockNodeText, Text: "cat", Marks: []document.Mark{comment}},
|
||||
},
|
||||
},
|
||||
"Stored nodes that are not text are skipped": {
|
||||
Runs: []run{{text: "a"}},
|
||||
Stored: []document.Block{{Type: document.BlockNodeParagraph}},
|
||||
Expected: []document.Block{{Type: document.BlockNodeText, Text: "a"}},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Expected, textNodes(c.Runs, c.Stored))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tagOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, 0, tagOrder("link"))
|
||||
assert.Less(t, tagOrder("bold"), tagOrder("code"))
|
||||
assert.Equal(t, -1, tagOrder("comment"))
|
||||
}
|
||||
|
|
@ -70,12 +70,12 @@ func Test_metrics_observeToolCall(t *testing.T) {
|
|||
rg := prometheus.NewRegistry()
|
||||
m := newMetrics(metricutil.NewFactory("test", rg), "claude")
|
||||
|
||||
m.observeToolCall("read_block", "success", 0.25)
|
||||
m.observeToolCall("get_document", "success", 0.25)
|
||||
|
||||
assert.InEpsilon(t, 1.0, gatherValue(t, rg, "test_assistant_tool_calls_total",
|
||||
map[string]string{"tool": "read_block", "status": "success"}), 0.0001)
|
||||
map[string]string{"tool": "get_document", "status": "success"}), 0.0001)
|
||||
assert.InEpsilon(t, 1.0, gatherValue(t, rg, "test_assistant_tool_call_duration_seconds",
|
||||
map[string]string{"tool": "read_block"}), 0.0001)
|
||||
map[string]string{"tool": "get_document"}), 0.0001)
|
||||
}
|
||||
|
||||
func Test_metrics_recordToolCall(t *testing.T) {
|
||||
|
|
@ -84,14 +84,14 @@ func Test_metrics_recordToolCall(t *testing.T) {
|
|||
rg := prometheus.NewRegistry()
|
||||
m := newMetrics(metricutil.NewFactory("test", rg), "claude")
|
||||
|
||||
m.recordToolCall("read_block", "success")
|
||||
m.recordToolCall("read_block", "success")
|
||||
m.recordToolCall("read_block", "error")
|
||||
m.recordToolCall("get_document", "success")
|
||||
m.recordToolCall("get_document", "success")
|
||||
m.recordToolCall("get_document", "error")
|
||||
|
||||
assert.InEpsilon(t, 2.0, gatherValue(t, rg, "test_assistant_tool_calls_total",
|
||||
map[string]string{"tool": "read_block", "status": "success"}), 0.0001)
|
||||
map[string]string{"tool": "get_document", "status": "success"}), 0.0001)
|
||||
assert.InEpsilon(t, 1.0, gatherValue(t, rg, "test_assistant_tool_calls_total",
|
||||
map[string]string{"tool": "read_block", "status": "error"}), 0.0001)
|
||||
map[string]string{"tool": "get_document", "status": "error"}), 0.0001)
|
||||
}
|
||||
|
||||
func Test_metrics_recordToolDuration(t *testing.T) {
|
||||
|
|
@ -100,11 +100,11 @@ func Test_metrics_recordToolDuration(t *testing.T) {
|
|||
rg := prometheus.NewRegistry()
|
||||
m := newMetrics(metricutil.NewFactory("test", rg), "claude")
|
||||
|
||||
m.recordToolDuration("read_block", 0.25)
|
||||
m.recordToolDuration("read_block", 0.5)
|
||||
m.recordToolDuration("get_document", 0.25)
|
||||
m.recordToolDuration("get_document", 0.5)
|
||||
|
||||
assert.InEpsilon(t, 2.0, gatherValue(t, rg, "test_assistant_tool_call_duration_seconds",
|
||||
map[string]string{"tool": "read_block"}), 0.0001)
|
||||
map[string]string{"tool": "get_document"}), 0.0001)
|
||||
}
|
||||
|
||||
func Test_metrics_recordTokenUsage(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ func Test_NewCompaction(t *testing.T) {
|
|||
"Both middlewares are built": {
|
||||
Model: &mock.ChatModel{},
|
||||
Backend: persist.NewOffload(&persistMock.BlobStore{}),
|
||||
Writes: []string{"insert_block"},
|
||||
Writes: []string{"insert_blocks"},
|
||||
},
|
||||
"Summarization needs a model": {
|
||||
Backend: persist.NewOffload(&persistMock.BlobStore{}),
|
||||
|
|
@ -68,7 +68,7 @@ func Test_newClearRewriter(t *testing.T) {
|
|||
Contains []string
|
||||
}{
|
||||
"Names the tool whose output was cleared": {
|
||||
Writes: []string{"insert_block"},
|
||||
Writes: []string{"insert_blocks"},
|
||||
Msg: schema.AssistantMessage("", []schema.ToolCall{
|
||||
{Function: schema.FunctionCall{Name: "get_document"}},
|
||||
}),
|
||||
|
|
@ -86,10 +86,10 @@ func Test_newClearRewriter(t *testing.T) {
|
|||
Contains: []string{"cleared"},
|
||||
},
|
||||
"Round containing a write call is left untouched": {
|
||||
Writes: []string{"insert_block"},
|
||||
Writes: []string{"insert_blocks"},
|
||||
Msg: schema.AssistantMessage("", []schema.ToolCall{
|
||||
{ID: "1", Function: schema.FunctionCall{Name: "read_block"}},
|
||||
{ID: "2", Function: schema.FunctionCall{Name: "insert_block"}},
|
||||
{ID: "2", Function: schema.FunctionCall{Name: "insert_blocks"}},
|
||||
}),
|
||||
Responses: []*schema.Message{
|
||||
schema.ToolMessage("read result", "1"),
|
||||
|
|
|
|||
|
|
@ -105,14 +105,14 @@ func Test_Observer_WrapInvokableToolCall(t *testing.T) {
|
|||
"Labelled tool is announced before it runs": {
|
||||
Label: "Reading Runbook",
|
||||
Announced: true,
|
||||
Observed: string(tools.NameReadBlock) + ":success",
|
||||
Observed: string(tools.NameGetDocument) + ":success",
|
||||
},
|
||||
"Unlabelled tool runs silently": {
|
||||
Observed: string(tools.NameReadBlock) + ":success",
|
||||
Observed: string(tools.NameGetDocument) + ":success",
|
||||
},
|
||||
"Error returned by the wrapped endpoint": {
|
||||
Err: errors.New("endpoint failed"),
|
||||
Observed: string(tools.NameReadBlock) + ":error",
|
||||
Observed: string(tools.NameGetDocument) + ":error",
|
||||
},
|
||||
"Interrupted tool is not counted": {
|
||||
Interrupt: true,
|
||||
|
|
@ -169,7 +169,7 @@ func Test_Observer_WrapInvokableToolCall(t *testing.T) {
|
|||
wrapped, err := o.WrapInvokableToolCall(
|
||||
context.Background(),
|
||||
endpoint,
|
||||
&adk.ToolContext{Name: string(tools.NameReadBlock), CallID: "1"},
|
||||
&adk.ToolContext{Name: string(tools.NameGetDocument), CallID: "1"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -191,7 +191,7 @@ func Test_Observer_WrapInvokableToolCall(t *testing.T) {
|
|||
assert.Equal(t, "{}", res)
|
||||
|
||||
require.Len(t, labels.LabelCalls(), 1)
|
||||
assert.Equal(t, tools.NameReadBlock, labels.LabelCalls()[0].Name)
|
||||
assert.Equal(t, tools.NameGetDocument, labels.LabelCalls()[0].Name)
|
||||
|
||||
if !c.Announced {
|
||||
assert.Empty(t, writer.WriteJSONCalls())
|
||||
|
|
@ -204,7 +204,7 @@ func Test_Observer_WrapInvokableToolCall(t *testing.T) {
|
|||
|
||||
msg, ok := writer.WriteJSONCalls()[0].Msg.(protocol.ToolStatusMessage)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, string(tools.NameReadBlock), msg.Tool)
|
||||
assert.Equal(t, string(tools.NameGetDocument), msg.Tool)
|
||||
assert.Equal(t, c.Label, msg.Label)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,131 +32,48 @@ Keep replies focused and brief: lead with the answer, keep caveats short, and us
|
|||
|
||||
## Tool use
|
||||
|
||||
Read tools run immediately. Write tools wait for the user's confirmation, and every write in a turn shares one confirmation, so make all the related edits in the same turn. Independent calls also go in one turn. Never invent a uid, document id or parameter value; read it first.
|
||||
|
||||
Read before you write: find the document, read it with get_document, and read a block in full only when you are about to edit its inner structure. Documents have branches, and every read or write of content names one by branch_id: list_documents carries each document's default_branch_id, a search hit carries the branch_id it was found on, and get_document lists every branch of a document with its id. A protected branch is read-only, so write to another branch of the document or say there is none rather than retrying.
|
||||
|
||||
Tags label documents in the sidebar and attach to one branch of a document, so assign_tag and unassign_tag take a branch_id like the content tools; list_tags shows every tag with its id and the documents carrying it, and get_document shows the tags of the branch it reads.
|
||||
|
||||
Hooks are freshness watchers on a branch or on one of its blocks: a scheduled reminder, a GitHub repository, a web page or a container image, each scoring the document down as what it watches changes. list_hooks shows the hooks of a branch with their ids, and create_hook takes the branch_id like the content tools and a block_uid from get_document to anchor to a block.
|
||||
|
||||
The data-source tools are read-only. Answer a question about the data from the query results; add a metric block only when the user asks for a chart, a metric or a dashboard in a document. Discover metric, label and table names with the metadata tools instead of guessing them, and run the query with the chart_type you intend before writing a metric block, so you know it renders.
|
||||
Read tools run immediately. Write tools wait for the user's confirmation, and every write in a turn shares one confirmation, so make all the related edits in the same turn. Independent calls also go in one turn. Never invent an id or parameter value; read it first. A protected branch is read-only, so write to another branch of the document or say there is none rather than retrying.
|
||||
|
||||
`
|
||||
|
||||
// _blockModelSection inlines the full canonical block schema, so the
|
||||
// model has the per-type rules without needing a discovery tool, and
|
||||
// enumerates the minimal-markdown subset used for inline text. Shared
|
||||
// verbatim between the chat prompt and the MCP instructions, so the
|
||||
// two surfaces cannot drift apart.
|
||||
const _blockModelSection = `## Canonical block model
|
||||
// _workflowSection is the cross-tool workflow both surfaces follow.
|
||||
// Shared verbatim between the chat prompt and the MCP instructions.
|
||||
const _workflowSection = `## Workflow
|
||||
|
||||
You read and write blocks in the canonical model below. The editor's own TipTap schema stays hidden behind it, so write these shapes even where you know the editor's. Every block has a type plus that type's own fields. Multi-paragraph content is several blocks, one per paragraph; a newline inside text does not start a new paragraph.
|
||||
Read before you write: find the document with list_documents or search_documents and read it with get_document. Every content tool names a branch by branch_id: listings and search hits carry one, get_document lists every branch, and a protected branch is read-only. Before adding a metric, find names with get_data_source_metadata and run the query through query_data_source with the chart_type you intend, so you know it renders.
|
||||
|
||||
Inline text is a minimal markdown subset: **bold**, *italic*, _underline_, ~~strike~~, backtick code and [label](url) links, with backslash escapes for literal markers (\*, \_, \~, backtick, \[, \\). An underscore inside a word, as in my_var, needs no escape. Headings take plain text, and inside code, titled_code and mermaid blocks text is raw, so no markdown is parsed in either.
|
||||
`
|
||||
|
||||
### Block types
|
||||
// _blockModelSection explains how content is read and written as
|
||||
// markup. The elements themselves are in insert_blocks' description,
|
||||
// which every client shows in full. Shared verbatim between the chat
|
||||
// prompt and the MCP instructions.
|
||||
const _blockModelSection = `## Content
|
||||
|
||||
| type | content fields | attrs |
|
||||
|---|---|---|
|
||||
| paragraph | text | - |
|
||||
| heading | text | level (1, 2, or 3), required |
|
||||
| blockquote | text (single paragraph) | - |
|
||||
| bullet_list | items: [Block] | - |
|
||||
| ordered_list | items: [Block] | - |
|
||||
| task_list | task_items: [{checked, block}] | - |
|
||||
| (any list entry) | a paragraph, plus children: [Block] for what is indented under it | - |
|
||||
| callout | text (shorthand) **or** items: [Block] | icon (defaults to lucide:text) |
|
||||
| code | text (raw) | language (optional, default empty) |
|
||||
| titled_code | text (raw code body) | title (required), language (optional) |
|
||||
| mermaid | text (raw mermaid source) | - |
|
||||
| horizontal_rule | - | - |
|
||||
| image | - | src (required), alt, title, width |
|
||||
| file | - | name, contentType, size, src; read-only |
|
||||
| figma | - | src (required), width, height |
|
||||
| metric | - | chart configuration, see "Metric blocks" |
|
||||
| metric_grid | items: [metric] | - |
|
||||
| split_doc | left: [Block], right: [Block] | inversed (optional) |
|
||||
| split_doc_param_list | header (plain text), params: [{name, type, description}] | - |
|
||||
|
||||
Three types live only inside a container: titled_code and metric go in split_doc's right side (metric also in a metric_grid), and split_doc_param_list goes in split_doc's left side. mermaid is also welcome on split_doc's right side. Every other type is fine at the document root, and a write that puts a block where its type is not allowed is rejected. A file block is a file someone uploaded in the editor: you can read it and move it, but a write that creates or edits one is rejected.
|
||||
|
||||
### Metric blocks
|
||||
|
||||
A metric block renders one chart of one data source. Insert it inside a metric_grid, or in a split_doc's right side; it never sits at the document root. Its attrs are:
|
||||
|
||||
- dataSourceId, the id from list_data_sources. Required for the block to render.
|
||||
- queries, a list of {name, query, legendFormat}. query is PromQL or SQL depending on the data source; legendFormat may be empty. A SQL chart selects a time column aliased "time" plus one or more numeric columns, and may use the $__ macros ($__timeFilter, $__timeGroupAlias).
|
||||
- width, one of compact, standard or wide.
|
||||
- visualizationType, timeRange, refreshInterval, unitType and simulationPreset, each with a fixed set of values that the block tools' schema lists. With unitType custom, put the label in unitCustom. simulationPreset draws a generated series in place of the query's own result, for a block documenting a metric that has no real data yet; omit it, or set it to null, to chart the query.
|
||||
- title, decimals, thresholds ([{value, label, color}]), baseThresholdColor, axisBoundsMin and axisBoundsMax, all optional; omit them to take the block's own defaults.
|
||||
|
||||
### Nested lists
|
||||
|
||||
items are the list's own entries, and an entry is a paragraph. A list, callout or anything else nested under an entry goes in that entry's children, never in the entry itself and never in a second entry. Reads report existing nesting the same way, so a block read with children has to be written back with them or the nested content is lost.
|
||||
|
||||
### Compound blocks
|
||||
|
||||
split_doc and split_doc_param_list are macros: express them with simple fields and the server expands them into the full nested editor structure.
|
||||
|
||||
A split_doc presents concept and example side by side. left starts with a heading at level 1: the panel provides its own visual emphasis, so the heading keeps level 1 whatever the surrounding outline. The rest of left may be paragraphs, lists, callouts, and split_doc_param_lists at the end. right holds only titled_code, metric or mermaid blocks.
|
||||
|
||||
A split_doc_param_list captures a named, typed parameter table: request bodies, function signatures, config keys.
|
||||
|
||||
<example>
|
||||
{
|
||||
"type": "split_doc",
|
||||
"left": [
|
||||
{"type": "heading", "text": "POST /api/auth/login", "attrs": {"level": 1}},
|
||||
{"type": "paragraph", "text": "Issues a session JWT."},
|
||||
{
|
||||
"type": "split_doc_param_list",
|
||||
"header": "Request body",
|
||||
"params": [
|
||||
{"name": "email", "type": "string", "description": "User email"},
|
||||
{"name": "totp", "type": "string?", "description": "Required when 2FA is enabled"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{"type": "titled_code", "attrs": {"title": "POST /api/auth/login"}, "text": "{\n \"email\": \"...\",\n \"password\": \"...\"\n}"}
|
||||
]
|
||||
}
|
||||
</example>
|
||||
get_document returns a document's blocks as XML, one element per block with its id. To change blocks, edit that XML and send it back with replace_blocks, keeping the id of every element that stays: a kept id keeps the block's comments, hooks and files. insert_blocks adds new blocks, many in one call, and its description lists every element. Inline text takes <b>, <i>, <u>, <s>, <code> and <a href>, and headings take plain text.
|
||||
|
||||
`
|
||||
|
||||
// _etiquetteSection codifies how edits are shaped. Shared verbatim
|
||||
// between the chat prompt and the MCP instructions.
|
||||
const _etiquetteSection = `## Edit etiquette
|
||||
const _etiquetteSection = `## Editing
|
||||
|
||||
Make small, targeted edits with the smallest tool that does the job, and send every independent edit of a turn together. Reorder with move_block: the block keeps its uid, and comments, hooks and files hang off that uid, so a copy inserted elsewhere loses them.
|
||||
|
||||
Write each section completely before starting the next. A heading with nothing under it reads as an unfinished document, so add a heading when its content is ready.
|
||||
|
||||
A finished document agrees with itself: names, numbers and claims match across sections. That is the standard the work is held to, so resolve any contradiction you notice before reporting.
|
||||
Make small, targeted edits and send independent ones together. Reorder with move_block, which keeps a block's id, rather than deleting and inserting. Write each section completely before the next, and keep names, numbers and claims consistent across the document.
|
||||
|
||||
`
|
||||
|
||||
// _aestheticsSection states what a readable document looks like.
|
||||
// Shared verbatim between the chat prompt and the MCP instructions.
|
||||
const _aestheticsSection = `## Aesthetics
|
||||
const _aestheticsSection = `## Style
|
||||
|
||||
Documents are for humans to read. Make them read well.
|
||||
|
||||
- The document name is shown above the content, so open with the first real paragraph or section rather than a heading that repeats it.
|
||||
- split_doc draws its own dividers, so leave horizontal_rule out next to it.
|
||||
- For code and titled_code, write language-agnostic pseudo-code or a natural-language sketch unless the document names a stack, and leave language empty rather than guessing one.
|
||||
- A titled_code title names what the code is. For a request or response that is the method and route, such as POST /v1/assets/{id}/due-date/preview, so the body itself does not repeat it.
|
||||
- Use callout for a constraint, warning or gotcha the reader must not miss; ordinary emphasis belongs in prose.
|
||||
- Keep the outline flat: most documents need two or three sections, and a heading earns its place only when the section under it runs longer than a paragraph or two.
|
||||
Documents are for humans to read. Open with the first real paragraph, since the name already shows above the content. Keep the outline flat, with a heading only over more than a paragraph or two. Use a callout for what the reader must not miss. Leave a code language empty unless the document names a stack, and title a titled pre with what the code is, such as POST /v1/assets/{id}.
|
||||
|
||||
`
|
||||
|
||||
// _reminderSection closes the chat prompt with the rules that matter
|
||||
// most, restated in two lines. Models weight the start and the end of
|
||||
// a prompt more than its middle, and the reference material above is
|
||||
// long, so the behaviour rules get a second showing here.
|
||||
// a prompt more than its middle, so the behaviour rules get a second
|
||||
// showing here.
|
||||
const _reminderSection = `## Before you reply
|
||||
|
||||
Read before you write, put every related edit in this turn, ask at most one question, and keep the reply brief.
|
||||
|
|
@ -166,24 +83,22 @@ Read before you write, put every related edit in this turn, ask at most one ques
|
|||
// assistant turn: the Rubber Duck persona and chat tool-use rules,
|
||||
// the sections shared with the MCP instructions, and the closing
|
||||
// reminder.
|
||||
const _basePrompt = _personaSection + _blockModelSection + _etiquetteSection + _aestheticsSection + _reminderSection
|
||||
const _basePrompt = _personaSection + _workflowSection + _blockModelSection + _etiquetteSection + _aestheticsSection + _reminderSection
|
||||
|
||||
// _mcpIntroSection frames the shared sections for an agent connecting
|
||||
// over MCP: unlike the chat model, it knows nothing about Oxynote, and
|
||||
// its own client owns the approval story, so writes apply immediately
|
||||
// instead of waiting on a confirmation.
|
||||
const _mcpIntroSection = `You are connected to Oxynote, a collaborative product for writing technical documentation. The tools operate on the documents of one organisation, which are also listed as resources. Blocks are the unit of content, addressed by uid; comments, hooks and files hang off those uids. Documents are written for humans first, balancing prose with technical detail where it sharpens meaning.
|
||||
|
||||
Read before you write: find the document, read it with get_document, and read a block in full only when you are about to edit its inner structure. Documents have branches, and every read or write of content names one by branch_id: list_documents carries each document's default_branch_id, a search hit carries the branch_id it was found on, and get_document lists every branch of a document with its id. Write tools apply immediately, and a protected branch is read-only, so write to another branch of the document. Tags label documents in the sidebar and attach to one branch of a document, so assign_tag and unassign_tag take a branch_id like the content tools; list_tags shows every tag with its id and the documents carrying it, and get_document shows the tags of the branch it reads. Hooks are freshness watchers on a branch or on one of its blocks; list_hooks shows them with their ids, and create_hook takes the branch_id like the content tools and a block_uid from get_document to anchor to a block. The data-source tools are read-only; discover metric, label and table names with the metadata tools instead of guessing them, and run a query with the chart_type you intend before writing a metric block, so you know it renders.
|
||||
const _mcpIntroSection = `You are connected to Oxynote, a collaborative product for writing technical documentation, with the documents of one organisation. Documents are written for humans first, balancing prose with technical detail where it sharpens meaning. Write tools apply immediately.
|
||||
|
||||
`
|
||||
|
||||
// MCPInstructions returns the text the MCP server hands a connecting
|
||||
// client in its initialize response: the MCP framing followed by the
|
||||
// same block model, edit etiquette, and aesthetics sections the chat
|
||||
// prompt carries.
|
||||
// sections the chat prompt shares. Claude Code shows only the first 2 KB
|
||||
// of it, so it has to stay below that.
|
||||
func MCPInstructions() string {
|
||||
return _mcpIntroSection + _blockModelSection + _etiquetteSection + _aestheticsSection
|
||||
return _mcpIntroSection + _workflowSection + _blockModelSection + _etiquetteSection + _aestheticsSection
|
||||
}
|
||||
|
||||
// buildSystemPrompt assembles the prompt sent on each turn. The
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import (
|
|||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/block"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -32,46 +30,9 @@ func Test_buildSystemPrompt(t *testing.T) {
|
|||
assert.Contains(t, branched, "write with branch_id `draft`")
|
||||
assertHouseStyle(t, branched)
|
||||
|
||||
// documents have branches, and the prompt says so.
|
||||
assert.Contains(t, got, "Documents have branches")
|
||||
assert.Contains(t, got, "assign_tag and unassign_tag take a branch_id")
|
||||
assert.Contains(t, got, "create_hook takes the branch_id like the content tools")
|
||||
|
||||
// width is the one metric enum the prompt is still the only source
|
||||
// for: attrs is a single field shared by every block type, and
|
||||
// width means something else on an image, so the tool schema cannot
|
||||
// publish it. The rest moved there, where Test_attrProps pins them.
|
||||
for _, v := range block.MetricEnums()[document.AttrWidth] {
|
||||
assert.Contains(t, got, v, "width value %q is missing from the prompt", v)
|
||||
}
|
||||
|
||||
for _, attr := range []string{
|
||||
document.AttrDataSourceID,
|
||||
document.AttrVisualizationType,
|
||||
document.AttrQueries,
|
||||
document.AttrTimeRange,
|
||||
document.AttrRefreshInterval,
|
||||
document.AttrThresholds,
|
||||
document.AttrBaseThresholdColor,
|
||||
document.AttrDecimals,
|
||||
document.AttrUnitType,
|
||||
document.AttrUnitCustom,
|
||||
document.AttrAxisBoundsMin,
|
||||
document.AttrAxisBoundsMax,
|
||||
document.AttrSimulationPreset,
|
||||
} {
|
||||
assert.Contains(t, got, attr, "attr %q is missing from the prompt", attr)
|
||||
}
|
||||
|
||||
// the model authors metric blocks now, so the prompt must not still
|
||||
// tell it not to.
|
||||
assert.NotContains(t, got, "do not author")
|
||||
|
||||
// the block model is the only schema the chat model sees, so every
|
||||
// type the tools accept has to be in it.
|
||||
for _, bt := range block.Types() {
|
||||
assert.Contains(t, _blockModelSection, "| "+bt+" |", "block type %q is missing from the prompt", bt)
|
||||
}
|
||||
// the model reads and writes content as markup, keeping ids.
|
||||
assert.Contains(t, got, "replace_blocks, keeping the id of every element that stays")
|
||||
assert.Contains(t, got, "branch_id")
|
||||
|
||||
// the rubber duck stance needs its counterweight: an explicit
|
||||
// request for text is written, not interviewed about.
|
||||
|
|
@ -96,18 +57,18 @@ func Test_MCPInstructions(t *testing.T) {
|
|||
got := MCPInstructions()
|
||||
|
||||
// the shared sections ship verbatim, so the two surfaces describe
|
||||
// the same block model, etiquette, and aesthetics.
|
||||
// the same workflow, content model, etiquette and style.
|
||||
assert.Contains(t, got, _workflowSection)
|
||||
assert.Contains(t, got, _blockModelSection)
|
||||
assert.Contains(t, got, _etiquetteSection)
|
||||
assert.Contains(t, got, _aestheticsSection)
|
||||
|
||||
// reordering is the etiquette rule the surface exists to teach.
|
||||
assert.Contains(t, got, "move_block")
|
||||
// Claude Code shows the first 2 KB of a server's instructions and
|
||||
// drops the rest.
|
||||
assert.Less(t, len(got), 2000)
|
||||
|
||||
// an MCP client learns about branches and the protected rule here.
|
||||
assert.Contains(t, got, "Documents have branches")
|
||||
assert.Contains(t, got, "assign_tag and unassign_tag take a branch_id")
|
||||
assert.Contains(t, got, "create_hook takes the branch_id like the content tools")
|
||||
assert.Contains(t, got, "names a branch by branch_id")
|
||||
assert.Contains(t, got, "protected branch is read-only")
|
||||
|
||||
// the persona and its confirmation flow are the chat surface's;
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ type ToolStatusMessage struct {
|
|||
// Type is always ServerTypeToolStatus.
|
||||
Type ServerMessageType `json:"type"`
|
||||
|
||||
// Tool is the tool name (e.g. "create_document", "read_block").
|
||||
// Tool is the tool name (e.g. "create_document", "get_document").
|
||||
Tool string `json:"tool"`
|
||||
|
||||
// Label is the user-facing description (e.g. "Reading 'Cat
|
||||
|
|
@ -222,7 +222,7 @@ type ConfirmRequest struct {
|
|||
// ConfirmAction summarises one write operation for the confirm UI.
|
||||
type ConfirmAction struct {
|
||||
// Tool is the name of the tool the assistant intends to call
|
||||
// (e.g. "insert_block", "delete_document"). The UI uses this
|
||||
// (e.g. "insert_blocks", "delete_document"). The UI uses this
|
||||
// to pick an affordance (e.g. emphasise deletes).
|
||||
Tool string `json:"tool"`
|
||||
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ var (
|
|||
func stubEditApplier() *toolsMock.EditApplier {
|
||||
return &toolsMock.EditApplier{
|
||||
ApplyFunc: func(_ context.Context, _, _ xid.ID, _ []edit.Operation, _ string, _ bool) (edit.Result, error) {
|
||||
return edit.Result{Applied: 1, Errors: []edit.OpError{}}, nil
|
||||
return edit.Result{Errors: []edit.OpError{}}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -614,11 +614,11 @@ func Test_session_handleConfirmResponse(t *testing.T) {
|
|||
t.Parallel()
|
||||
|
||||
docID := _stubDocID.String()
|
||||
edit1 := `{"document_id":"` + docID + `","branch_id":"` + _stubBranchID.String() + `","block_uid":"a","text":"one"}`
|
||||
edit2 := `{"document_id":"` + docID + `","branch_id":"` + _stubBranchID.String() + `","block_uid":"b","text":"two"}`
|
||||
edit1 := `{"document_id":"` + docID + `","branch_id":"` + _stubBranchID.String() + `","block_uid":"a","content":"<p id=\"a\">one</p>"}`
|
||||
edit2 := `{"document_id":"` + docID + `","branch_id":"` + _stubBranchID.String() + `","block_uid":"b","content":"<p id=\"b\">two</p>"}`
|
||||
|
||||
oneWrite := []*schema.Message{
|
||||
toolCall("1", string(tools.NameUpdateBlockText), edit1),
|
||||
toolCall("1", string(tools.NameReplaceBlocks), edit1),
|
||||
schema.AssistantMessage("updated the intro", nil),
|
||||
}
|
||||
|
||||
|
|
@ -664,8 +664,8 @@ func Test_session_handleConfirmResponse(t *testing.T) {
|
|||
// two separate write batches: the first is confirmed, the
|
||||
// second must ride on the same answer.
|
||||
Responses: []*schema.Message{
|
||||
toolCall("1", string(tools.NameUpdateBlockText), edit1),
|
||||
toolCall("2", string(tools.NameUpdateBlockText), edit2),
|
||||
toolCall("1", string(tools.NameReplaceBlocks), edit1),
|
||||
toolCall("2", string(tools.NameReplaceBlocks), edit2),
|
||||
schema.AssistantMessage("both edits applied", nil),
|
||||
},
|
||||
Approved: true,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -11,6 +11,11 @@ import (
|
|||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// _declinedResult is the tool result reported to the model when the user
|
||||
// refuses a write. It is a normal result rather than an error so the
|
||||
// model can acknowledge the refusal and carry on.
|
||||
const _declinedResult = `{"approved":false,"reason":"user declined"}`
|
||||
|
||||
// SessionKeyAutoApprove marks a turn in which the user answered a
|
||||
// confirmation with "approve all". It lives in the agent session so it
|
||||
// survives the checkpoint written when the turn is interrupted.
|
||||
|
|
@ -122,7 +127,7 @@ func (c *confirming) InvokableRun(
|
|||
}
|
||||
|
||||
if !hasDecision || !decision.Approved {
|
||||
return declinedResult()
|
||||
return _declinedResult, nil
|
||||
}
|
||||
|
||||
return c.einoTool.InvokableRun(ctx, argumentsInJSON, opts...)
|
||||
|
|
@ -147,19 +152,3 @@ func (c *confirming) interrupt(ctx context.Context, args json.RawMessage) (strin
|
|||
|
||||
return "", compose.StatefulInterrupt(ctx, summary, confirmState{})
|
||||
}
|
||||
|
||||
// declinedResult is the tool result reported to the model when the user
|
||||
// refuses a write. It is a normal result rather than an error so the
|
||||
// model can acknowledge the refusal and carry on.
|
||||
func declinedResult() (string, error) {
|
||||
res, err := json.Marshal(map[string]any{
|
||||
"approved": false,
|
||||
"reason": "user declined",
|
||||
})
|
||||
if err != nil {
|
||||
// NOCOV: the payload is a literal of JSON-encodable types.
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(res), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package tools
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
|
|
@ -65,28 +64,28 @@ func Test_confirming_InvokableRun(t *testing.T) {
|
|||
t.Parallel()
|
||||
|
||||
docID := _testDocID
|
||||
writeArgs := `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"a","text":"hi"}`
|
||||
writeArgs := `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"a","content":"<p id=\"a\">hi</p>"}`
|
||||
deleteArgs := `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"a"}`
|
||||
|
||||
// every write tool must ask before it acts. Resuming an approved
|
||||
// turn reruns the tool from the top, so a tool that applied its
|
||||
// edit before interrupting would apply it a second time.
|
||||
cc := map[string]gateCase{
|
||||
"Insert block asks first": {
|
||||
Name: NameInsertBlock,
|
||||
Args: `{` + targetArgs(_stubMainBranchID) + `,"reference_block_uid":"b","position":"after","block":{"type":"paragraph"}}`,
|
||||
"Insert blocks asks first": {
|
||||
Name: NameInsertBlocks,
|
||||
Args: `{` + targetArgs(_stubMainBranchID) + `,"reference_block_uid":"b","position":"after","content":"<p>x</p>"}`,
|
||||
Interrupted: true,
|
||||
},
|
||||
"Delete block asks first": {Name: NameDeleteBlock, Args: deleteArgs, Interrupted: true},
|
||||
"Delete document asks first": {Name: NameDeleteDocument, Args: `{"document_id":"` + docID.String() + `"}`, Interrupted: true},
|
||||
"Update document asks first": {Name: NameUpdateDocument, Args: `{"document_id":"` + docID.String() + `","name":"n"}`, Interrupted: true},
|
||||
"Create document asks first": {Name: NameCreateDocument, Args: `{"name":"n"}`, Interrupted: true},
|
||||
"Update block text asks first": {Name: NameUpdateBlockText, Args: writeArgs, Interrupted: true},
|
||||
"Delete block asks first": {Name: NameDeleteBlock, Args: deleteArgs, Interrupted: true},
|
||||
"Delete document asks first": {Name: NameDeleteDocument, Args: `{"document_id":"` + docID.String() + `"}`, Interrupted: true},
|
||||
"Update document asks first": {Name: NameUpdateDocument, Args: `{"document_id":"` + docID.String() + `","name":"n"}`, Interrupted: true},
|
||||
"Create document asks first": {Name: NameCreateDocument, Args: `{"name":"n"}`, Interrupted: true},
|
||||
"Replace blocks asks first": {Name: NameReplaceBlocks, Args: writeArgs, Interrupted: true},
|
||||
"Approve-all covers a later non-destructive write": {
|
||||
Name: NameUpdateBlockText,
|
||||
Name: NameReplaceBlocks,
|
||||
Args: writeArgs,
|
||||
AutoApprove: true,
|
||||
RespJSON: `{"blocks":[{"uid":"a","kind":"paragraph","text":"hi","depth":0}]}`,
|
||||
RespJSON: `{"content":"<p id=\"a\">hi</p>\n"}`,
|
||||
ApplyCalls: 1,
|
||||
},
|
||||
"Approve-all never covers a destructive write": {
|
||||
|
|
@ -96,33 +95,33 @@ func Test_confirming_InvokableRun(t *testing.T) {
|
|||
Interrupted: true,
|
||||
},
|
||||
"Approved confirmation reruns the tool": {
|
||||
Name: NameUpdateBlockText,
|
||||
Name: NameReplaceBlocks,
|
||||
Args: writeArgs,
|
||||
Decision: &Decision{Approved: true},
|
||||
RespJSON: `{"blocks":[{"uid":"a","kind":"paragraph","text":"hi","depth":0}]}`,
|
||||
RespJSON: `{"content":"<p id=\"a\">hi</p>\n"}`,
|
||||
ApplyCalls: 1,
|
||||
},
|
||||
"Declined confirmation reports the refusal": {
|
||||
Name: NameUpdateBlockText,
|
||||
Name: NameReplaceBlocks,
|
||||
Args: writeArgs,
|
||||
Decision: &Decision{Approved: false},
|
||||
RespJSON: `{"approved":false,"reason":"user declined"}`,
|
||||
},
|
||||
"Resume of another interrupt point re-interrupts": {
|
||||
Name: NameUpdateBlockText,
|
||||
Name: NameReplaceBlocks,
|
||||
Args: writeArgs,
|
||||
ResumeOther: true,
|
||||
Interrupted: true,
|
||||
},
|
||||
"Unreadable arguments are refused, never confirmed": {
|
||||
Name: NameUpdateBlockText,
|
||||
Name: NameReplaceBlocks,
|
||||
Args: `{`,
|
||||
Rejected: "update_block_text: invalid input",
|
||||
Rejected: "replace_blocks: invalid input",
|
||||
},
|
||||
"Missing arguments are refused, never confirmed": {
|
||||
Name: NameUpdateBlockText,
|
||||
Name: NameReplaceBlocks,
|
||||
Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"b"}`,
|
||||
Rejected: "update_block_text: text is required",
|
||||
Rejected: "replace_blocks: content is required",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -334,22 +333,6 @@ func (s *memStore) Set(_ context.Context, id string, data []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func Test_declinedResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := declinedResult()
|
||||
require.NoError(t, err)
|
||||
|
||||
var out struct {
|
||||
Approved bool `json:"approved"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(res), &out))
|
||||
assert.False(t, out.Approved)
|
||||
assert.NotEmpty(t, out.Reason)
|
||||
}
|
||||
|
||||
func Test_autoApproved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -4,11 +4,10 @@ import (
|
|||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/markup"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/internal/tag"
|
||||
"github.com/rs/xid"
|
||||
|
|
@ -27,25 +26,21 @@ func (listDocumentsArgs) Validate() error {
|
|||
}
|
||||
|
||||
// listDocuments returns the organisation's document tree.
|
||||
type listDocuments struct {
|
||||
plainSummary
|
||||
plainTraits
|
||||
plainTitle
|
||||
}
|
||||
type listDocuments struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (listDocuments) Info() Info {
|
||||
return Info{
|
||||
Name: NameListDocuments,
|
||||
Description: "List the organisation's documents as a tree of {id, name, default_branch_id, children}; default_branch_id is what get_document and the block tools take as branch_id for the document's default branch. Use it to find a document by name or to see what sits under a parent; use search_documents when you are looking for content rather than a title. Pass parent_id to get only that document's direct children, or omit it for the whole organisation.",
|
||||
Description: "List the organisation's documents as a tree of {id, name, default_branch_id, children}. Use it to find a document by title; search_documents finds content. parent_id narrows it to that document's children.",
|
||||
Properties: map[string]any{
|
||||
"parent_id": map[string]any{"type": "string", "description": "Optional. Return only the direct children of this document. Omit for the full tree."},
|
||||
"parent_id": map[string]any{"type": "string", "description": "Optional. A document whose direct children to list; omit it for the whole tree."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute lists the documents the model asked for.
|
||||
func (listDocuments) Execute(inp Input) (string, error) {
|
||||
func (listDocuments) Execute(inp *input) (string, error) {
|
||||
var in listDocumentsArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -64,7 +59,7 @@ func (listDocuments) Execute(inp Input) (string, error) {
|
|||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list_documents: fetch tree: %w", err)
|
||||
return "", fmt.Errorf("fetch tree: %w", err)
|
||||
}
|
||||
|
||||
return result(documentTreeResult{
|
||||
|
|
@ -101,52 +96,48 @@ type deletedDocumentResult struct {
|
|||
// getDocumentArgs is what get_document is called with.
|
||||
type getDocumentArgs struct {
|
||||
docTarget
|
||||
|
||||
// BlockUID narrows the content to one block. Optional.
|
||||
BlockUID string `json:"block_uid"`
|
||||
}
|
||||
|
||||
// Validate checks the arguments are complete.
|
||||
func (a getDocumentArgs) Validate() error {
|
||||
return a.validate()
|
||||
}
|
||||
|
||||
// getDocument returns one document: its metadata and the ordered rows
|
||||
// of its content.
|
||||
type getDocument struct {
|
||||
plainSummary
|
||||
plainTraits
|
||||
}
|
||||
// getDocument returns one document: its metadata and its content as
|
||||
// markup.
|
||||
type getDocument struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (getDocument) Info() Info {
|
||||
return Info{
|
||||
Name: NameGetDocument,
|
||||
Description: "Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, name, color}] with color a palette name as list_tags reports it, followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.",
|
||||
Description: "Read one document on one branch: its name, icon, parent_id and updated_at, every branch it has as {id, name, protected, default, updated_at}, the tags on the branch read, and content: its blocks as XML, one element per block with its id, in the format insert_blocks describes. Read a document this way before editing it. A protected branch refuses writes, so write to an unprotected one.",
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"branch_id": map[string]any{"type": "string", "description": "The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write."},
|
||||
"document_id": map[string]any{"type": "string", "description": _documentIDDescription},
|
||||
"branch_id": map[string]any{"type": "string", "description": _branchIDDescription},
|
||||
"block_uid": map[string]any{"type": "string", "description": "Optional. Narrows content to this block and what it holds; an id inside a block, such as a search hit's, reads the block holding it."},
|
||||
},
|
||||
Required: []string{"document_id", "branch_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// Title announces which document is being read.
|
||||
func (getDocument) Title(inp DescribeInput) (string, error) {
|
||||
func (getDocument) Title(inp DescribeInput) string {
|
||||
var in getDocumentArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
return ""
|
||||
}
|
||||
|
||||
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch document: %w", NameGetDocument, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return "Reading " + doc.Title(), nil
|
||||
return "Reading " + doc.Title()
|
||||
}
|
||||
|
||||
// Execute fetches the branch and summarises its content. The document
|
||||
// fetch carries the branch's content, so no second read is needed.
|
||||
func (getDocument) Execute(inp Input) (string, error) {
|
||||
func (getDocument) Execute(inp *input) (string, error) {
|
||||
var in getDocumentArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -155,17 +146,28 @@ func (getDocument) Execute(inp Input) (string, error) {
|
|||
|
||||
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get_document: fetch: %w", err)
|
||||
return "", fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
|
||||
branches, err := inp.FetchDocumentBranches(in.DocumentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get_document: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
tags, err := inp.FetchBranchTags(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get_document: fetch tags: %w", err)
|
||||
return "", fmt.Errorf("fetch tags: %w", err)
|
||||
}
|
||||
|
||||
blocks := doc.Content.Content
|
||||
|
||||
if in.BlockUID != "" {
|
||||
b, ok := markup.Readable(blocks, in.BlockUID)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("block %s: %w", in.BlockUID, errUnknownBlock)
|
||||
}
|
||||
|
||||
blocks = []document.Block{b}
|
||||
}
|
||||
|
||||
out := documentResult{
|
||||
|
|
@ -173,12 +175,10 @@ func (getDocument) Execute(inp Input) (string, error) {
|
|||
Name: doc.DocumentName,
|
||||
Icon: doc.Icon,
|
||||
ParentID: doc.ParentID,
|
||||
Protected: doc.Protected,
|
||||
UpdatedAt: doc.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
Branch: branchInfo{ID: doc.BranchID, Name: doc.BranchName, Protected: doc.Protected, Default: doc.Default},
|
||||
Branches: make([]branchInfo, 0, len(branches)),
|
||||
Tags: make([]documentTag, 0, len(tags)),
|
||||
Blocks: walkDocForAssistant(doc.Content.Content),
|
||||
Content: markup.Render(blocks),
|
||||
}
|
||||
|
||||
for _, tg := range tags {
|
||||
|
|
@ -212,23 +212,18 @@ type documentResult struct {
|
|||
// ParentID is the document's parent, absent at the root.
|
||||
ParentID null.Value[xid.ID] `json:"parent_id,omitzero"`
|
||||
|
||||
// Protected indicates the branch read refuses every write.
|
||||
Protected bool `json:"protected"`
|
||||
|
||||
// UpdatedAt is when the branch last changed, as RFC3339.
|
||||
// UpdatedAt is when the branch read last changed, as RFC3339.
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
|
||||
// Branch is the branch the rows were read from.
|
||||
Branch branchInfo `json:"branch"`
|
||||
|
||||
// Branches is every branch the document has.
|
||||
// Branches is every branch the document has, the one read among
|
||||
// them.
|
||||
Branches []branchInfo `json:"branches"`
|
||||
|
||||
// Tags is every tag the branch read carries.
|
||||
Tags []documentTag `json:"tags"`
|
||||
|
||||
// Blocks is the branch's content, one row per block.
|
||||
Blocks []docSummaryEntry `json:"blocks"`
|
||||
// Content is the branch's blocks, or the one asked for, as markup.
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// documentTag is one tag row of get_document.
|
||||
|
|
@ -294,32 +289,17 @@ func (a createDocumentArgs) Validate() error {
|
|||
func (createDocument) Info() Info {
|
||||
return Info{
|
||||
Name: NameCreateDocument,
|
||||
Description: "Create a new document and return {document_id, branch_id}, the branch_id being its default branch. The document starts with a single empty paragraph, so follow up with insert_block calls (position end, that branch_id) in the same turn to fill it. Omit parent_id to create it at the organisation root.",
|
||||
Traits: Traits{Write: true},
|
||||
Description: "Create a document and return {document_id, branch_id}, the branch_id being its default branch. It starts with one empty paragraph; fill it with insert_blocks at position end on that branch.",
|
||||
Properties: map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "Display name for the new document."},
|
||||
document.AttrIcon: map[string]any{"type": "string", "description": "Iconify identifier, as \"collection:name\". The product's own icons are MingCute fills (e.g. \"mingcute:file-code-fill\"); prefer one so the document matches the rest of the sidebar. Defaults to \"mingcute:document-2-fill\" when empty."},
|
||||
"parent_id": map[string]any{"type": "string", "description": "Optional parent document id. Omit to create at the org root."},
|
||||
"name": map[string]any{"type": "string", "description": "The display name."},
|
||||
document.AttrIcon: map[string]any{"type": "string", "description": "Optional. An Iconify id; the sidebar uses MingCute fills, such as mingcute:file-code-fill. Defaults to mingcute:document-2-fill."},
|
||||
"parent_id": map[string]any{"type": "string", "description": "Optional. The parent document id; omit it for the organisation root."},
|
||||
},
|
||||
Required: []string{"name"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a write.
|
||||
func (createDocument) Traits() Traits {
|
||||
return Traits{Write: true}
|
||||
}
|
||||
|
||||
// Title announces the document being created.
|
||||
func (createDocument) Title(inp DescribeInput) (string, error) {
|
||||
var in createDocumentArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Creating %q", in.Name), nil
|
||||
}
|
||||
|
||||
// Summary describes the document the model wants to create.
|
||||
func (createDocument) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in createDocumentArgs
|
||||
|
|
@ -337,7 +317,7 @@ func (createDocument) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
}
|
||||
|
||||
// Execute creates the document, its maintainer row and its search job.
|
||||
func (createDocument) Execute(inp Input) (string, error) {
|
||||
func (createDocument) Execute(inp *input) (string, error) {
|
||||
var in createDocumentArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -353,14 +333,12 @@ func (createDocument) Execute(inp Input) (string, error) {
|
|||
Name: in.Name,
|
||||
Icon: icon,
|
||||
ParentID: in.ParentID,
|
||||
}, inp.OrganizationID(), inp.UserID())
|
||||
}, inp.orgID, inp.userID)
|
||||
|
||||
if err := inp.CreateDocument(doc); err != nil {
|
||||
return "", fmt.Errorf("create_document: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
inp.NotifyTreeChange(doc.ParentID)
|
||||
|
||||
return result(createdDocumentResult{
|
||||
DocumentID: doc.ID,
|
||||
BranchID: doc.BranchID,
|
||||
|
|
@ -389,34 +367,13 @@ type deleteDocument struct{}
|
|||
func (deleteDocument) Info() Info {
|
||||
return Info{
|
||||
Name: NameDeleteDocument,
|
||||
Traits: Traits{Write: true, Destructive: true},
|
||||
Description: "Delete a document and every document nested under it. The whole subtree goes and cannot be restored, so check the tree with list_documents first, and use update_document when the aim is to relocate rather than remove. Returns {document_id, deleted}.",
|
||||
Properties: map[string]any{"document_id": map[string]any{"type": "string", "description": "The document id to delete."}},
|
||||
Properties: map[string]any{"document_id": map[string]any{"type": "string", "description": _documentIDDescription}},
|
||||
Required: []string{"document_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a destructive write, which stays outside any "approve
|
||||
// all" answer.
|
||||
func (deleteDocument) Traits() Traits {
|
||||
return Traits{Write: true, Destructive: true}
|
||||
}
|
||||
|
||||
// Title announces which document is being deleted.
|
||||
func (deleteDocument) Title(inp DescribeInput) (string, error) {
|
||||
var in deleteDocumentArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
doc, err := inp.FetchDocument(in.DocumentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch document: %w", NameDeleteDocument, err)
|
||||
}
|
||||
|
||||
return "Deleting " + doc.DocumentName, nil
|
||||
}
|
||||
|
||||
// Summary describes the document the model wants to delete.
|
||||
func (deleteDocument) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in deleteDocumentArgs
|
||||
|
|
@ -427,7 +384,7 @@ func (deleteDocument) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
|
||||
doc, err := inp.FetchDocument(in.DocumentID)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameDeleteDocument, err)
|
||||
return ActionSummary{}, fmt.Errorf("fetch document: %w", err)
|
||||
}
|
||||
|
||||
summary := "Delete " + doc.DocumentName
|
||||
|
|
@ -435,7 +392,7 @@ func (deleteDocument) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
// the delete cascades, so a card naming only the document would
|
||||
// have the user approve a subtree they were never shown.
|
||||
if n, err := inp.DescendantCount(in.DocumentID); err == nil && n > 0 {
|
||||
summary += fmt.Sprintf(" and the %s nested under it", pluralPages(n))
|
||||
summary += fmt.Sprintf(" and the %s nested under it", countPhrase(n, "page"))
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
|
|
@ -446,37 +403,18 @@ func (deleteDocument) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// pluralPages renders a nested-document count for the confirm card.
|
||||
func pluralPages(n int) string {
|
||||
if n == 1 {
|
||||
return "1 page"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d pages", n)
|
||||
}
|
||||
|
||||
// Execute deletes the document and refreshes the tree.
|
||||
func (deleteDocument) Execute(inp Input) (string, error) {
|
||||
func (deleteDocument) Execute(inp *input) (string, error) {
|
||||
var in deleteDocumentArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// capture the parent before the row goes away so we can scope the
|
||||
// tree-change notification to the affected subtree.
|
||||
var parentID null.Value[xid.ID]
|
||||
|
||||
if doc, ferr := inp.FetchDocument(in.DocumentID); ferr == nil && doc != nil {
|
||||
parentID = doc.ParentID
|
||||
}
|
||||
|
||||
if err := inp.DeleteDocument(in.DocumentID); err != nil {
|
||||
return "", fmt.Errorf("delete_document: delete: %w", err)
|
||||
return "", fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
|
||||
inp.NotifyTreeChange(parentID)
|
||||
|
||||
return result(deletedDocumentResult{
|
||||
DocumentID: in.DocumentID,
|
||||
Deleted: true,
|
||||
|
|
@ -507,7 +445,7 @@ func (a updateDocumentArgs) Validate() error {
|
|||
}
|
||||
|
||||
if a.Name == "" && a.Icon == "" && a.ParentID == nil {
|
||||
return errors.New("update_document needs at least one of name, icon or parent_id")
|
||||
return errors.New("give at least one of name, icon or parent_id")
|
||||
}
|
||||
|
||||
if a.ParentID != nil && *a.ParentID != "" {
|
||||
|
|
@ -568,38 +506,18 @@ type updateDocument struct{}
|
|||
func (updateDocument) Info() Info {
|
||||
return Info{
|
||||
Name: NameUpdateDocument,
|
||||
Description: "Change a document's name, icon or place in the tree, any combination in one call; content is untouched. Give only the fields to change: name is the new display name, icon an Iconify identifier as \"collection:name\" (the product's own icons are MingCute fills, e.g. \"mingcute:rocket-fill\", so prefer one to match the sidebar), and parent_id the new parent, or an empty string for the organisation root. A call with none of the three is refused, and a parent that is the document itself or one of its descendants fails. Returns {document_id} with the fields that changed.",
|
||||
Traits: Traits{Write: true},
|
||||
Description: "Change a document's name, icon or parent; give only what changes, and a call changing nothing is refused. Content is untouched, and a parent that is the document itself or one of its descendants is refused. Returns {document_id} with what changed.",
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"name": map[string]any{"type": "string", "description": "Optional. The new display name; omit to keep the current one."},
|
||||
document.AttrIcon: map[string]any{"type": "string", "description": "Optional. The new icon identifier; omit to keep the current one."},
|
||||
"parent_id": map[string]any{"type": "string", "description": "Optional. The new parent document id, or an empty string to move the document to the organisation root; omit to leave it where it is."},
|
||||
"document_id": map[string]any{"type": "string", "description": _documentIDDescription},
|
||||
"name": map[string]any{"type": "string", "description": "Optional. The new display name."},
|
||||
document.AttrIcon: map[string]any{"type": "string", "description": "Optional. An Iconify id; the sidebar uses MingCute fills, such as mingcute:rocket-fill."},
|
||||
"parent_id": map[string]any{"type": "string", "description": "Optional. The new parent document id, or an empty string for the organisation root."},
|
||||
},
|
||||
Required: []string{"document_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a write.
|
||||
func (updateDocument) Traits() Traits {
|
||||
return Traits{Write: true}
|
||||
}
|
||||
|
||||
// Title announces which document is being updated.
|
||||
func (updateDocument) Title(inp DescribeInput) (string, error) {
|
||||
var in updateDocumentArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
doc, err := inp.FetchDocument(in.DocumentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch document: %w", NameUpdateDocument, err)
|
||||
}
|
||||
|
||||
return "Updating " + doc.DocumentName, nil
|
||||
}
|
||||
|
||||
// Summary lists exactly the changes the model asked for.
|
||||
func (updateDocument) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in updateDocumentArgs
|
||||
|
|
@ -610,28 +528,20 @@ func (updateDocument) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
|
||||
doc, err := inp.FetchDocument(in.DocumentID)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameUpdateDocument, err)
|
||||
}
|
||||
|
||||
changes := in.changes(doc.DocumentName)
|
||||
summary := strings.ToUpper(changes[0][:1]) + changes[0][1:]
|
||||
|
||||
if len(changes) > 1 {
|
||||
summary = fmt.Sprintf("%s and %s", strings.Join(changes[:len(changes)-1], ", "), changes[len(changes)-1])
|
||||
summary = strings.ToUpper(summary[:1]) + summary[1:]
|
||||
return ActionSummary{}, fmt.Errorf("fetch document: %w", err)
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
Tool: NameUpdateDocument,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: summary,
|
||||
Summary: sentence(in.changes(doc.DocumentName)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute applies the name and icon through the live document, then
|
||||
// re-parents it, and tells the tree subscribers what moved.
|
||||
func (updateDocument) Execute(inp Input) (string, error) {
|
||||
func (updateDocument) Execute(inp *input) (string, error) {
|
||||
var in updateDocumentArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -640,45 +550,19 @@ func (updateDocument) Execute(inp Input) (string, error) {
|
|||
|
||||
doc, err := inp.FetchDocument(in.DocumentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("update_document: fetch document: %w", err)
|
||||
return "", fmt.Errorf("fetch document: %w", err)
|
||||
}
|
||||
|
||||
var ops []edit.Operation
|
||||
|
||||
if in.Name != "" {
|
||||
ops = append(ops, edit.SetName(in.Name))
|
||||
if err := inp.RenameDocument(doc, in.Name, in.Icon); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if in.Icon != "" {
|
||||
ops = append(ops, edit.SetIcon(in.Icon))
|
||||
}
|
||||
|
||||
if len(ops) > 0 {
|
||||
if err := inp.ApplyEdit(in.DocumentID, doc.BranchID, ops); err != nil {
|
||||
if parent, moved := in.parent(); moved {
|
||||
if err := inp.MoveDocument(doc, parent); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
parent, moved := in.parent()
|
||||
if moved {
|
||||
if err := inp.MoveDocument(doc, parent); err != nil {
|
||||
return "", fmt.Errorf("update_document: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// a rename or icon change shows in the document's own row; a move
|
||||
// changes the shape of the source and destination subtrees, and
|
||||
// when those are the same parent one notification covers both.
|
||||
switch {
|
||||
case moved && doc.ParentID != parent:
|
||||
inp.NotifyTreeChange(doc.ParentID)
|
||||
inp.NotifyTreeChange(parent)
|
||||
case moved:
|
||||
inp.NotifyTreeChange(parent)
|
||||
default:
|
||||
inp.NotifyTreeChangeForDocument(in.DocumentID)
|
||||
}
|
||||
|
||||
return result(updatedDocumentResult(in))
|
||||
}
|
||||
|
||||
|
|
@ -711,10 +595,6 @@ type docTreeNode struct {
|
|||
// summariesToTree converts the document package's nested Summary tree
|
||||
// into the snake_case shape returned by list_documents.
|
||||
func summariesToTree(ss document.Summaries) []docTreeNode {
|
||||
if len(ss) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]docTreeNode, 0, len(ss))
|
||||
|
||||
for _, s := range ss {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ func Test_listDocuments_Info(t *testing.T) {
|
|||
|
||||
info := listDocuments{}.Info()
|
||||
|
||||
assert.Equal(t, Traits{}, info.Traits)
|
||||
|
||||
assert.Equal(t, NameListDocuments, info.Name)
|
||||
assert.NotEmpty(t, info.Description)
|
||||
assert.Contains(t, info.Properties, "parent_id")
|
||||
|
|
@ -58,20 +60,6 @@ func Test_listDocuments_Info(t *testing.T) {
|
|||
assert.Empty(t, info.Required)
|
||||
}
|
||||
|
||||
func Test_listDocuments_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, Traits{}, listDocuments{}.Traits())
|
||||
}
|
||||
|
||||
func Test_listDocuments_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := listDocuments{}.Title(testInput(testDeps(nil, nil, nil), NameListDocuments, `{}`))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func Test_listDocuments_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -162,57 +150,37 @@ func Test_listDocuments_Execute(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_getDocumentArgs_Validate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertValidate(t, getDocumentArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID}, map[string]Args{
|
||||
"document_id": getDocumentArgs{},
|
||||
})
|
||||
}
|
||||
|
||||
func Test_getDocument_Info(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
info := getDocument{}.Info()
|
||||
|
||||
assert.Equal(t, Traits{}, info.Traits)
|
||||
|
||||
assert.Equal(t, NameGetDocument, info.Name)
|
||||
assert.NotEmpty(t, info.Description)
|
||||
assert.Equal(t, []string{"document_id", "branch_id"}, info.Required)
|
||||
assert.Contains(t, info.Properties, "branch_id")
|
||||
}
|
||||
|
||||
func Test_getDocument_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, Traits{}, getDocument{}.Traits())
|
||||
}
|
||||
|
||||
func Test_getDocument_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := getDocument{}.Title(testInput(
|
||||
assert.Equal(t, "Reading Runbook", getDocument{}.Title(testInput(
|
||||
testDeps(stubDocumentDB(), nil, nil), NameGetDocument,
|
||||
`{`+targetArgs(_stubMainBranchID)+`}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Reading Runbook", got)
|
||||
)))
|
||||
|
||||
// a branch other than the default one is named.
|
||||
got, err = getDocument{}.Title(testInput(
|
||||
assert.Equal(t, "Reading Runbook on branch draft", getDocument{}.Title(testInput(
|
||||
testDeps(stubDocumentDB(), nil, nil), NameGetDocument,
|
||||
`{`+targetArgs(_stubBranchID)+`}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Reading Runbook on branch draft", got)
|
||||
)))
|
||||
|
||||
// the document it names has to resolve; the failure is passed on
|
||||
// rather than described around.
|
||||
_, err = getDocument{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameGetDocument, requiredArgs(t, NameGetDocument)))
|
||||
require.Error(t, err)
|
||||
|
||||
// unreadable arguments are refused before anything is looked up.
|
||||
_, err = getDocument{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameGetDocument, `{`))
|
||||
require.Error(t, err)
|
||||
// a document that does not resolve, or arguments that cannot be
|
||||
// read, make no line.
|
||||
assert.Empty(t, getDocument{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameGetDocument, requiredArgs(t, NameGetDocument))))
|
||||
assert.Empty(t, getDocument{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameGetDocument, `{`)))
|
||||
}
|
||||
|
||||
func Test_getDocument_Execute(t *testing.T) {
|
||||
|
|
@ -283,26 +251,23 @@ func Test_getDocument_Execute(t *testing.T) {
|
|||
Args: `{` + targetArgs(_stubMainBranchID) + `}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Metadata, branches and rows are returned": {
|
||||
"Metadata, branches and content are returned": {
|
||||
DB: nested,
|
||||
Args: `{` + targetArgs(_stubMainBranchID) + `}`,
|
||||
Contains: []string{
|
||||
`"name":"Runbook"`,
|
||||
`"icon":"lucide:rocket"`,
|
||||
`"parent_id":"` + parentID.String() + `"`,
|
||||
`"protected":true`,
|
||||
`"branch":{"id":"` + _stubMainBranchID.String() + `","name":"main","protected":true,"default":true}`,
|
||||
`"branches":[{"id":"` + _stubMainBranchID.String() + `","name":"main","protected":false,"default":true,"updated_at":`,
|
||||
`{"id":"` + _stubBranchID.String() + `","name":"draft","protected":false,"default":false,"updated_at":`,
|
||||
`"tags":[]`,
|
||||
`"uid":"a"`,
|
||||
`"kind":"paragraph"`,
|
||||
`"content":"<p id=\"a\">`,
|
||||
},
|
||||
},
|
||||
"Another branch is read": {
|
||||
DB: stubContentDB(nil),
|
||||
Args: `{` + targetArgs(_stubBranchID) + `}`,
|
||||
Contains: []string{`"branch":{"id":"` + _stubBranchID.String() + `","name":"draft","protected":false,"default":false}`, `"uid":"a"`},
|
||||
Contains: []string{`<p id=\"a\">hello</p>`},
|
||||
},
|
||||
"Tags of the branch read are listed": {
|
||||
DB: tagged,
|
||||
|
|
@ -311,11 +276,22 @@ func Test_getDocument_Execute(t *testing.T) {
|
|||
`"tags":[{"id":"` + _testTagID.String() + `","name":"Production","color":"green"}]`,
|
||||
},
|
||||
},
|
||||
"Block narrows the content to it": {
|
||||
DB: stubContentDB(nil),
|
||||
Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"cp"}`,
|
||||
Contains: []string{`"content":"<p id=\"cp\">inside</p>\n"`},
|
||||
Omits: []string{`id=\"a\"`},
|
||||
},
|
||||
"Block the document does not hold": {
|
||||
DB: stubContentDB(nil),
|
||||
Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"zzz"}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Root document omits the parent": {
|
||||
DB: stubContentDB(nil),
|
||||
Args: `{` + targetArgs(_stubMainBranchID) + `}`,
|
||||
Contains: []string{`"name":"Runbook"`, `"protected":false`},
|
||||
Omits: []string{`"parent_id"`, `"branch_id"`},
|
||||
Contains: []string{`"name":"Runbook"`},
|
||||
Omits: []string{`"parent_id"`, `"branch_id"`, `"branch":`},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -354,30 +330,13 @@ func Test_createDocument_Info(t *testing.T) {
|
|||
|
||||
info := createDocument{}.Info()
|
||||
|
||||
assert.Equal(t, Traits{Write: true}, info.Traits)
|
||||
|
||||
assert.Equal(t, NameCreateDocument, info.Name)
|
||||
assert.Equal(t, []string{"name"}, info.Required)
|
||||
assert.Contains(t, info.Properties, document.AttrIcon)
|
||||
}
|
||||
|
||||
func Test_createDocument_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, Traits{Write: true}, createDocument{}.Traits())
|
||||
}
|
||||
|
||||
func Test_createDocument_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := testDeps(nil, nil, nil)
|
||||
|
||||
got, err := createDocument{}.Title(testInput(d, NameCreateDocument, `{"name":"Runbook"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `Creating "Runbook"`, got)
|
||||
|
||||
_, err = createDocument{}.Title(testInput(d, NameCreateDocument, `{}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_createDocument_Summary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -477,38 +436,14 @@ func Test_deleteDocument_Info(t *testing.T) {
|
|||
|
||||
info := deleteDocument{}.Info()
|
||||
|
||||
// a delete stays outside any "approve all" answer.
|
||||
assert.Equal(t, Traits{Write: true, Destructive: true}, info.Traits)
|
||||
|
||||
assert.Equal(t, NameDeleteDocument, info.Name)
|
||||
assert.Contains(t, info.Description, "cannot be restored")
|
||||
assert.Equal(t, []string{"document_id"}, info.Required)
|
||||
}
|
||||
|
||||
func Test_deleteDocument_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// a delete stays outside any "approve all" answer.
|
||||
assert.Equal(t, Traits{Write: true, Destructive: true}, deleteDocument{}.Traits())
|
||||
}
|
||||
|
||||
func Test_deleteDocument_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := deleteDocument{}.Title(testInput(
|
||||
testDeps(stubDocumentDB(), nil, nil), NameDeleteDocument,
|
||||
`{"document_id":"`+_testDocID.String()+`"}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Deleting Runbook", got)
|
||||
|
||||
// the document it names has to resolve; the failure is passed on
|
||||
// rather than described around.
|
||||
_, err = deleteDocument{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameDeleteDocument, requiredArgs(t, NameDeleteDocument)))
|
||||
require.Error(t, err)
|
||||
|
||||
// unreadable arguments are refused before anything is looked up.
|
||||
_, err = deleteDocument{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameDeleteDocument, `{`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_deleteDocument_Summary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -593,7 +528,7 @@ func Test_deleteDocument_Execute(t *testing.T) {
|
|||
// with deleteErr.
|
||||
stubDeleteDB := func(db *DBMock, deleteErr error) *DBMock {
|
||||
if db == nil {
|
||||
db = &DBMock{}
|
||||
db = stubDocumentDB()
|
||||
}
|
||||
|
||||
db.BeginTxFunc = func(_ context.Context, dest any) error {
|
||||
|
|
@ -751,6 +686,8 @@ func Test_updateDocument_Info(t *testing.T) {
|
|||
|
||||
info := updateDocument{}.Info()
|
||||
|
||||
assert.Equal(t, Traits{Write: true}, info.Traits)
|
||||
|
||||
assert.Equal(t, NameUpdateDocument, info.Name)
|
||||
assert.Equal(t, []string{"document_id"}, info.Required)
|
||||
assert.Contains(t, info.Properties, "name")
|
||||
|
|
@ -758,33 +695,6 @@ func Test_updateDocument_Info(t *testing.T) {
|
|||
assert.Contains(t, info.Properties, "parent_id")
|
||||
}
|
||||
|
||||
func Test_updateDocument_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, Traits{Write: true}, updateDocument{}.Traits())
|
||||
}
|
||||
|
||||
func Test_updateDocument_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := updateDocument{}.Title(testInput(
|
||||
testDeps(stubDocumentDB(), nil, nil), NameUpdateDocument,
|
||||
`{"document_id":"`+_testDocID.String()+`","name":"Playbook"}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updating Runbook", got)
|
||||
|
||||
// the document it names has to resolve; the failure is passed on
|
||||
// rather than described around.
|
||||
_, err = updateDocument{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameUpdateDocument,
|
||||
`{"document_id":"`+_testDocID.String()+`","name":"Playbook"}`))
|
||||
require.Error(t, err)
|
||||
|
||||
// unreadable arguments are refused before anything is looked up.
|
||||
_, err = updateDocument{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameUpdateDocument, `{`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_updateDocument_Summary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -943,7 +853,7 @@ func Test_updateDocument_Execute(t *testing.T) {
|
|||
Applier: stubApplier(),
|
||||
Args: doc + `,"name":"Playbook","parent_id":"` + parentID.String() + `"}`,
|
||||
Ops: 1,
|
||||
Notify: 2,
|
||||
Notify: 3,
|
||||
Result: `{"document_id":"` + _testDocID.String() + `","name":"Playbook","parent_id":"` + parentID.String() + `"}`,
|
||||
},
|
||||
}
|
||||
|
|
@ -982,7 +892,7 @@ func Test_updateDocument_Execute(t *testing.T) {
|
|||
func Test_summariesToTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Nil(t, summariesToTree(nil))
|
||||
assert.Equal(t, []docTreeNode{}, summariesToTree(nil))
|
||||
|
||||
id := xid.New()
|
||||
child := xid.New()
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/block"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// _walkerKinds names the list wrappers the canonical model has no type
|
||||
// for. The summary reports them anyway, since it describes the hierarchy
|
||||
// the AI targets its edits at.
|
||||
var _walkerKinds = map[document.BlockNodeType]string{
|
||||
document.BlockNodeListItem: "list_item",
|
||||
document.BlockNodeTaskItem: "task_item",
|
||||
}
|
||||
|
||||
// docSummaryEntry is one row in the assistant-facing document
|
||||
// summary. Every directly editable block surfaces with its uid so
|
||||
// the assistant can target edits or deletes precisely.
|
||||
type docSummaryEntry struct {
|
||||
// UID is the block's uid attribute.
|
||||
UID string `json:"uid"`
|
||||
|
||||
// Kind is the canonical block kind ("paragraph", "heading",
|
||||
// "callout", "bullet_list", "list_item", "split_doc", …). For
|
||||
// nested children we emit the inner kind (e.g. "list_item")
|
||||
// alongside ParentUID so the AI can reason about hierarchy.
|
||||
Kind string `json:"kind"`
|
||||
|
||||
// Text is the flattened text of the block's subtree, useful for
|
||||
// the AI to identify the block by its content.
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
// Depth is the nesting level in this summary (0 = top-level
|
||||
// document children).
|
||||
Depth int `json:"depth"`
|
||||
|
||||
// ParentUID is the uid of the enclosing block when the entry
|
||||
// is nested. Empty at depth 0.
|
||||
ParentUID string `json:"parent_uid,omitempty"`
|
||||
|
||||
// Attrs surfaces the small per-kind attrs the AI might want
|
||||
// (heading.level, callout.icon, code.language, list task
|
||||
// item.checked). Opaque attrs (metric configs) are omitted.
|
||||
Attrs map[string]any `json:"attrs,omitempty"`
|
||||
|
||||
// HasChildren marks an entry holding addressable blocks the walk
|
||||
// does not emit — a sublist under a list item, the internals of a
|
||||
// macro. Their text is folded into this entry's Text, so without
|
||||
// the flag the summary reads as though this row were the whole of
|
||||
// it, and the uids an edit would need are invisible.
|
||||
HasChildren bool `json:"has_children,omitempty"`
|
||||
}
|
||||
|
||||
// walkDocForAssistant builds the flat list of summary entries the
|
||||
// assistant uses. Each entry preserves document order; nested
|
||||
// blocks follow their parent with depth/parent_uid set so the AI
|
||||
// can reconstruct the hierarchy if needed.
|
||||
//
|
||||
// Macro internals (the LeftSide/RightSide wrappers of
|
||||
// splitDocumentation, the four-deep nesting inside a parameter
|
||||
// list) are not exposed: the AI sees the macro as a single entry
|
||||
// and drills into it via read_block when it needs the detail.
|
||||
func walkDocForAssistant(blocks []document.Block) []docSummaryEntry {
|
||||
w := &docWalker{}
|
||||
w.walkLevel(blocks, 0, "")
|
||||
|
||||
return w.out
|
||||
}
|
||||
|
||||
// blockRows builds the summary rows for one block and everything nested
|
||||
// in it: what a write returns for the block it wrote or touched. Depth
|
||||
// is relative to the block, so the block itself sits at 0 with no
|
||||
// parent. A kind the document walk never lists on its own (titled_code
|
||||
// lives only inside split_doc.right) still gets its one row here, since
|
||||
// the caller just wrote it and needs its uid back.
|
||||
func blockRows(b document.Block) []docSummaryEntry {
|
||||
w := &docWalker{}
|
||||
w.walkLevel([]document.Block{b}, 0, "")
|
||||
|
||||
if len(w.out) == 0 {
|
||||
if uid, ok := b.UID(); ok && uid != "" {
|
||||
w.emit(b, uid, 0, "", hasAddressableChildren(b))
|
||||
}
|
||||
}
|
||||
|
||||
return w.out
|
||||
}
|
||||
|
||||
// docWalker accumulates the summary entries walkDocForAssistant
|
||||
// produces as it recurses through the document tree.
|
||||
type docWalker struct {
|
||||
// out collects the emitted entries in document order.
|
||||
out []docSummaryEntry
|
||||
}
|
||||
|
||||
// walkLevel emits one entry per block at the current level, then
|
||||
// recurses into children for compound kinds whose children are
|
||||
// individually editable (lists, callout, blockquote, metric_grid).
|
||||
// Macro kinds (split_doc, param_list) emit a single entry without
|
||||
// descending — their internals are not editable as standalone
|
||||
// blocks.
|
||||
func (w *docWalker) walkLevel(blocks []document.Block, depth int, parentUID string) {
|
||||
for _, b := range blocks {
|
||||
uid, _ := b.UID()
|
||||
if uid == "" {
|
||||
// A block without a uid can't be referenced from any
|
||||
// edit tool, so it's not useful to surface.
|
||||
continue
|
||||
}
|
||||
|
||||
switch b.Type {
|
||||
// list items surface flat because their visible content is
|
||||
// the first child paragraph; deeper recursion isn't useful.
|
||||
// The macro kinds (split_doc, param_list) also surface as a
|
||||
// single entry without descending — their internals are not
|
||||
// editable as standalone blocks, so the AI drills into them
|
||||
// via read_block. Titled code blocks never reach this walk:
|
||||
// they exist only inside split_doc.right.
|
||||
case document.BlockNodeParagraph,
|
||||
document.BlockNodeHeading,
|
||||
document.BlockNodeCodeBlock,
|
||||
document.BlockNodeMermaidBlock,
|
||||
document.BlockNodeHorizontalRule,
|
||||
document.BlockNodeImageBlock,
|
||||
document.BlockNodeFileBlock,
|
||||
document.BlockNodeFigmaBlock,
|
||||
document.BlockNodeMetricBlock,
|
||||
document.BlockNodeListItem,
|
||||
document.BlockNodeTaskItem,
|
||||
document.BlockNodeSplitDoc,
|
||||
document.BlockNodeParamList:
|
||||
w.emit(b, uid, depth, parentUID, hasAddressableChildren(b))
|
||||
|
||||
case document.BlockNodeBulletList,
|
||||
document.BlockNodeOrderedList,
|
||||
document.BlockNodeTaskList,
|
||||
document.BlockNodeCalloutBlock,
|
||||
document.BlockNodeBlockquote,
|
||||
document.BlockNodeMetricGrid:
|
||||
// the walk lists this block's children itself, so there is
|
||||
// nothing hidden below it to point the reader at.
|
||||
w.emit(b, uid, depth, parentUID, false)
|
||||
w.walkLevel(b.Content, depth+1, uid)
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emit creates the standard summary entry for a block and appends it to
|
||||
// the walker's output. hasChildren says whether the block holds
|
||||
// addressable blocks this walk is not going to emit.
|
||||
func (w *docWalker) emit(
|
||||
b document.Block,
|
||||
uid string,
|
||||
depth int,
|
||||
parentUID string,
|
||||
hasChildren bool,
|
||||
) {
|
||||
entry := docSummaryEntry{
|
||||
UID: uid,
|
||||
Kind: canonicalKindForPM(b.Type),
|
||||
Text: b.Flatten(),
|
||||
Depth: depth,
|
||||
ParentUID: parentUID,
|
||||
Attrs: summaryAttrs(b),
|
||||
HasChildren: hasChildren,
|
||||
}
|
||||
|
||||
w.out = append(w.out, entry)
|
||||
}
|
||||
|
||||
// hasAddressableChildren reports whether the block holds addressable
|
||||
// blocks beyond the text the entry already shows. A leading paragraph
|
||||
// is that text — every list item has one — so it is not what the flag
|
||||
// is for; anything after it is structure the summary does not list.
|
||||
func hasAddressableChildren(b document.Block) bool {
|
||||
for i, cb := range b.Content {
|
||||
if i == 0 && cb.Type == document.BlockNodeParagraph {
|
||||
continue
|
||||
}
|
||||
|
||||
if uid, ok := cb.UID(); ok && uid != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
if hasAddressableChildren(cb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// canonicalKindForPM maps a ProseMirror node type to the canonical kind
|
||||
// name the assistant uses elsewhere. Keeping the AI on one vocabulary
|
||||
// (snake_case canonical names) avoids surprises when it reads a summary,
|
||||
// then writes via tools that use the canonical names.
|
||||
//
|
||||
// listItem and taskItem have no canonical type — the canonical model folds
|
||||
// them into their list's typed fields — but the summary names them anyway,
|
||||
// since it reports the hierarchy the AI targets its edits at.
|
||||
func canonicalKindForPM(pm document.BlockNodeType) string {
|
||||
if kind, ok := _walkerKinds[pm]; ok {
|
||||
return kind
|
||||
}
|
||||
|
||||
t, ok := block.CanonicalType(pm)
|
||||
if !ok {
|
||||
return string(pm)
|
||||
}
|
||||
|
||||
return string(t)
|
||||
}
|
||||
|
||||
// summaryAttrs filters the block's raw PM attrs down to the small
|
||||
// per-kind subset the AI cares about for reads. uid is dropped
|
||||
// (already on the entry); opaque metric configurations are dropped
|
||||
// because they would balloon the payload and the AI never edits
|
||||
// them by hand anyway.
|
||||
func summaryAttrs(b document.Block) map[string]any {
|
||||
switch b.Type {
|
||||
case document.BlockNodeHeading:
|
||||
if v, ok := b.Attrs[document.AttrLevel]; ok {
|
||||
return map[string]any{document.AttrLevel: v}
|
||||
}
|
||||
case document.BlockNodeCalloutBlock:
|
||||
if v, ok := b.Attrs[document.AttrIcon]; ok {
|
||||
return map[string]any{document.AttrIcon: v}
|
||||
}
|
||||
case document.BlockNodeCodeBlock:
|
||||
if v, ok := b.Attrs[document.AttrLanguage]; ok && v != "" {
|
||||
return map[string]any{document.AttrLanguage: v}
|
||||
}
|
||||
case document.BlockNodeTaskItem:
|
||||
if v, ok := b.Attrs[document.AttrChecked]; ok {
|
||||
return map[string]any{document.AttrChecked: v}
|
||||
}
|
||||
case document.BlockNodeImageBlock:
|
||||
return presentAttrs(b, document.AttrSrc, document.AttrAlt, document.AttrTitle, document.AttrWidth)
|
||||
case document.BlockNodeFileBlock:
|
||||
return presentAttrs(b, document.AttrName, document.AttrContentType, document.AttrSize, document.AttrSrc)
|
||||
case document.BlockNodeFigmaBlock:
|
||||
return presentAttrs(b, document.AttrSrc, document.AttrWidth, document.AttrHeight)
|
||||
case document.BlockNodeSplitDoc:
|
||||
if a, ok := b.Attrs.Get(document.AttrInversed); ok && a.Bool() {
|
||||
return map[string]any{document.AttrInversed: true}
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// presentAttrs picks the named attrs the block carries with a value,
|
||||
// or nil when it carries none of them.
|
||||
func presentAttrs(b document.Block, keys ...string) map[string]any {
|
||||
out := map[string]any{}
|
||||
|
||||
for _, k := range keys {
|
||||
if v, ok := b.Attrs[k]; ok && v != nil && v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// pmBlock builds a document.Block with a uid attr plus optional
|
||||
// extra attrs and children.
|
||||
func pmBlock(tp document.BlockNodeType, uid string, attrs map[string]any, children ...document.Block) document.Block {
|
||||
all := document.Attributes{}
|
||||
if uid != "" {
|
||||
all["uid"] = uid
|
||||
}
|
||||
|
||||
maps.Copy(all, attrs)
|
||||
|
||||
if len(all) == 0 {
|
||||
all = nil
|
||||
}
|
||||
|
||||
return document.Block{Type: tp, Attrs: all, Content: children}
|
||||
}
|
||||
|
||||
// pmText builds an inline text node.
|
||||
func pmText(text string) document.Block {
|
||||
return document.Block{Type: document.BlockNodeText, Text: text}
|
||||
}
|
||||
|
||||
func Test_walkDocForAssistant(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Blocks []document.Block
|
||||
Result []docSummaryEntry
|
||||
}{
|
||||
"Simple blocks surface flat": {
|
||||
Blocks: []document.Block{
|
||||
pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("hello")),
|
||||
pmBlock(document.BlockNodeHeading, "h1", map[string]any{"level": 2}, pmText("Title")),
|
||||
},
|
||||
Result: []docSummaryEntry{
|
||||
{UID: "p1", Kind: "paragraph", Text: "hello"},
|
||||
{UID: "h1", Kind: "heading", Text: "Title", Attrs: map[string]any{"level": 2}},
|
||||
},
|
||||
},
|
||||
"Lists descend into their items": {
|
||||
Blocks: []document.Block{
|
||||
pmBlock(document.BlockNodeBulletList, "l1", nil,
|
||||
pmBlock(document.BlockNodeListItem, "li1", nil,
|
||||
pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("one")),
|
||||
),
|
||||
),
|
||||
},
|
||||
Result: []docSummaryEntry{
|
||||
{UID: "l1", Kind: "bullet_list", Text: "one"},
|
||||
{UID: "li1", Kind: "list_item", Text: "one", Depth: 1, ParentUID: "l1"},
|
||||
},
|
||||
},
|
||||
"List item holding a sublist is flagged, since the walk stops there": {
|
||||
Blocks: []document.Block{
|
||||
pmBlock(document.BlockNodeBulletList, "l1", nil,
|
||||
pmBlock(document.BlockNodeListItem, "li1", nil,
|
||||
pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("parent")),
|
||||
pmBlock(document.BlockNodeBulletList, "l2", nil,
|
||||
pmBlock(document.BlockNodeListItem, "li2", nil,
|
||||
pmBlock(document.BlockNodeParagraph, "p2", nil, pmText("child")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
Result: []docSummaryEntry{
|
||||
{UID: "l1", Kind: "bullet_list", Text: "parent child"},
|
||||
{
|
||||
UID: "li1",
|
||||
Kind: "list_item",
|
||||
Text: "parent child",
|
||||
Depth: 1,
|
||||
ParentUID: "l1",
|
||||
HasChildren: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"Task list surfaces checked attr on items": {
|
||||
Blocks: []document.Block{
|
||||
pmBlock(document.BlockNodeTaskList, "t1", nil,
|
||||
pmBlock(document.BlockNodeTaskItem, "ti1", map[string]any{"checked": true},
|
||||
pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("done")),
|
||||
),
|
||||
),
|
||||
},
|
||||
Result: []docSummaryEntry{
|
||||
{UID: "t1", Kind: "task_list", Text: "done"},
|
||||
{UID: "ti1", Kind: "task_item", Text: "done", Depth: 1, ParentUID: "t1", Attrs: map[string]any{"checked": true}},
|
||||
},
|
||||
},
|
||||
"Blockquote descends into its children": {
|
||||
Blocks: []document.Block{
|
||||
pmBlock(document.BlockNodeBlockquote, "b1", nil,
|
||||
pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("quoted")),
|
||||
),
|
||||
},
|
||||
Result: []docSummaryEntry{
|
||||
{UID: "b1", Kind: "blockquote", Text: "quoted"},
|
||||
{UID: "p1", Kind: "paragraph", Text: "quoted", Depth: 1, ParentUID: "b1"},
|
||||
},
|
||||
},
|
||||
"Split doc emits a single entry without descending": {
|
||||
Blocks: []document.Block{
|
||||
pmBlock(document.BlockNodeSplitDoc, "s1", map[string]any{"inversed": true},
|
||||
pmBlock(document.BlockNodeSplitDocLeft, "sl1", nil,
|
||||
pmBlock(document.BlockNodeHeading, "h1", map[string]any{"level": 1}, pmText("API")),
|
||||
),
|
||||
),
|
||||
},
|
||||
Result: []docSummaryEntry{
|
||||
{UID: "s1", Kind: "split_doc", Text: "API", Attrs: map[string]any{"inversed": true}, HasChildren: true},
|
||||
},
|
||||
},
|
||||
"Param list emits a single entry without descending": {
|
||||
Blocks: []document.Block{
|
||||
pmBlock(document.BlockNodeParamList, "pl1", nil,
|
||||
pmBlock(document.BlockNodeParamListHeader, "plh1", nil, pmText("Body")),
|
||||
),
|
||||
},
|
||||
Result: []docSummaryEntry{
|
||||
{UID: "pl1", Kind: "split_doc_param_list", Text: "Body", HasChildren: true},
|
||||
},
|
||||
},
|
||||
"Blocks without uid are skipped": {
|
||||
Blocks: []document.Block{
|
||||
pmBlock(document.BlockNodeParagraph, "", nil, pmText("anonymous")),
|
||||
pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("named")),
|
||||
},
|
||||
Result: []docSummaryEntry{
|
||||
{UID: "p1", Kind: "paragraph", Text: "named"},
|
||||
},
|
||||
},
|
||||
"Unknown node types are skipped": {
|
||||
Blocks: []document.Block{
|
||||
pmBlock("weirdNode", "w1", nil),
|
||||
pmBlock(document.BlockNodeCodeBlock, "c1", map[string]any{"language": "go"}, pmText("x := 1")),
|
||||
},
|
||||
Result: []docSummaryEntry{
|
||||
{UID: "c1", Kind: "code", Text: "x := 1", Attrs: map[string]any{"language": "go"}},
|
||||
},
|
||||
},
|
||||
"Empty document yields no entries": {
|
||||
Blocks: nil,
|
||||
Result: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, walkDocForAssistant(c.Blocks))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_canonicalKindForPM(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
PM document.BlockNodeType
|
||||
Result string
|
||||
}{
|
||||
"Paragraph": {PM: document.BlockNodeParagraph, Result: "paragraph"},
|
||||
"Heading": {PM: document.BlockNodeHeading, Result: "heading"},
|
||||
"Blockquote": {PM: document.BlockNodeBlockquote, Result: "blockquote"},
|
||||
"Bullet list": {PM: document.BlockNodeBulletList, Result: "bullet_list"},
|
||||
"Ordered list": {PM: document.BlockNodeOrderedList, Result: "ordered_list"},
|
||||
"Task list": {PM: document.BlockNodeTaskList, Result: "task_list"},
|
||||
"List item": {PM: document.BlockNodeListItem, Result: "list_item"},
|
||||
"Task item": {PM: document.BlockNodeTaskItem, Result: "task_item"},
|
||||
"Callout": {PM: document.BlockNodeCalloutBlock, Result: "callout"},
|
||||
"Code": {PM: document.BlockNodeCodeBlock, Result: "code"},
|
||||
"Titled code": {PM: document.BlockNodeTitledCodeBlock, Result: "titled_code"},
|
||||
"Mermaid": {PM: document.BlockNodeMermaidBlock, Result: "mermaid"},
|
||||
"Horizontal rule": {PM: document.BlockNodeHorizontalRule, Result: "horizontal_rule"},
|
||||
"Image": {PM: document.BlockNodeImageBlock, Result: "image"},
|
||||
"File": {PM: document.BlockNodeFileBlock, Result: "file"},
|
||||
"Figma": {PM: document.BlockNodeFigmaBlock, Result: "figma"},
|
||||
"Metric": {PM: document.BlockNodeMetricBlock, Result: "metric"},
|
||||
"Metric grid": {PM: document.BlockNodeMetricGrid, Result: "metric_grid"},
|
||||
"Split doc": {PM: document.BlockNodeSplitDoc, Result: "split_doc"},
|
||||
"Param list": {PM: document.BlockNodeParamList, Result: "split_doc_param_list"},
|
||||
"Unknown falls through": {PM: "weirdNode", Result: "weirdNode"},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, canonicalKindForPM(c.PM))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_presentAttrs(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Input document.Block
|
||||
Keys []string
|
||||
Result map[string]any
|
||||
}{
|
||||
"Named attrs with values are kept": {
|
||||
Input: pmBlock(document.BlockNodeFileBlock, "f", map[string]any{"name": "a.zip", "size": 1, "other": "x"}),
|
||||
Keys: []string{"name", "size"},
|
||||
Result: map[string]any{"name": "a.zip", "size": 1},
|
||||
},
|
||||
"Empty and nil values are dropped": {
|
||||
Input: pmBlock(document.BlockNodeFileBlock, "f", map[string]any{"name": "", "size": nil, "src": "http://f"}),
|
||||
Keys: []string{"name", "size", "src"},
|
||||
Result: map[string]any{"src": "http://f"},
|
||||
},
|
||||
"Nothing present": {
|
||||
Input: pmBlock(document.BlockNodeFileBlock, "f", nil),
|
||||
Keys: []string{"name"},
|
||||
Result: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, presentAttrs(c.Input, c.Keys...))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_summaryAttrs(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Input document.Block
|
||||
Result map[string]any
|
||||
}{
|
||||
"Heading level": {
|
||||
Input: pmBlock(document.BlockNodeHeading, "h", map[string]any{"level": 3}),
|
||||
Result: map[string]any{"level": 3},
|
||||
},
|
||||
"Heading without level": {
|
||||
Input: pmBlock(document.BlockNodeHeading, "h", nil),
|
||||
Result: nil,
|
||||
},
|
||||
"Callout icon": {
|
||||
Input: pmBlock(document.BlockNodeCalloutBlock, "c", map[string]any{"icon": "lucide:warning"}),
|
||||
Result: map[string]any{"icon": "lucide:warning"},
|
||||
},
|
||||
"Code language": {
|
||||
Input: pmBlock(document.BlockNodeCodeBlock, "c", map[string]any{"language": "go"}),
|
||||
Result: map[string]any{"language": "go"},
|
||||
},
|
||||
"Code with empty language": {
|
||||
Input: pmBlock(document.BlockNodeCodeBlock, "c", map[string]any{"language": ""}),
|
||||
Result: nil,
|
||||
},
|
||||
"Task item checked": {
|
||||
Input: pmBlock(document.BlockNodeTaskItem, "t", map[string]any{"checked": false}),
|
||||
Result: map[string]any{"checked": false},
|
||||
},
|
||||
"Image attrs filtered": {
|
||||
Input: pmBlock(document.BlockNodeImageBlock, "i", map[string]any{
|
||||
"src": "http://x", "alt": "", "width": 100, "opaque": "dropped",
|
||||
}),
|
||||
Result: map[string]any{"src": "http://x", "width": 100},
|
||||
},
|
||||
"Image without usable attrs": {
|
||||
Input: pmBlock(document.BlockNodeImageBlock, "i", nil),
|
||||
Result: nil,
|
||||
},
|
||||
"File attrs filtered": {
|
||||
Input: pmBlock(document.BlockNodeFileBlock, "f", map[string]any{
|
||||
"src": "http://f", "name": "notes.zip", "contentType": "application/zip", "size": 2048, "uploading": false,
|
||||
}),
|
||||
Result: map[string]any{"src": "http://f", "name": "notes.zip", "contentType": "application/zip", "size": 2048},
|
||||
},
|
||||
"File without usable attrs": {
|
||||
Input: pmBlock(document.BlockNodeFileBlock, "f", nil),
|
||||
Result: nil,
|
||||
},
|
||||
"Figma attrs filtered": {
|
||||
Input: pmBlock(document.BlockNodeFigmaBlock, "f", map[string]any{
|
||||
"src": "http://f", "height": 200,
|
||||
}),
|
||||
Result: map[string]any{"src": "http://f", "height": 200},
|
||||
},
|
||||
"Figma without usable attrs": {
|
||||
Input: pmBlock(document.BlockNodeFigmaBlock, "f", nil),
|
||||
Result: nil,
|
||||
},
|
||||
"Split doc inversed": {
|
||||
Input: pmBlock(document.BlockNodeSplitDoc, "s", map[string]any{"inversed": true}),
|
||||
Result: map[string]any{"inversed": true},
|
||||
},
|
||||
"Split doc not inversed": {
|
||||
Input: pmBlock(document.BlockNodeSplitDoc, "s", map[string]any{"inversed": false}),
|
||||
Result: nil,
|
||||
},
|
||||
"Metric attrs are opaque": {
|
||||
Input: pmBlock(document.BlockNodeMetricBlock, "m", map[string]any{"query": "up"}),
|
||||
Result: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, summaryAttrs(c.Input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_blockRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Block document.Block
|
||||
Result []docSummaryEntry
|
||||
}{
|
||||
"Block without a uid yields nothing": {
|
||||
Block: pmBlock(document.BlockNodeParagraph, "", nil, pmText("x")),
|
||||
},
|
||||
"Block sits at depth zero with no parent": {
|
||||
Block: pmBlock(document.BlockNodeParagraph, "p", nil, pmText("hello")),
|
||||
Result: []docSummaryEntry{{UID: "p", Kind: "paragraph", Text: "hello"}},
|
||||
},
|
||||
"Nested entries follow the block": {
|
||||
Block: pmBlock(document.BlockNodeBulletList, "l", nil,
|
||||
pmBlock(document.BlockNodeListItem, "i", nil,
|
||||
pmBlock(document.BlockNodeParagraph, "", nil, pmText("one")),
|
||||
),
|
||||
),
|
||||
Result: []docSummaryEntry{
|
||||
{UID: "l", Kind: "bullet_list", Text: "one"},
|
||||
{UID: "i", Kind: "list_item", Text: "one", Depth: 1, ParentUID: "l"},
|
||||
},
|
||||
},
|
||||
// the document walk never lists a titled code block on its own,
|
||||
// but a write of one still has to hand its uid back.
|
||||
"Kind the document walk skips still gets its row": {
|
||||
Block: pmBlock(document.BlockNodeTitledCodeBlock, "t", map[string]any{"title": "Request"}, pmText("GET /")),
|
||||
Result: []docSummaryEntry{{UID: "t", Kind: "titled_code", Text: "GET /"}},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, blockRows(c.Block))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ import (
|
|||
//
|
||||
// It is deliberately not called read_file: the assistant's vocabulary
|
||||
// is documents and blocks, and a generic file tool sitting next to
|
||||
// read_block invites the model to reach for the wrong one.
|
||||
// get_document invites the model to reach for the wrong one.
|
||||
const NameReadToolOutput Name = "read_tool_output"
|
||||
|
||||
// readToolOutputArgs is what read_tool_output is called with.
|
||||
|
|
@ -45,15 +45,13 @@ func (a readToolOutputArgs) Validate() error {
|
|||
|
||||
// readToolOutput hands the model back a result the reduction middleware
|
||||
// moved out of the conversation.
|
||||
type readToolOutput struct {
|
||||
plainSummary
|
||||
plainTitle
|
||||
}
|
||||
type readToolOutput struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (readToolOutput) Info() Info {
|
||||
return Info{
|
||||
Name: NameReadToolOutput,
|
||||
Name: NameReadToolOutput,
|
||||
Traits: Traits{Internal: true},
|
||||
Description: "Retrieve the full output of an earlier tool call that was too large to keep in the conversation. " +
|
||||
"Pass the path shown in the truncation notice.",
|
||||
Properties: map[string]any{
|
||||
|
|
@ -63,22 +61,15 @@ func (readToolOutput) Info() Info {
|
|||
}
|
||||
}
|
||||
|
||||
// Traits reports an internal read: the paths this takes are minted by
|
||||
// the reduction middleware during a chat turn, so a client holding none
|
||||
// of that state would be offered a tool it can never call.
|
||||
func (readToolOutput) Traits() Traits {
|
||||
return Traits{Internal: true}
|
||||
}
|
||||
|
||||
// Execute returns the stored output.
|
||||
func (readToolOutput) Execute(inp Input) (string, error) {
|
||||
func (readToolOutput) Execute(inp *input) (string, error) {
|
||||
var in readToolOutputArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return inp.ReadOffloaded(in.FilePath)
|
||||
return inp.offload.Read(inp.Context(), in.FilePath)
|
||||
}
|
||||
|
||||
// einoTool adapts one of this package's tools to the interface the
|
||||
|
|
@ -87,7 +78,7 @@ type einoTool struct {
|
|||
// tl is the tool being adapted.
|
||||
tl Tool
|
||||
|
||||
// deps is the session wiring each call's Input is built from.
|
||||
// deps is the session wiring each call's input is built from.
|
||||
deps *Deps
|
||||
|
||||
// info is the tool's description, resolved once because it never
|
||||
|
|
@ -106,22 +97,15 @@ func (et *einoTool) Info(_ context.Context) (*schema.ToolInfo, error) {
|
|||
return et.info.toEino()
|
||||
}
|
||||
|
||||
// Run performs the call, handing the tool an Input built from this
|
||||
// call's context and arguments, and reports what the call changed
|
||||
// alongside what it produced.
|
||||
//
|
||||
// The documents come back even on failure: a tool that errors partway
|
||||
// may already have changed something, and a caller is better told than
|
||||
// left to assume otherwise.
|
||||
func (et *einoTool) Run(ctx context.Context, args json.RawMessage) (Result, error) {
|
||||
inp := et.input(ctx, string(args))
|
||||
// Run performs the call, handing the tool an input built from this
|
||||
// call's context and arguments. A failure names the tool.
|
||||
func (et *einoTool) Run(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
out, err := et.tl.Execute(et.input(ctx, string(args)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", et.info.Name, err)
|
||||
}
|
||||
|
||||
out, err := et.tl.Execute(inp)
|
||||
|
||||
return Result{
|
||||
Output: out,
|
||||
Documents: inp.touched,
|
||||
}, err
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InvokableRun performs the call for the agent framework, which hands
|
||||
|
|
@ -145,24 +129,50 @@ func (et *einoTool) InvokableRun(
|
|||
argumentsInJSON string,
|
||||
_ ...tool.Option,
|
||||
) (string, error) {
|
||||
res, err := et.Run(ctx, json.RawMessage(argumentsInJSON))
|
||||
out, err := et.Run(ctx, json.RawMessage(argumentsInJSON))
|
||||
if err != nil {
|
||||
//nolint:nilerr // the failure is the call's result here, not the turn's
|
||||
return err.Error(), nil
|
||||
}
|
||||
|
||||
return res.Output, err
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Title returns the status line shown while the tool runs.
|
||||
func (et *einoTool) Title(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
return et.tl.Title(et.input(ctx, string(args)))
|
||||
// Title returns the status line shown while the tool runs: a write's
|
||||
// summary, a read's own title, or nothing for a read without one or
|
||||
// arguments that cannot be described.
|
||||
func (et *einoTool) Title(ctx context.Context, args json.RawMessage) string {
|
||||
if et.info.Traits.Write {
|
||||
sum, err := et.Summary(ctx, args)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return sum.Summary
|
||||
}
|
||||
|
||||
if t, ok := et.tl.(titler); ok {
|
||||
return t.Title(et.input(ctx, string(args)))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Summary describes the pending write for the user. It is only reached
|
||||
// for a tool the registry gated, which is only ever a write.
|
||||
// for a tool the registry gated, which is only ever a write, and every
|
||||
// write describes itself. A failure names the tool.
|
||||
func (et *einoTool) Summary(ctx context.Context, args json.RawMessage) (ActionSummary, error) {
|
||||
return et.tl.Summary(et.input(ctx, string(args)))
|
||||
s, ok := et.tl.(summarizer)
|
||||
if !ok {
|
||||
return ActionSummary{}, fmt.Errorf("%s: proposes no change to describe", et.info.Name)
|
||||
}
|
||||
|
||||
sum, err := s.Summary(et.input(ctx, string(args)))
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: %w", et.info.Name, err)
|
||||
}
|
||||
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// input assembles the per-call input the tool is handed.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/datasource"
|
||||
|
|
@ -48,6 +49,10 @@ func Test_Info_toEino(t *testing.T) {
|
|||
data, err := json.Marshal(got)
|
||||
require.NoError(t, err)
|
||||
|
||||
// every session sends the whole tool list, and an MCP client keeps
|
||||
// it in context, so it stays under about 6k tokens.
|
||||
assert.Less(t, len(data), 22000, "the tool list has grown past its budget")
|
||||
|
||||
// a description change is meant to be reviewed as a golden diff, so
|
||||
// the file is rewritten on request rather than by hand.
|
||||
if os.Getenv("UPDATE_GOLDEN") != "" {
|
||||
|
|
@ -89,28 +94,18 @@ func Test_einoTool_Info(t *testing.T) {
|
|||
func Test_einoTool_Run(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// error: the arguments never reach a tool that cannot read them
|
||||
res, err := newEinoTool(getDocument{}, testDeps(nil, nil, nil)).
|
||||
// error: the arguments never reach a tool that cannot read them, and
|
||||
// the failure names the tool.
|
||||
_, err := newEinoTool(getDocument{}, testDeps(nil, nil, nil)).
|
||||
Run(context.Background(), json.RawMessage(`{`))
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, res.Documents)
|
||||
assert.True(t, strings.HasPrefix(err.Error(), "get_document: invalid input: "))
|
||||
|
||||
// a read changes nothing, so it has nothing to report changing
|
||||
res, err = newEinoTool(listDocuments{}, testDeps(nil, nil, nil)).
|
||||
// success: the tool's own output comes back as it is
|
||||
out, err := newEinoTool(listDocuments{}, testDeps(nil, nil, nil)).
|
||||
Run(context.Background(), json.RawMessage(`{}`))
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"documents":null}`, res.Output)
|
||||
assert.Empty(t, res.Documents)
|
||||
|
||||
// a write reports the document it changed, taken from the edit it
|
||||
// actually applied rather than from its arguments
|
||||
res, err = newEinoTool(updateBlockText{}, testDeps(stubContentDB(nil), stubApplier(), nil)).
|
||||
Run(context.Background(), json.RawMessage(
|
||||
`{`+targetArgs(_stubMainBranchID)+`,"block_uid":"a","text":"hi"}`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"blocks":[{"uid":"a","kind":"paragraph","text":"hi","depth":0}]}`, res.Output)
|
||||
assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubMainBranchID}}, res.Documents)
|
||||
assert.JSONEq(t, `{"documents":[]}`, out)
|
||||
}
|
||||
|
||||
func Test_einoTool_InvokableRun(t *testing.T) {
|
||||
|
|
@ -120,7 +115,7 @@ func Test_einoTool_InvokableRun(t *testing.T) {
|
|||
res, err := newEinoTool(listDocuments{}, testDeps(nil, nil, nil)).
|
||||
InvokableRun(context.Background(), `{}`)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"documents":null}`, res)
|
||||
assert.JSONEq(t, `{"documents":[]}`, res)
|
||||
|
||||
// a failure comes back as the call's result rather than as an
|
||||
// error: the framework would end the whole turn on an error, and
|
||||
|
|
@ -139,7 +134,7 @@ func Test_einoTool_InvokableRun(t *testing.T) {
|
|||
|
||||
args := `{"data_source_id":"` + _testDataSourceID.String() + `","query":"up)"}`
|
||||
|
||||
res, err = newEinoTool(queryPrometheus{}, failing).InvokableRun(context.Background(), args)
|
||||
res, err = newEinoTool(queryDataSource{}, failing).InvokableRun(context.Background(), args)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res, "parse error")
|
||||
}
|
||||
|
|
@ -147,11 +142,20 @@ func Test_einoTool_InvokableRun(t *testing.T) {
|
|||
func Test_einoTool_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
et := newEinoTool(getDocument{}, testDeps(stubDocumentDB(), nil, nil))
|
||||
ctx := context.Background()
|
||||
d := testDeps(stubContentDB(nil), nil, nil)
|
||||
|
||||
got, err := et.Title(context.Background(), json.RawMessage(`{`+targetArgs(_stubMainBranchID)+`}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Reading Runbook", got)
|
||||
// a read announces itself with its own title.
|
||||
assert.Equal(t, "Reading Runbook", newEinoTool(getDocument{}, d).Title(ctx, json.RawMessage(`{`+targetArgs(_stubMainBranchID)+`}`)))
|
||||
|
||||
// a read without one runs silently.
|
||||
assert.Empty(t, newEinoTool(listDocuments{}, d).Title(ctx, json.RawMessage(`{}`)))
|
||||
|
||||
// a write is announced by its summary, and by nothing when its
|
||||
// arguments cannot be read.
|
||||
write := newEinoTool(deleteBlock{}, d)
|
||||
assert.Equal(t, "Delete a block in Runbook", write.Title(ctx, json.RawMessage(`{`+targetArgs(_stubMainBranchID)+`,"block_uid":"a"}`)))
|
||||
assert.Empty(t, write.Title(ctx, json.RawMessage(`{`)))
|
||||
}
|
||||
|
||||
func Test_einoTool_Summary(t *testing.T) {
|
||||
|
|
@ -167,16 +171,12 @@ func Test_einoTool_Summary(t *testing.T) {
|
|||
|
||||
// arguments the tool cannot read are refused rather than described:
|
||||
// the same payload would fail on resume anyway.
|
||||
_, err = et.Summary(context.Background(), json.RawMessage(`{`))
|
||||
_, err = et.Summary(context.Background(), json.RawMessage(`{"document_id":null}`))
|
||||
assert.EqualError(t, err, "delete_document: document_id is required")
|
||||
|
||||
// a read proposes nothing, so it has nothing to describe.
|
||||
_, err = newEinoTool(getDocument{}, testDeps(nil, nil, nil)).Summary(context.Background(), json.RawMessage(`{}`))
|
||||
require.Error(t, err)
|
||||
|
||||
// a read proposes nothing and is never gated, so the adapter reaches
|
||||
// its plainSummary and comes back with nothing to show.
|
||||
read := newEinoTool(getDocument{}, testDeps(nil, nil, nil))
|
||||
|
||||
got, err = read.Summary(context.Background(), json.RawMessage(`{}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ActionSummary{}, got)
|
||||
}
|
||||
|
||||
func Test_readToolOutputArgs_Validate(t *testing.T) {
|
||||
|
|
@ -192,30 +192,18 @@ func Test_readToolOutput_Info(t *testing.T) {
|
|||
|
||||
info := readToolOutput{}.Info()
|
||||
|
||||
// the paths it takes only exist inside a conversation, so it must
|
||||
// never be offered to a client that has none.
|
||||
assert.Equal(t, Traits{Internal: true}, info.Traits)
|
||||
|
||||
// the tool is named for tool output, not files: sitting next to
|
||||
// read_block, a generic read_file would invite the wrong call.
|
||||
// get_document, a generic read_file would invite the wrong call.
|
||||
assert.Equal(t, NameReadToolOutput, info.Name)
|
||||
assert.NotEmpty(t, info.Description)
|
||||
assert.Contains(t, info.Properties, "file_path")
|
||||
assert.Equal(t, []string{"file_path"}, info.Required)
|
||||
}
|
||||
|
||||
func Test_readToolOutput_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// the paths it takes only exist inside a conversation, so it must
|
||||
// never be offered to a client that has none.
|
||||
assert.Equal(t, Traits{Internal: true}, readToolOutput{}.Traits())
|
||||
}
|
||||
|
||||
func Test_readToolOutput_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := readToolOutput{}.Title(testInput(testDeps(nil, nil, nil), NameReadToolOutput, `{}`))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func Test_readToolOutput_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
|
|
@ -25,25 +25,23 @@ func hookSettingsSchema() map[string]any {
|
|||
"description": "The hook's type and its settings.",
|
||||
"anyOf": []map[string]any{
|
||||
{
|
||||
"title": string(hook.TypeScheduledReminder),
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"const": string(hook.TypeScheduledReminder)},
|
||||
"schedule": map[string]any{"type": "string", "description": "The RFC 3339 timestamp at which the reminder is due."},
|
||||
"schedule": map[string]any{"type": "string", "description": "When the reminder is due, RFC 3339."},
|
||||
},
|
||||
"required": []string{"type", "schedule"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
{
|
||||
"title": string(hook.TypeGithubTracking),
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"const": string(hook.TypeGithubTracking)},
|
||||
"repository": map[string]any{"type": "string", "description": "The repository to follow, by its name alone; the connected GitHub account is its owner."},
|
||||
"branch": map[string]any{"type": "string", "description": "The repository branch to follow."},
|
||||
"repository": map[string]any{"type": "string", "description": "The repository name, owned by the connected GitHub account."},
|
||||
"branch": map[string]any{"type": "string", "description": "The branch to follow."},
|
||||
"paths": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The repository paths whose changes mark the document stale.",
|
||||
"description": "The paths whose changes mark the document stale.",
|
||||
"items": map[string]any{"type": "string"},
|
||||
"minItems": 1,
|
||||
},
|
||||
|
|
@ -52,21 +50,19 @@ func hookSettingsSchema() map[string]any {
|
|||
"additionalProperties": false,
|
||||
},
|
||||
{
|
||||
"title": string(hook.TypeURLWatcher),
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"const": string(hook.TypeURLWatcher)},
|
||||
"url": map[string]any{"type": "string", "description": "The web address to watch for changes."},
|
||||
"url": map[string]any{"type": "string", "description": "The address to watch."},
|
||||
},
|
||||
"required": []string{"type", "url"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
{
|
||||
"title": string(hook.TypeContainerImageWatcher),
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"const": string(hook.TypeContainerImageWatcher)},
|
||||
"image": map[string]any{"type": "string", "description": "The container image reference to watch for new digests."},
|
||||
"image": map[string]any{"type": "string", "description": "The image reference to watch for new digests."},
|
||||
},
|
||||
"required": []string{"type", "image"},
|
||||
"additionalProperties": false,
|
||||
|
|
@ -105,13 +101,8 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
|
|||
|
||||
switch tp {
|
||||
case hook.TypeScheduledReminder:
|
||||
var sr struct {
|
||||
processor.ScheduledReminder
|
||||
|
||||
Type hook.Type `json:"type"`
|
||||
}
|
||||
|
||||
if err := decodeSettings(tp, raw, &sr); err != nil {
|
||||
sr, err := decodeSettings[processor.ScheduledReminder](tp, raw)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
|
|
@ -122,15 +113,10 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
|
|||
// a concrete date is what the editor calls a custom duration.
|
||||
sr.Scale = processor.ScaleTypeLinear
|
||||
sr.Duration = null.StringFrom("custom")
|
||||
v = sr.ScheduledReminder
|
||||
v = sr
|
||||
case hook.TypeGithubTracking:
|
||||
var gt struct {
|
||||
processor.GithubTracking
|
||||
|
||||
Type hook.Type `json:"type"`
|
||||
}
|
||||
|
||||
if err := decodeSettings(tp, raw, >); err != nil {
|
||||
gt, err := decodeSettings[processor.GithubTracking](tp, raw)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
|
|
@ -143,15 +129,10 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
|
|||
return "", nil, errRequired("paths")
|
||||
}
|
||||
|
||||
v = gt.GithubTracking
|
||||
v = gt
|
||||
case hook.TypeURLWatcher:
|
||||
var uw struct {
|
||||
processor.URLWatcher
|
||||
|
||||
Type hook.Type `json:"type"`
|
||||
}
|
||||
|
||||
if err := decodeSettings(tp, raw, &uw); err != nil {
|
||||
uw, err := decodeSettings[processor.URLWatcher](tp, raw)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
|
|
@ -159,15 +140,10 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
|
|||
return "", nil, errRequired("url")
|
||||
}
|
||||
|
||||
v = uw.URLWatcher
|
||||
v = uw
|
||||
case hook.TypeContainerImageWatcher:
|
||||
var ciw struct {
|
||||
processor.ContainerImageWatcher
|
||||
|
||||
Type hook.Type `json:"type"`
|
||||
}
|
||||
|
||||
if err := decodeSettings(tp, raw, &ciw); err != nil {
|
||||
ciw, err := decodeSettings[processor.ContainerImageWatcher](tp, raw)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +151,7 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
|
|||
return "", nil, errRequired("image")
|
||||
}
|
||||
|
||||
v = ciw.ContainerImageWatcher
|
||||
v = ciw
|
||||
}
|
||||
|
||||
out, err := json.Marshal(v)
|
||||
|
|
@ -187,26 +163,29 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
|
|||
}
|
||||
|
||||
// decodeSettings unmarshals the settings into the processor struct of
|
||||
// the given type, refusing a field the struct does not have. An absent
|
||||
// payload is an empty one, so the type's own check reports what is
|
||||
// missing.
|
||||
func decodeSettings(tp hook.Type, raw json.RawMessage, dst any) error {
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
// the given type, refusing a field the struct does not have. The type
|
||||
// field sits beside the settings, so it is not refused.
|
||||
func decodeSettings[T any](tp hook.Type, raw json.RawMessage) (T, error) {
|
||||
var dst struct {
|
||||
// Settings is the processor struct the settings decode into.
|
||||
Settings T `json:",embed"` //nolint:revive // embed is the json/v2 option revive does not know yet
|
||||
|
||||
// Type is the hook type the settings belong to.
|
||||
Type hook.Type `json:"type"`
|
||||
}
|
||||
|
||||
err := jsonv2.Unmarshal(raw, dst, jsonv2.RejectUnknownMembers(true))
|
||||
err := jsonv2.Unmarshal(raw, &dst, jsonv2.RejectUnknownMembers(true))
|
||||
if err == nil {
|
||||
return nil
|
||||
return dst.Settings, nil
|
||||
}
|
||||
|
||||
var serr *jsonv2.SemanticError
|
||||
|
||||
if errors.As(err, &serr) && errors.Is(serr.Err, jsonv2.ErrUnknownName) {
|
||||
return fmt.Errorf("%s is not a %s setting", serr.JSONPointer.LastToken(), tp)
|
||||
return dst.Settings, fmt.Errorf("%s is not a %s setting", serr.JSONPointer.LastToken(), tp)
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid settings: %w", err)
|
||||
return dst.Settings, fmt.Errorf("invalid settings: %w", err)
|
||||
}
|
||||
|
||||
// listHooksArgs is what list_hooks is called with.
|
||||
|
|
@ -214,33 +193,24 @@ type listHooksArgs struct {
|
|||
docTarget
|
||||
}
|
||||
|
||||
// Validate checks the arguments name a branch.
|
||||
func (a listHooksArgs) Validate() error {
|
||||
return a.validate()
|
||||
}
|
||||
|
||||
// listHooks returns every hook on one branch of a document.
|
||||
type listHooks struct {
|
||||
plainSummary
|
||||
plainTraits
|
||||
plainTitle
|
||||
}
|
||||
type listHooks struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (listHooks) Info() Info {
|
||||
return Info{
|
||||
Name: NameListHooks,
|
||||
Description: "List the freshness hooks on one branch of a document as [{id, type, block_uid, settings, score, state, created_at, updated_at}]. A hook watches something outside the document and lowers its score as that thing changes or a date approaches; block_uid names the block it is anchored to, or is null for a hook on the document as a whole. Use it to learn the id that update_hook, reset_hook and delete_hook take as hook_id. document_id and branch_id name the branch the way the content tools do.",
|
||||
Description: "List the freshness hooks on one branch of a document as [{id, type, block_uid, settings, score, state, status, created_at, updated_at}]. A hook watches something outside the document and lowers its score from 100 as that thing changes or a date approaches. block_uid is the block it is anchored to, or null for the whole document.",
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"branch_id": map[string]any{"type": "string", "description": "The id of the branch whose hooks to list: a document's default_branch_id from list_documents, or any id from the branches get_document lists."},
|
||||
"document_id": map[string]any{"type": "string", "description": _documentIDDescription},
|
||||
"branch_id": map[string]any{"type": "string", "description": _branchIDDescription},
|
||||
},
|
||||
Required: []string{"document_id", "branch_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute lists the branch's hooks.
|
||||
func (listHooks) Execute(inp Input) (string, error) {
|
||||
func (listHooks) Execute(inp *input) (string, error) {
|
||||
var in listHooksArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -249,7 +219,7 @@ func (listHooks) Execute(inp Input) (string, error) {
|
|||
|
||||
hooks, err := inp.FetchHooks(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list_hooks: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
out := hookListResult{Hooks: make([]hookRow, 0, len(hooks))}
|
||||
|
|
@ -331,7 +301,7 @@ type createHookArgs struct {
|
|||
// Validate checks the arguments name a branch and carry settings naming
|
||||
// a hook type, with exactly that type's fields.
|
||||
func (a createHookArgs) Validate() error {
|
||||
if err := a.validate(); err != nil {
|
||||
if err := a.docTarget.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -347,38 +317,18 @@ type createHook struct{}
|
|||
func (createHook) Info() Info {
|
||||
return Info{
|
||||
Name: NameCreateHook,
|
||||
Description: "Add a freshness hook to one branch of a document and return it as list_hooks would. settings.type is one of scheduled-reminder, github-tracking, url-watcher and container-image-watcher, and the rest of settings is that type's own fields. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. block_uid anchors the hook to a block; a uid the branch does not hold is refused. A new hook starts at score 100.",
|
||||
Traits: Traits{Write: true},
|
||||
Description: "Add a freshness hook to one branch of a document and return it as list_hooks would. github-tracking and url-watcher need the deployment's GitHub App and changedetection.io integrations, and are refused without them. A new hook starts at score 100.",
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"branch_id": map[string]any{"type": "string", "description": "The id of the branch the hook goes on: a document's default_branch_id from list_documents, or any id from the branches get_document lists."},
|
||||
"block_uid": map[string]any{"type": "string", "description": "Optional. The uid of a block on the branch to anchor the hook to, from get_document; omit to put the hook on the document as a whole."},
|
||||
"document_id": map[string]any{"type": "string", "description": _documentIDDescription},
|
||||
"branch_id": map[string]any{"type": "string", "description": _branchIDDescription},
|
||||
"block_uid": map[string]any{"type": "string", "description": "Optional. The id of a block on the branch to anchor the hook to; omit it for the whole document."},
|
||||
"settings": hookSettingsSchema(),
|
||||
},
|
||||
Required: []string{"document_id", "branch_id", "settings"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a write.
|
||||
func (createHook) Traits() Traits {
|
||||
return Traits{Write: true}
|
||||
}
|
||||
|
||||
// Title announces which document gets the hook.
|
||||
func (createHook) Title(inp DescribeInput) (string, error) {
|
||||
var in createHookArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch document: %w", NameCreateHook, err)
|
||||
}
|
||||
|
||||
return "Adding a hook to " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary names the hook type and where it goes.
|
||||
func (createHook) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in createHookArgs
|
||||
|
|
@ -389,13 +339,13 @@ func (createHook) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
|
||||
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameCreateHook, err)
|
||||
return ActionSummary{}, fmt.Errorf("fetch document: %w", err)
|
||||
}
|
||||
|
||||
// Validate already accepted the settings, so this cannot fail.
|
||||
tp, _, err := decodeHookSettings(in.Settings)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: %w", NameCreateHook, err)
|
||||
return ActionSummary{}, err
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
|
|
@ -407,7 +357,7 @@ func (createHook) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
}
|
||||
|
||||
// Execute creates the hook.
|
||||
func (createHook) Execute(inp Input) (string, error) {
|
||||
func (createHook) Execute(inp *input) (string, error) {
|
||||
var in createHookArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -417,12 +367,12 @@ func (createHook) Execute(inp Input) (string, error) {
|
|||
// Validate already accepted the settings, so this cannot fail.
|
||||
tp, settings, err := decodeHookSettings(in.Settings)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create_hook: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
hk, err := inp.CreateHook(in.DocumentID, in.BranchID, in.BlockUID, tp, settings)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create_hook: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result(newHookRow(hk))
|
||||
|
|
@ -433,83 +383,71 @@ type updateHookArgs struct {
|
|||
hookRefArgs
|
||||
|
||||
// Settings is the hook's type and its new settings in the shape
|
||||
// that type takes.
|
||||
// that type takes; absent keeps the settings and only resets the
|
||||
// hook.
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
}
|
||||
|
||||
// Validate checks the arguments name a hook and carry settings naming a
|
||||
// hook type, with exactly that type's fields. That the type is the
|
||||
// hook's own is checked once it is fetched.
|
||||
// Validate checks the arguments name a hook and, when they carry
|
||||
// settings, that those name a hook type with exactly that type's
|
||||
// fields. That the type is the hook's own is checked once it is
|
||||
// fetched.
|
||||
func (a updateHookArgs) Validate() error {
|
||||
if err := a.hookRefArgs.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !a.replacesSettings() {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _, err := decodeHookSettings(a.Settings)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// decodeUpdateHookSettings reads the settings of an update_hook call
|
||||
// for the hook it names, refusing settings of another type: a hook
|
||||
// keeps its type for life.
|
||||
func decodeUpdateHookSettings(hk *hook.Hook, raw json.RawMessage) (processor.Settings, error) {
|
||||
tp, settings, err := decodeHookSettings(raw)
|
||||
// replacesSettings reports whether the call carries new settings, as
|
||||
// opposed to only resetting the hook.
|
||||
func (a updateHookArgs) replacesSettings() bool {
|
||||
trimmed := bytes.TrimSpace(a.Settings)
|
||||
|
||||
return len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null"))
|
||||
}
|
||||
|
||||
// settingsFor reads the call's settings for the hook it names, refusing
|
||||
// settings of another type: a hook keeps its type for life.
|
||||
func (a updateHookArgs) settingsFor(hk *hook.Hook) (processor.Settings, error) {
|
||||
tp, settings, err := decodeHookSettings(a.Settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tp != hk.Type {
|
||||
return nil, errHookTypeMismatch(hk.Type, tp)
|
||||
return nil, fmt.Errorf("the hook is a %s, not a %s; to change the type, delete it and create another", hk.Type, tp)
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// errHookTypeMismatch reports settings of one type sent to a hook of
|
||||
// another.
|
||||
func errHookTypeMismatch(have, got hook.Type) error {
|
||||
return fmt.Errorf("the hook is a %s, not a %s; to change the type, delete it and create another", have, got)
|
||||
}
|
||||
|
||||
// updateHook replaces a hook's settings.
|
||||
// updateHook resets a hook's score and state, replacing its settings
|
||||
// when new ones are given.
|
||||
type updateHook struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (updateHook) Info() Info {
|
||||
return Info{
|
||||
Name: NameUpdateHook,
|
||||
Description: "Replace the settings of a hook and reset its score and state, returning it as list_hooks would. settings.type is one of scheduled-reminder, github-tracking, url-watcher and container-image-watcher, and the rest of settings is that type's own fields. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. The hook keeps its type, so settings.type is the type list_hooks reports for it; to change the type, delete the hook and create another.",
|
||||
Traits: Traits{Write: true},
|
||||
Description: "Reset a hook's score to 100 and its state to a fresh hook's, and return it as list_hooks would. Without settings the hook keeps them: use that once the document is brought up to date with what the hook flagged. With settings they replace the old ones; the hook keeps its type, so to change it delete the hook and create another.",
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"hook_id": map[string]any{"type": "string", "description": "The hook id, as list_hooks reports it."},
|
||||
"settings": hookSettingsSchema(),
|
||||
"document_id": map[string]any{"type": "string", "description": _documentIDDescription},
|
||||
"hook_id": map[string]any{"type": "string", "description": _hookIDDescription},
|
||||
"settings": map[string]any{"type": "object", "description": "Optional. New settings, shaped as create_hook's, with the hook's own type."},
|
||||
},
|
||||
Required: []string{"document_id", "hook_id", "settings"},
|
||||
Required: []string{"document_id", "hook_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a write.
|
||||
func (updateHook) Traits() Traits {
|
||||
return Traits{Write: true}
|
||||
}
|
||||
|
||||
// Title announces which hook is being updated.
|
||||
func (updateHook) Title(inp DescribeInput) (string, error) {
|
||||
var in updateHookArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
doc, err := inp.FetchDocument(in.DocumentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch document: %w", NameUpdateHook, err)
|
||||
}
|
||||
|
||||
return "Updating a hook on " + doc.DocumentName, nil
|
||||
}
|
||||
|
||||
// Summary names the hook and checks the settings fit its type, so the
|
||||
// user is not asked to approve a call that cannot run.
|
||||
func (updateHook) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
|
|
@ -519,25 +457,32 @@ func (updateHook) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
return ActionSummary{}, err
|
||||
}
|
||||
|
||||
doc, hk, err := describeHook(inp, NameUpdateHook, in.hookRefArgs)
|
||||
doc, hk, err := in.fetchHookAndBranch(inp)
|
||||
if err != nil {
|
||||
return ActionSummary{}, err
|
||||
}
|
||||
|
||||
if _, err := decodeUpdateHookSettings(hk, in.Settings); err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: %w", NameUpdateHook, err)
|
||||
summary := "Reset the " + hookLabel(hk.Type, hk.BlockID, doc)
|
||||
|
||||
if in.replacesSettings() {
|
||||
if _, err := in.settingsFor(hk); err != nil {
|
||||
return ActionSummary{}, err
|
||||
}
|
||||
|
||||
summary = "Update the " + hookLabel(hk.Type, hk.BlockID, doc)
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
Tool: NameUpdateHook,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: "Update the " + hookLabel(hk.Type, hk.BlockID, doc),
|
||||
Summary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute replaces the settings and resets the hook.
|
||||
func (updateHook) Execute(inp Input) (string, error) {
|
||||
// Execute resets the hook, replacing its settings when new ones were
|
||||
// given.
|
||||
func (updateHook) Execute(inp *input) (string, error) {
|
||||
var in updateHookArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -546,23 +491,31 @@ func (updateHook) Execute(inp Input) (string, error) {
|
|||
|
||||
hk, err := inp.FetchHook(in.DocumentID, in.HookID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("update_hook: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
settings, err := decodeUpdateHookSettings(hk, in.Settings)
|
||||
if !in.replacesSettings() {
|
||||
if rerr := inp.ResetHook(hk); rerr != nil {
|
||||
return "", rerr
|
||||
}
|
||||
|
||||
return result(newHookRow(hk))
|
||||
}
|
||||
|
||||
settings, err := in.settingsFor(hk)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("update_hook: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := inp.UpdateHook(hk, settings); err != nil {
|
||||
return "", fmt.Errorf("update_hook: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result(newHookRow(hk))
|
||||
}
|
||||
|
||||
// hookRefArgs is what reset_hook and delete_hook are called with: a
|
||||
// document and one of its hooks.
|
||||
// hookRefArgs is a document and one of its hooks, which is what
|
||||
// delete_hook is called with and update_hook starts from.
|
||||
type hookRefArgs struct {
|
||||
// DocumentID names the document the hook is on.
|
||||
DocumentID xid.ID `json:"document_id"`
|
||||
|
|
@ -584,91 +537,6 @@ func (a hookRefArgs) Validate() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// hookRefProps builds the argument schema reset_hook and delete_hook
|
||||
// share.
|
||||
func hookRefProps() map[string]any {
|
||||
return map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"hook_id": map[string]any{"type": "string", "description": "The hook id, as list_hooks reports it."},
|
||||
}
|
||||
}
|
||||
|
||||
// resetHook restores a hook's score and state without touching its
|
||||
// settings.
|
||||
type resetHook struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (resetHook) Info() Info {
|
||||
return Info{
|
||||
Name: NameResetHook,
|
||||
Description: "Reset a hook's score to 100 and its state to what a fresh hook has, keeping its settings, and return it as list_hooks would. Use it once the document has been brought up to date with whatever the hook flagged.",
|
||||
Properties: hookRefProps(),
|
||||
Required: []string{"document_id", "hook_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a write.
|
||||
func (resetHook) Traits() Traits {
|
||||
return Traits{Write: true}
|
||||
}
|
||||
|
||||
// Title announces which hook is being reset.
|
||||
func (resetHook) Title(inp DescribeInput) (string, error) {
|
||||
var in hookRefArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
doc, err := inp.FetchDocument(in.DocumentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch document: %w", NameResetHook, err)
|
||||
}
|
||||
|
||||
return "Resetting a hook on " + doc.DocumentName, nil
|
||||
}
|
||||
|
||||
// Summary names the hook being reset.
|
||||
func (resetHook) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in hookRefArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return ActionSummary{}, err
|
||||
}
|
||||
|
||||
doc, hk, err := describeHook(inp, NameResetHook, in)
|
||||
if err != nil {
|
||||
return ActionSummary{}, err
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
Tool: NameResetHook,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: "Reset the " + hookLabel(hk.Type, hk.BlockID, doc),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute resets the hook.
|
||||
func (resetHook) Execute(inp Input) (string, error) {
|
||||
var in hookRefArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hk, err := inp.FetchHook(in.DocumentID, in.HookID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reset_hook: %w", err)
|
||||
}
|
||||
|
||||
if err := inp.ResetHook(hk); err != nil {
|
||||
return "", fmt.Errorf("reset_hook: %w", err)
|
||||
}
|
||||
|
||||
return result(newHookRow(hk))
|
||||
}
|
||||
|
||||
// deleteHook removes a hook from its document.
|
||||
type deleteHook struct{}
|
||||
|
||||
|
|
@ -676,34 +544,16 @@ type deleteHook struct{}
|
|||
func (deleteHook) Info() Info {
|
||||
return Info{
|
||||
Name: NameDeleteHook,
|
||||
Description: "Delete a hook, tearing down whatever it watches outside the document; it cannot be restored, so use reset_hook when the aim is to clear what it flagged rather than stop watching. Returns {hook_id, deleted}.",
|
||||
Properties: hookRefProps(),
|
||||
Required: []string{"document_id", "hook_id"},
|
||||
Traits: Traits{Write: true, Destructive: true},
|
||||
Description: "Delete a hook, tearing down whatever it watches outside the document. It cannot be restored; update_hook without settings clears what it flagged instead. Returns {hook_id, deleted}.",
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": _documentIDDescription},
|
||||
"hook_id": map[string]any{"type": "string", "description": _hookIDDescription},
|
||||
},
|
||||
Required: []string{"document_id", "hook_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a destructive write, which stays outside any "approve
|
||||
// all" answer.
|
||||
func (deleteHook) Traits() Traits {
|
||||
return Traits{Write: true, Destructive: true}
|
||||
}
|
||||
|
||||
// Title announces which hook is being deleted.
|
||||
func (deleteHook) Title(inp DescribeInput) (string, error) {
|
||||
var in hookRefArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
doc, err := inp.FetchDocument(in.DocumentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch document: %w", NameDeleteHook, err)
|
||||
}
|
||||
|
||||
return "Deleting a hook on " + doc.DocumentName, nil
|
||||
}
|
||||
|
||||
// Summary names the hook being deleted.
|
||||
func (deleteHook) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in hookRefArgs
|
||||
|
|
@ -712,7 +562,7 @@ func (deleteHook) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
return ActionSummary{}, err
|
||||
}
|
||||
|
||||
doc, hk, err := describeHook(inp, NameDeleteHook, in)
|
||||
doc, hk, err := in.fetchHookAndBranch(inp)
|
||||
if err != nil {
|
||||
return ActionSummary{}, err
|
||||
}
|
||||
|
|
@ -726,7 +576,7 @@ func (deleteHook) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
}
|
||||
|
||||
// Execute deletes the hook.
|
||||
func (deleteHook) Execute(inp Input) (string, error) {
|
||||
func (deleteHook) Execute(inp *input) (string, error) {
|
||||
var in hookRefArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -735,11 +585,11 @@ func (deleteHook) Execute(inp Input) (string, error) {
|
|||
|
||||
hk, err := inp.FetchHook(in.DocumentID, in.HookID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("delete_hook: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := inp.DeleteHook(hk); err != nil {
|
||||
return "", fmt.Errorf("delete_hook: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result(deletedHookResult{HookID: hk.ID, Deleted: true})
|
||||
|
|
@ -755,25 +605,26 @@ type deletedHookResult struct {
|
|||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// describeHook resolves the hook a description names and the document
|
||||
// on the branch the hook is on, so the label can name that branch. A
|
||||
// hook whose branch is gone is named on the document's default one.
|
||||
func describeHook(inp DescribeInput, name Name, ref hookRefArgs) (*document.Document, *hook.Hook, error) {
|
||||
hk, err := inp.FetchHook(ref.DocumentID, ref.HookID)
|
||||
// fetchHookAndBranch fetches the hook the arguments name and the
|
||||
// document on the branch the hook is on, so a label can name that
|
||||
// branch. A hook whose branch is gone is named on the document's default
|
||||
// one.
|
||||
func (a hookRefArgs) fetchHookAndBranch(inp DescribeInput) (*document.Document, *hook.Hook, error) {
|
||||
hk, err := inp.FetchHook(a.DocumentID, a.HookID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: fetch hook: %w", name, err)
|
||||
return nil, nil, fmt.Errorf("fetch hook: %w", err)
|
||||
}
|
||||
|
||||
var doc *document.Document
|
||||
|
||||
if hk.BranchID.Valid {
|
||||
doc, err = inp.FetchBranch(ref.DocumentID, hk.BranchID.V)
|
||||
doc, err = inp.FetchBranch(a.DocumentID, hk.BranchID.V)
|
||||
} else {
|
||||
doc, err = inp.FetchDocument(ref.DocumentID)
|
||||
doc, err = inp.FetchDocument(a.DocumentID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: fetch document: %w", name, err)
|
||||
return nil, nil, fmt.Errorf("fetch document: %w", err)
|
||||
}
|
||||
|
||||
return doc, hk, nil
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package tools
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -121,7 +122,6 @@ func Test_hookSettingsSchema(t *testing.T) {
|
|||
tp := want[i]
|
||||
require.NoError(t, tp.Validate())
|
||||
|
||||
assert.Equal(t, string(tp), v["title"])
|
||||
assert.Equal(t, false, v["additionalProperties"])
|
||||
|
||||
props, pok := v["properties"].(map[string]any)
|
||||
|
|
@ -254,36 +254,47 @@ func Test_decodeHookSettings(t *testing.T) {
|
|||
func Test_decodeSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var uw processor.URLWatcher
|
||||
|
||||
// an absent payload decodes as an empty one.
|
||||
require.NoError(t, decodeSettings(hook.TypeURLWatcher, nil, &uw))
|
||||
assert.Empty(t, uw.URL)
|
||||
|
||||
require.NoError(t, decodeSettings(hook.TypeURLWatcher, json.RawMessage(`{"url":"https://example.com"}`), &uw))
|
||||
assert.Equal(t, "https://example.com", uw.URL)
|
||||
|
||||
err := decodeSettings(hook.TypeURLWatcher, json.RawMessage(`{"colour":"red"}`), &uw)
|
||||
assert.Equal(t, fmt.Errorf("%s is not a %s setting", "colour", hook.TypeURLWatcher), err)
|
||||
|
||||
// a value of the wrong shape is reported with its path.
|
||||
err = decodeSettings(hook.TypeURLWatcher, json.RawMessage(`{"url":1}`), &uw)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid settings")
|
||||
|
||||
require.Error(t, decodeSettings(hook.TypeURLWatcher, json.RawMessage(`{`), &uw))
|
||||
}
|
||||
|
||||
func Test_listHooksArgs_Validate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertValidate(t,
|
||||
listHooksArgs{docTarget{DocumentID: _testDocID, BranchID: _stubMainBranchID}},
|
||||
map[string]Args{
|
||||
"document_id": listHooksArgs{docTarget{BranchID: _stubMainBranchID}},
|
||||
"branch_id": listHooksArgs{docTarget{DocumentID: _testDocID}},
|
||||
cc := map[string]struct {
|
||||
Raw string
|
||||
Result processor.URLWatcher
|
||||
Err error
|
||||
}{
|
||||
"Successful decode": {
|
||||
Raw: `{"url":"https://example.com"}`,
|
||||
Result: processor.URLWatcher{URL: "https://example.com"},
|
||||
},
|
||||
)
|
||||
"Type beside the settings": {
|
||||
Raw: `{"type":"url-watcher","url":"https://example.com"}`,
|
||||
Result: processor.URLWatcher{URL: "https://example.com"},
|
||||
},
|
||||
"Unknown field": {
|
||||
Raw: `{"colour":"red"}`,
|
||||
Err: fmt.Errorf("%s is not a %s setting", "colour", hook.TypeURLWatcher),
|
||||
},
|
||||
"Value of the wrong shape": {
|
||||
Raw: `{"url":1}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Malformed payload": {
|
||||
Raw: `{`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := decodeSettings[processor.URLWatcher](hook.TypeURLWatcher, json.RawMessage(c.Raw))
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Result, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_listHooks_Info(t *testing.T) {
|
||||
|
|
@ -291,26 +302,14 @@ func Test_listHooks_Info(t *testing.T) {
|
|||
|
||||
info := listHooks{}.Info()
|
||||
|
||||
assert.Equal(t, Traits{}, info.Traits)
|
||||
|
||||
assert.Equal(t, NameListHooks, info.Name)
|
||||
assert.NotEmpty(t, info.Description)
|
||||
assert.Equal(t, []string{"document_id", "branch_id"}, info.Required)
|
||||
assert.Contains(t, info.Properties, "branch_id")
|
||||
}
|
||||
|
||||
func Test_listHooks_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, Traits{}, listHooks{}.Traits())
|
||||
}
|
||||
|
||||
func Test_listHooks_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := listHooks{}.Title(testInput(testDeps(nil, nil, nil), NameListHooks, `{}`))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func Test_listHooks_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -429,6 +428,8 @@ func Test_createHook_Info(t *testing.T) {
|
|||
|
||||
info := createHook{}.Info()
|
||||
|
||||
assert.Equal(t, Traits{Write: true}, info.Traits)
|
||||
|
||||
assert.Equal(t, NameCreateHook, info.Name)
|
||||
assert.NotEmpty(t, info.Description)
|
||||
assert.Equal(t, []string{"document_id", "branch_id", "settings"}, info.Required)
|
||||
|
|
@ -446,32 +447,6 @@ func Test_createHook_Info(t *testing.T) {
|
|||
assert.Contains(t, info.Description, "integrations")
|
||||
}
|
||||
|
||||
func Test_createHook_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, Traits{Write: true}, createHook{}.Traits())
|
||||
}
|
||||
|
||||
func Test_createHook_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scheduled := `,"settings":{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `"}`
|
||||
|
||||
got, err := createHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`+targetArgs(_stubMainBranchID)+scheduled+`}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Adding a hook to Runbook", got)
|
||||
|
||||
got, err = createHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`+targetArgs(_stubBranchID)+scheduled+`}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Adding a hook to Runbook on branch draft", got)
|
||||
|
||||
_, err = createHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameCreateHook, requiredArgs(t, NameCreateHook)))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = createHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_createHook_Summary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -531,7 +506,7 @@ func Test_createHook_Execute(t *testing.T) {
|
|||
"Another type's field is refused": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + targetArgs(_stubBranchID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `","url":"https://example.com"}}`,
|
||||
Err: fmt.Errorf("%s: %w", NameCreateHook, fmt.Errorf("%s is not a %s setting", "url", hook.TypeScheduledReminder)),
|
||||
Err: fmt.Errorf("%s is not a %s setting", "url", hook.TypeScheduledReminder),
|
||||
},
|
||||
"Unknown branch": {
|
||||
DB: stubHookDB(),
|
||||
|
|
@ -542,17 +517,17 @@ func Test_createHook_Execute(t *testing.T) {
|
|||
DB: stubHookDB(),
|
||||
Man: blockless,
|
||||
Args: `{` + targetArgs(_stubBranchID) + `,` + scheduled + `,"block_uid":"nope"}`,
|
||||
Err: fmt.Errorf("create_hook: %w", fmt.Errorf("block %s: %w", "nope", errUnknownBlock)),
|
||||
Err: fmt.Errorf("block %s: %w", "nope", errUnknownBlock),
|
||||
},
|
||||
"GitHub tracking without the app": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + targetArgs(_stubBranchID) + `,"settings":{"type":"github-tracking","repository":"r","branch":"main","paths":["a"]}}`,
|
||||
Err: fmt.Errorf("create_hook: %w", fmt.Errorf("%s: %w", hook.TypeGithubTracking, github.ErrNotConfigured)),
|
||||
Err: fmt.Errorf("%s: %w", hook.TypeGithubTracking, github.ErrNotConfigured),
|
||||
},
|
||||
"URL watcher without changedetection": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + targetArgs(_stubBranchID) + `,"settings":{"type":"url-watcher","url":"https://example.com"}}`,
|
||||
Err: fmt.Errorf("create_hook: %w", fmt.Errorf("%s: %w", hook.TypeURLWatcher, webchange.ErrNotConfigured)),
|
||||
Err: fmt.Errorf("%s: %w", hook.TypeURLWatcher, webchange.ErrNotConfigured),
|
||||
},
|
||||
"Error returned by hookMan.CreateHook": {
|
||||
DB: stubHookDB(),
|
||||
|
|
@ -592,7 +567,7 @@ func Test_createHook_Execute(t *testing.T) {
|
|||
assert.Equal(t, _stubBranchID, ff[0].Ci.BranchID)
|
||||
assert.Equal(t, "org", ff[0].OrganizationID)
|
||||
assert.Equal(t, c.BlockUID, ff[0].Ci.BlockID)
|
||||
assert.Equal(t, inp.UserID(), ff[0].UpdatedBy)
|
||||
assert.Equal(t, inp.userID, ff[0].UpdatedBy)
|
||||
|
||||
row := decodeHookRow(t, res)
|
||||
assert.False(t, row.ID.IsNil())
|
||||
|
|
@ -601,8 +576,6 @@ func Test_createHook_Execute(t *testing.T) {
|
|||
assert.JSONEq(t, `{"scale":"linear","duration":"custom","schedule":"`+_stubSchedule+`"}`, string(row.Settings))
|
||||
assert.Equal(t, "100", row.Score.String())
|
||||
assert.Contains(t, string(row.State), "startedAt")
|
||||
|
||||
assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -611,18 +584,67 @@ func Test_updateHookArgs_Validate(t *testing.T) {
|
|||
t.Parallel()
|
||||
|
||||
settings := json.RawMessage(`{"type":"url-watcher","url":"https://example.com"}`)
|
||||
ref := hookRefArgs{DocumentID: _testDocID, HookID: _testHookID}
|
||||
|
||||
// the settings are checked against the hook's own type later, so any
|
||||
// type's complete settings pass here.
|
||||
// type's complete settings pass here, and none at all is a reset.
|
||||
assertValidate(t,
|
||||
updateHookArgs{DocumentID: _testDocID, HookID: _testHookID, Settings: settings},
|
||||
updateHookArgs{hookRefArgs: ref, Settings: settings},
|
||||
map[string]Args{
|
||||
"document_id": updateHookArgs{hookRefArgs: hookRefArgs{HookID: _testHookID}, Settings: settings},
|
||||
"hook_id": updateHookArgs{hookRefArgs: hookRefArgs{DocumentID: _testDocID}, Settings: settings},
|
||||
"settings.type": updateHookArgs{hookRefArgs: hookRefArgs{DocumentID: _testDocID, HookID: _testHookID}},
|
||||
"url": updateHookArgs{hookRefArgs: hookRefArgs{DocumentID: _testDocID, HookID: _testHookID}, Settings: json.RawMessage(`{"type":"url-watcher"}`)},
|
||||
"document_id": updateHookArgs{hookRefArgs: hookRefArgs{HookID: _testHookID}, Settings: settings},
|
||||
"hook_id": updateHookArgs{hookRefArgs: hookRefArgs{DocumentID: _testDocID}, Settings: settings},
|
||||
"url": updateHookArgs{hookRefArgs: ref, Settings: json.RawMessage(`{"type":"url-watcher"}`)},
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, updateHookArgs{hookRefArgs: ref}.Validate())
|
||||
require.NoError(t, updateHookArgs{hookRefArgs: ref, Settings: json.RawMessage(` null `)}.Validate())
|
||||
}
|
||||
|
||||
func Test_updateHookArgs_settingsFor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Settings string
|
||||
Result string
|
||||
Err error
|
||||
}{
|
||||
"Invalid settings": {
|
||||
Settings: `{}`,
|
||||
Err: errRequired("settings.type"),
|
||||
},
|
||||
"Another type's settings": {
|
||||
Settings: `{"type":"url-watcher","url":"https://example.com"}`,
|
||||
Err: errors.New("the hook is a scheduled-reminder, not a url-watcher; to change the type, delete it and create another"),
|
||||
},
|
||||
"Settings of the hook's type": {
|
||||
Settings: `{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `"}`,
|
||||
Result: `{"scale":"linear","duration":"custom","schedule":"` + _stubSchedule + `"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := updateHookArgs{Settings: json.RawMessage(c.Settings)}.settingsFor(stubHook())
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.JSONEq(t, c.Result, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_updateHookArgs_replacesSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.False(t, updateHookArgs{}.replacesSettings())
|
||||
assert.False(t, updateHookArgs{Settings: json.RawMessage("null")}.replacesSettings())
|
||||
assert.True(t, updateHookArgs{Settings: json.RawMessage(`{"type":"url-watcher"}`)}.replacesSettings())
|
||||
}
|
||||
|
||||
func Test_updateHook_Info(t *testing.T) {
|
||||
|
|
@ -630,120 +652,148 @@ func Test_updateHook_Info(t *testing.T) {
|
|||
|
||||
info := updateHook{}.Info()
|
||||
|
||||
assert.Equal(t, Traits{Write: true}, info.Traits)
|
||||
|
||||
assert.Equal(t, NameUpdateHook, info.Name)
|
||||
assert.NotEmpty(t, info.Description)
|
||||
assert.Equal(t, []string{"document_id", "hook_id", "settings"}, info.Required)
|
||||
assert.Equal(t, []string{"document_id", "hook_id"}, info.Required)
|
||||
|
||||
for _, key := range []string{"document_id", "hook_id", "settings"} {
|
||||
assert.Contains(t, info.Properties, key)
|
||||
}
|
||||
|
||||
assert.Equal(t, hookSettingsSchema(), info.Properties["settings"])
|
||||
assert.NotContains(t, info.Properties, "type")
|
||||
|
||||
// a hook keeps its type, and the description says how to change it.
|
||||
assert.Contains(t, info.Description, "delete the hook and create another")
|
||||
}
|
||||
|
||||
func Test_updateHook_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, Traits{Write: true}, updateHook{}.Traits())
|
||||
}
|
||||
|
||||
func Test_updateHook_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := updateHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_testHookID)+`,"settings":{"type":"scheduled-reminder","schedule":"`+_stubSchedule+`"}}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updating a hook on Runbook", got)
|
||||
|
||||
_, err = updateHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameUpdateHook, requiredArgs(t, NameUpdateHook)))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = updateHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_updateHook_Summary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_testHookID)+`,"settings":{"type":"scheduled-reminder","schedule":"`+_stubSchedule+`"}}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ActionSummary{
|
||||
Tool: NameUpdateHook,
|
||||
DocumentID: _testDocID,
|
||||
DocumentName: "Runbook",
|
||||
Summary: "Update the Scheduled Reminder hook on Runbook on branch draft",
|
||||
}, got)
|
||||
cc := map[string]struct {
|
||||
Args string
|
||||
Result string
|
||||
Err error
|
||||
}{
|
||||
"New settings": {
|
||||
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `"}}`,
|
||||
Result: "Update the Scheduled Reminder hook on Runbook on branch draft",
|
||||
},
|
||||
"A reset": {
|
||||
Args: `{` + hookArgs(_testHookID) + `}`,
|
||||
Result: "Reset the Scheduled Reminder hook on Runbook on branch draft",
|
||||
},
|
||||
// settings of another type are refused here, so the user is not
|
||||
// asked to approve a call that cannot run.
|
||||
"Another type's settings": {
|
||||
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"url-watcher","url":"https://example.com"}}`,
|
||||
Err: errors.New("the hook is a scheduled-reminder, not a url-watcher; to change the type, delete it and create another"),
|
||||
},
|
||||
"Unknown hook": {
|
||||
Args: `{` + hookArgs(_unknownHookID) + `}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Malformed arguments": {Args: `{`, Err: assert.AnError},
|
||||
}
|
||||
|
||||
// settings of another type are refused here, so the user is not
|
||||
// asked to approve a call that cannot run.
|
||||
_, err = updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_testHookID)+`,"settings":{"type":"url-watcher","url":"https://example.com"}}`))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, fmt.Errorf("%s: %w", NameUpdateHook, errHookTypeMismatch(hook.TypeScheduledReminder, hook.TypeURLWatcher)), err)
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err = updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_unknownHookID)+`,"settings":{"type":"scheduled-reminder","schedule":"`+_stubSchedule+`"}}`))
|
||||
require.Error(t, err)
|
||||
got, err := updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, c.Args))
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
_, err = updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`))
|
||||
require.Error(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, ActionSummary{
|
||||
Tool: NameUpdateHook,
|
||||
DocumentID: _testDocID,
|
||||
DocumentName: "Runbook",
|
||||
Summary: c.Result,
|
||||
}, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_updateHook_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
failing := stubHookManager()
|
||||
failing.UpdateHookFunc = func(context.Context, xid.ID, xid.ID, string, hook.UpdateInput, string) (*hook.Hook, error) {
|
||||
failingUpdate := stubHookManager()
|
||||
failingUpdate.UpdateHookFunc = func(context.Context, xid.ID, xid.ID, string, hook.UpdateInput, string) (*hook.Hook, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
|
||||
failingReset := stubHookManager()
|
||||
failingReset.ResetHookFunc = func(context.Context, xid.ID, xid.ID, string) (*hook.Hook, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
|
||||
later := time.Date(2031, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
Man *HookManagerMock
|
||||
Args string
|
||||
Err error
|
||||
DB *DBMock
|
||||
Man *HookManagerMock
|
||||
Args string
|
||||
Updates int
|
||||
Resets int
|
||||
Settings string
|
||||
Err error
|
||||
}{
|
||||
"Malformed arguments": {DB: stubHookDB(), Args: `{`, Err: assert.AnError},
|
||||
"Hook id is required": {DB: stubHookDB(), Args: `{"document_id":"` + _testDocID.String() + `"}`, Err: assert.AnError},
|
||||
"Unknown hook": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_unknownHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `"}}`,
|
||||
Err: fmt.Errorf("update_hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)),
|
||||
Err: fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook),
|
||||
},
|
||||
"Settings are required": {
|
||||
"Settings without a type": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_testHookID) + `}`,
|
||||
Err: fmt.Errorf("%s: %w", NameUpdateHook, errRequired("settings"+"."+"type")),
|
||||
Args: `{` + hookArgs(_testHookID) + `,"settings":{}}`,
|
||||
Err: errRequired("settings" + "." + "type"),
|
||||
},
|
||||
"Schedule is required for a scheduled reminder": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder"}}`,
|
||||
Err: fmt.Errorf("%s: %w", NameUpdateHook, errRequired("schedule")),
|
||||
Err: errRequired("schedule"),
|
||||
},
|
||||
"Another type's field is refused": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `","image":"nginx:1"}}`,
|
||||
Err: fmt.Errorf("%s: %w", NameUpdateHook, fmt.Errorf("%s is not a %s setting", "image", hook.TypeScheduledReminder)),
|
||||
Err: fmt.Errorf("%s is not a %s setting", "image", hook.TypeScheduledReminder),
|
||||
},
|
||||
// a hook keeps its type for life, so complete settings of another
|
||||
// type are refused once the hook is known.
|
||||
"Another type's settings are refused": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"url-watcher","url":"https://example.com"}}`,
|
||||
Err: fmt.Errorf("update_hook: %w", errHookTypeMismatch(hook.TypeScheduledReminder, hook.TypeURLWatcher)),
|
||||
Err: errors.New("the hook is a scheduled-reminder, not a url-watcher; to change the type, delete it and create another"),
|
||||
},
|
||||
"Error returned by hookMan.UpdateHook": {
|
||||
DB: stubHookDB(),
|
||||
Man: failing,
|
||||
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + later.Format(time.RFC3339) + `"}}`,
|
||||
Err: assert.AnError,
|
||||
DB: stubHookDB(),
|
||||
Man: failingUpdate,
|
||||
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + later.Format(time.RFC3339) + `"}}`,
|
||||
Updates: 1,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Error returned by hookMan.ResetHook": {
|
||||
DB: stubHookDB(),
|
||||
Man: failingReset,
|
||||
Args: `{` + hookArgs(_testHookID) + `}`,
|
||||
Resets: 1,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Updated": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + later.Format(time.RFC3339) + `"}}`,
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + later.Format(time.RFC3339) + `"}}`,
|
||||
Updates: 1,
|
||||
Settings: `{"scale":"linear","duration":"custom","schedule":"2031-06-01T12:00:00Z"}`,
|
||||
},
|
||||
"Reset, keeping the settings": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_testHookID) + `}`,
|
||||
Resets: 1,
|
||||
Settings: string(stubHook().Settings),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -757,21 +807,18 @@ func Test_updateHook_Execute(t *testing.T) {
|
|||
res, err := updateHook{}.Execute(inp)
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
man := d.hookMan.(*HookManagerMock)
|
||||
assert.Len(t, man.UpdateHookCalls(), c.Updates)
|
||||
assert.Len(t, man.ResetHookCalls(), c.Resets)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ff := d.hookMan.(*HookManagerMock).UpdateHookCalls()
|
||||
require.Len(t, ff, 1)
|
||||
assert.Equal(t, _testHookID, ff[0].ID)
|
||||
assert.JSONEq(t, `{"scale":"linear","duration":"custom","schedule":"2031-06-01T12:00:00Z"}`, string(ff[0].UI.Settings))
|
||||
|
||||
row := decodeHookRow(t, res)
|
||||
assert.Equal(t, _testHookID, row.ID)
|
||||
assert.JSONEq(t, string(ff[0].UI.Settings), string(row.Settings))
|
||||
assert.JSONEq(t, c.Settings, string(row.Settings))
|
||||
assert.Equal(t, null.TimeFrom(_stubScheduleTime), row.UpdatedAt)
|
||||
|
||||
assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -788,157 +835,19 @@ func Test_hookRefArgs_Validate(t *testing.T) {
|
|||
)
|
||||
}
|
||||
|
||||
func Test_hookRefProps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := hookRefProps()
|
||||
|
||||
assert.Contains(t, got, "document_id")
|
||||
assert.Contains(t, got, "hook_id")
|
||||
}
|
||||
|
||||
func Test_resetHook_Info(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
info := resetHook{}.Info()
|
||||
|
||||
assert.Equal(t, NameResetHook, info.Name)
|
||||
assert.NotEmpty(t, info.Description)
|
||||
assert.Equal(t, []string{"document_id", "hook_id"}, info.Required)
|
||||
assert.Contains(t, info.Properties, "hook_id")
|
||||
}
|
||||
|
||||
func Test_resetHook_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, Traits{Write: true}, resetHook{}.Traits())
|
||||
}
|
||||
|
||||
func Test_resetHook_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := resetHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`+hookArgs(_testHookID)+`}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Resetting a hook on Runbook", got)
|
||||
|
||||
_, err = resetHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameResetHook, requiredArgs(t, NameResetHook)))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = resetHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_resetHook_Summary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := resetHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`+hookArgs(_testHookID)+`}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ActionSummary{
|
||||
Tool: NameResetHook,
|
||||
DocumentID: _testDocID,
|
||||
DocumentName: "Runbook",
|
||||
Summary: "Reset the Scheduled Reminder hook on Runbook on branch draft",
|
||||
}, got)
|
||||
|
||||
_, err = resetHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`+hookArgs(_unknownHookID)+`}`))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = resetHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_resetHook_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
failing := stubHookManager()
|
||||
failing.ResetHookFunc = func(context.Context, xid.ID, xid.ID, string) (*hook.Hook, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
Man *HookManagerMock
|
||||
Args string
|
||||
Err error
|
||||
}{
|
||||
"Malformed arguments": {DB: stubHookDB(), Args: `{`, Err: assert.AnError},
|
||||
"Hook id is required": {DB: stubHookDB(), Args: `{"document_id":"` + _testDocID.String() + `"}`, Err: assert.AnError},
|
||||
"Unknown hook": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_unknownHookID) + `}`,
|
||||
Err: fmt.Errorf("reset_hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)),
|
||||
},
|
||||
"Error returned by hookMan.ResetHook": {
|
||||
DB: stubHookDB(),
|
||||
Man: failing,
|
||||
Args: `{` + hookArgs(_testHookID) + `}`,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Reset": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_testHookID) + `}`,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := hookDeps(c.DB, c.Man)
|
||||
inp := testInput(d, NameResetHook, c.Args)
|
||||
|
||||
res, err := resetHook{}.Execute(inp)
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ff := d.hookMan.(*HookManagerMock).ResetHookCalls()
|
||||
require.Len(t, ff, 1)
|
||||
assert.Equal(t, _testHookID, ff[0].ID)
|
||||
|
||||
row := decodeHookRow(t, res)
|
||||
assert.Equal(t, _testHookID, row.ID)
|
||||
assert.JSONEq(t, string(stubHook().Settings), string(row.Settings))
|
||||
assert.Equal(t, null.TimeFrom(_stubScheduleTime), row.UpdatedAt)
|
||||
|
||||
assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_deleteHook_Info(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
info := deleteHook{}.Info()
|
||||
|
||||
assert.Equal(t, Traits{Write: true, Destructive: true}, info.Traits)
|
||||
|
||||
assert.Equal(t, NameDeleteHook, info.Name)
|
||||
assert.NotEmpty(t, info.Description)
|
||||
assert.Equal(t, []string{"document_id", "hook_id"}, info.Required)
|
||||
assert.Contains(t, info.Properties, "hook_id")
|
||||
}
|
||||
|
||||
func Test_deleteHook_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, Traits{Write: true, Destructive: true}, deleteHook{}.Traits())
|
||||
}
|
||||
|
||||
func Test_deleteHook_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := deleteHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameDeleteHook, `{`+hookArgs(_testHookID)+`}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Deleting a hook on Runbook", got)
|
||||
|
||||
_, err = deleteHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameDeleteHook, requiredArgs(t, NameDeleteHook)))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = deleteHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameDeleteHook, `{`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_deleteHook_Summary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -977,7 +886,7 @@ func Test_deleteHook_Execute(t *testing.T) {
|
|||
"Unknown hook": {
|
||||
DB: stubHookDB(),
|
||||
Args: `{` + hookArgs(_unknownHookID) + `}`,
|
||||
Err: fmt.Errorf("delete_hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)),
|
||||
Err: fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook),
|
||||
},
|
||||
"Error returned by hookMan.DeleteHook": {
|
||||
DB: stubHookDB(),
|
||||
|
|
@ -1011,12 +920,11 @@ func Test_deleteHook_Execute(t *testing.T) {
|
|||
assert.JSONEq(t, `{"hook_id":"`+_testHookID.String()+`","deleted":true}`, res)
|
||||
|
||||
// the branch stays, and the editor showing it has to redraw.
|
||||
assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_describeHook(t *testing.T) {
|
||||
func Test_hookRefArgs_fetchHookAndBranch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
branchless := stubHookDB()
|
||||
|
|
@ -1041,7 +949,7 @@ func Test_describeHook(t *testing.T) {
|
|||
"Unknown hook": {
|
||||
DB: stubHookDB(),
|
||||
HookID: _unknownHookID,
|
||||
Err: fmt.Errorf("%s: fetch hook: %w", NameResetHook, fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)),
|
||||
Err: fmt.Errorf("fetch hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)),
|
||||
},
|
||||
"Error returned by the branch fetch": {
|
||||
DB: failingDoc,
|
||||
|
|
@ -1066,7 +974,7 @@ func Test_describeHook(t *testing.T) {
|
|||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc, hk, err := describeHook(testInput(testDeps(c.DB, nil, nil), NameResetHook, `{}`), NameResetHook, hookRefArgs{DocumentID: _testDocID, HookID: c.HookID})
|
||||
doc, hk, err := hookRefArgs{DocumentID: _testDocID, HookID: c.HookID}.fetchHookAndBranch(testInput(testDeps(c.DB, nil, nil), NameUpdateHook, `{}`))
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/oxynote/server/core/internal/apps/github"
|
||||
"github.com/oxynote/oxynote/server/core/internal/apps/webchange"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/block"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
|
||||
"github.com/oxynote/oxynote/server/core/internal/datasource"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
|
|
@ -43,7 +41,7 @@ var errUnknownHook = errors.New("no hook with that id on this document; call lis
|
|||
// tool reaches through and the (organization, user) pair every call is
|
||||
// scoped to.
|
||||
//
|
||||
// It is built once per session. The per-call Input a tool actually sees
|
||||
// It is built once per session. The per-call input a tool actually sees
|
||||
// is assembled from it by the eino adapter, which is the only thing
|
||||
// that knows a call's context and arguments.
|
||||
type Deps struct {
|
||||
|
|
@ -142,14 +140,13 @@ func NewDeps(
|
|||
}
|
||||
|
||||
// input is one tool call: the session's wiring plus the context and
|
||||
// arguments of the call being served. It satisfies both DescribeInput
|
||||
// and Input; which of the two a tool receives is decided by the method
|
||||
// being called, not by what is in here.
|
||||
// arguments of the call being served. Execute receives it whole; Title
|
||||
// and Summary receive it as a DescribeInput, which reaches nothing that
|
||||
// writes.
|
||||
type input struct {
|
||||
*Deps
|
||||
|
||||
// name is the tool being called, so a rejected argument can say
|
||||
// which tool rejected it.
|
||||
// name is the tool being called, so a warning names it.
|
||||
name Name
|
||||
|
||||
// ctx is the context of this call. It carries the agent session
|
||||
|
|
@ -160,22 +157,10 @@ type input struct {
|
|||
// args is the raw JSON the model supplied.
|
||||
args json.RawMessage
|
||||
|
||||
// touched lists the branches this call changed, in the order the
|
||||
// writes below recorded them.
|
||||
touched []Touched
|
||||
}
|
||||
|
||||
// recordTouched notes a branch this call changed. Every write in this
|
||||
// package goes through one of the four methods below, so recording it
|
||||
// here is what makes Result.Documents right by construction rather than
|
||||
// by a convention about argument names.
|
||||
func (i *input) recordTouched(documentID, branchID xid.ID) {
|
||||
t := Touched{DocumentID: documentID, BranchID: branchID}
|
||||
if documentID.IsNil() || branchID.IsNil() || slices.Contains(i.touched, t) {
|
||||
return
|
||||
}
|
||||
|
||||
i.touched = append(i.touched, t)
|
||||
// branches holds the branches this call has fetched, by branch id.
|
||||
// A call reads its branch in several steps, and they share one
|
||||
// fetch.
|
||||
branches map[xid.ID]*document.Document
|
||||
}
|
||||
|
||||
// newInput creates a fresh instance of input for one tool call.
|
||||
|
|
@ -188,8 +173,7 @@ func (i *input) Context() context.Context {
|
|||
return i.ctx
|
||||
}
|
||||
|
||||
// Decode decodes the call's arguments into dst and validates them,
|
||||
// naming the tool that rejected them.
|
||||
// Decode decodes the call's arguments into dst and validates them.
|
||||
//
|
||||
// Decoding uses json/v2 because its errors name the argument they
|
||||
// failed on. A domain type that parses itself — an id, a timestamp, an
|
||||
|
|
@ -206,24 +190,10 @@ func (i *input) Decode(dst Args) error {
|
|||
}
|
||||
|
||||
if err := jsonv2.Unmarshal(args, dst); err != nil {
|
||||
return fmt.Errorf("%s: invalid input: %w", i.name, err)
|
||||
return fmt.Errorf("invalid input: %w", err)
|
||||
}
|
||||
|
||||
if err := dst.Validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", i.name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// OrganizationID returns the organisation every call is scoped to.
|
||||
func (i *input) OrganizationID() string {
|
||||
return i.orgID
|
||||
}
|
||||
|
||||
// UserID returns the user the assistant is acting for.
|
||||
func (i *input) UserID() string {
|
||||
return i.userID
|
||||
return dst.Validate()
|
||||
}
|
||||
|
||||
// FetchDataSource returns the data source the id names.
|
||||
|
|
@ -234,7 +204,11 @@ func (i *input) UserID() string {
|
|||
func (i *input) FetchDataSource(dataSourceID xid.ID) (*datasource.DataSource, error) {
|
||||
ds, err := i.db.FetchDataSource(i.ctx, dataSourceID, i.orgID)
|
||||
if err != nil {
|
||||
return nil, errUnknownDataSource
|
||||
if errutil.IsNotFound(err) {
|
||||
return nil, errUnknownDataSource
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("fetching data source: %w", err)
|
||||
}
|
||||
|
||||
return ds, nil
|
||||
|
|
@ -245,24 +219,30 @@ func (i *input) FetchDataSources() ([]datasource.DataSource, error) {
|
|||
return i.db.FetchDataSources(i.ctx, i.orgID)
|
||||
}
|
||||
|
||||
// CheckDataSources refuses a write naming a data source the
|
||||
// organisation does not own.
|
||||
// CheckDataSources refuses blocks, nested ones included, that name a
|
||||
// data source the organisation does not own.
|
||||
//
|
||||
// It sits with the write rather than with the schema because it is the
|
||||
// only check that needs the database: block.Validate can say the id is
|
||||
// only check that needs the database: markup.Build can say the id is
|
||||
// a string, but only a lookup can say it addresses something, and a
|
||||
// metric block pointing at nothing renders as a broken chart the user
|
||||
// then has to fix by hand. The ids are block content, not arguments, so
|
||||
// they arrive as the strings the document stores and are parsed here.
|
||||
func (i *input) CheckDataSources(ids []string) error {
|
||||
for _, raw := range ids {
|
||||
id, err := xid.FromString(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("metric %s %q: %w", document.AttrDataSourceID, raw, err)
|
||||
// then has to fix by hand. The ids are the strings the blocks store, so
|
||||
// they are parsed here.
|
||||
func (i *input) CheckDataSources(blocks []document.Block) error {
|
||||
for _, b := range blocks {
|
||||
if raw := b.Attrs.Get(document.AttrDataSourceID).String(); raw != "" {
|
||||
id, err := xid.FromString(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("metric %s %q: %w", document.AttrDataSourceID, raw, err)
|
||||
}
|
||||
|
||||
if _, err := i.FetchDataSource(id); err != nil {
|
||||
return fmt.Errorf("metric %s %q: %w", document.AttrDataSourceID, raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := i.FetchDataSource(id); err != nil {
|
||||
return fmt.Errorf("metric %s %q: %w", document.AttrDataSourceID, raw, err)
|
||||
if err := i.CheckDataSources(b.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,15 +250,11 @@ func (i *input) CheckDataSources(ids []string) error {
|
|||
}
|
||||
|
||||
// DataSourceRunner returns the runner that reads the data source the id
|
||||
// names.
|
||||
//
|
||||
// The lookup is the cross-org safety check: FetchDataSource scopes by
|
||||
// organisation, so an id belonging to another one is as absent as an id
|
||||
// belonging to nobody, and the model is told the same thing either way.
|
||||
// names, looked up through FetchDataSource.
|
||||
func (i *input) DataSourceRunner(id xid.ID) (datasource.Runner, error) {
|
||||
ds, err := i.db.FetchDataSource(i.ctx, id, i.orgID)
|
||||
ds, err := i.FetchDataSource(id)
|
||||
if err != nil {
|
||||
return nil, errUnknownDataSource
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return i.runners.Runner(*ds), nil
|
||||
|
|
@ -306,6 +282,10 @@ func (i *input) FetchDocument(documentID xid.ID) (*document.Document, error) {
|
|||
// as unknown to this one, and an unknown branch is reported with the
|
||||
// ones the document does have.
|
||||
func (i *input) FetchBranch(documentID, branchID xid.ID) (*document.Document, error) {
|
||||
if doc, ok := i.branches[branchID]; ok && doc.ID == documentID {
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
doc, err := i.db.FetchDocumentByBranchID(i.ctx, branchID, i.orgID)
|
||||
if err != nil {
|
||||
if errutil.IsNotFound(err) {
|
||||
|
|
@ -319,6 +299,12 @@ func (i *input) FetchBranch(documentID, branchID xid.ID) (*document.Document, er
|
|||
return nil, i.unknownBranch(documentID, branchID)
|
||||
}
|
||||
|
||||
if i.branches == nil {
|
||||
i.branches = map[xid.ID]*document.Document{}
|
||||
}
|
||||
|
||||
i.branches[branchID] = doc
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
|
|
@ -331,7 +317,7 @@ func (i *input) unknownBranch(documentID, branchID xid.ID) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("branch %s: %w; the branches are %s", branchID, ErrUnknownBranch, strings.Join(branchLabels(branches, nil), ", "))
|
||||
return fmt.Errorf("branch %s: %w; the branches are %s", branchID, ErrUnknownBranch, branchLabels(branches))
|
||||
}
|
||||
|
||||
// FetchDocumentBranches lists every branch of the document. A document the
|
||||
|
|
@ -350,33 +336,15 @@ func (i *input) FetchDocumentBranches(documentID xid.ID) ([]document.BranchSumma
|
|||
return branches, nil
|
||||
}
|
||||
|
||||
// FetchDocumentContent returns the parsed content of the branch branchID
|
||||
// names. The branch fetch already carries the content, so this is the
|
||||
// same lookup in the shape the block walks take.
|
||||
func (i *input) FetchDocumentContent(documentID, branchID xid.ID) (document.Content, error) {
|
||||
doc, err := i.FetchBranch(documentID, branchID)
|
||||
if err != nil {
|
||||
return document.Content{}, err
|
||||
}
|
||||
|
||||
return document.Content{
|
||||
OrganizationID: i.orgID,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Content: doc.Content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchDocumentBlock finds one block of the branch by uid. It reads the
|
||||
// persisted content, which is what every placement check reads too, so
|
||||
// a write and its checks see the same document.
|
||||
// FetchDocumentBlock finds one block of the branch by uid, in the
|
||||
// persisted content.
|
||||
func (i *input) FetchDocumentBlock(documentID, branchID xid.ID, blockUID string) (document.Block, error) {
|
||||
content, err := i.FetchDocumentContent(documentID, branchID)
|
||||
doc, err := i.FetchBranch(documentID, branchID)
|
||||
if err != nil {
|
||||
return document.Block{}, fmt.Errorf("fetching content: %w", err)
|
||||
}
|
||||
|
||||
b, ok := content.Content.FindByUID(blockUID)
|
||||
b, ok := doc.Content.FindByUID(blockUID)
|
||||
if !ok {
|
||||
return document.Block{}, fmt.Errorf("block %s: %w", blockUID, errUnknownBlock)
|
||||
}
|
||||
|
|
@ -423,7 +391,11 @@ func (i *input) FetchDocumentChildren(parentID null.Value[xid.ID]) (document.Sum
|
|||
func (i *input) CreateDocument(doc document.Document) error {
|
||||
if doc.ParentID.Valid {
|
||||
if err := i.db.CheckDocumentExists(i.ctx, doc.ParentID.V, i.orgID); err != nil {
|
||||
return fmt.Errorf("parent %s: %w", doc.ParentID.V, errUnknownParent(err))
|
||||
if errutil.IsNotFound(err) {
|
||||
return fmt.Errorf("parent %s: %w", doc.ParentID.V, errUnknownParent)
|
||||
}
|
||||
|
||||
return fmt.Errorf("checking parent: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -456,7 +428,7 @@ func (i *input) CreateDocument(doc document.Document) error {
|
|||
}
|
||||
|
||||
i.searchTrigger.Trigger()
|
||||
i.recordTouched(doc.ID, doc.BranchID)
|
||||
i.notifyTreeChange(doc.ParentID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -470,12 +442,10 @@ var errCyclicParent = errors.New("a document cannot be moved under itself or one
|
|||
// current uids, since a stale uid is the usual cause.
|
||||
var errUnknownBlock = errors.New("no block with that uid in this document; call get_document for the current uids")
|
||||
|
||||
// errUnknownParent wraps a parent lookup failure so the model learns
|
||||
// where the ids it may use come from; a missing parent is otherwise a
|
||||
// bare not-found.
|
||||
func errUnknownParent(err error) error {
|
||||
return fmt.Errorf("no document with that id to use as parent; call list_documents for the ids that exist: %w", err)
|
||||
}
|
||||
// errUnknownParent is what a create or move reports for a parent id
|
||||
// that names no document in the organisation. It says where the ids the
|
||||
// model may use come from.
|
||||
var errUnknownParent = errors.New("no document with that id to use as parent; call list_documents for the ids that exist")
|
||||
|
||||
// ErrUnknownDocument is what a document lookup reports for an id that
|
||||
// names nothing in the session's organisation. Another organisation's
|
||||
|
|
@ -483,14 +453,20 @@ func errUnknownParent(err error) error {
|
|||
// discover that a document exists elsewhere.
|
||||
var ErrUnknownDocument = errors.New("no document with that id in this organisation; call list_documents for the ids that exist")
|
||||
|
||||
// DeleteDocument removes the document. It records nothing as touched:
|
||||
// the document is gone, so there is nothing left to point a caller at.
|
||||
// The delete reports the ids of the destroyed subtree, and their
|
||||
// search-index removal is queued in the same transaction: after the
|
||||
// commit nothing else knows what went away. An empty subtree means the
|
||||
// delete matched nothing, which is reported as an error rather than a
|
||||
// silent success.
|
||||
// DeleteDocument removes the document. The delete reports the ids of the
|
||||
// destroyed subtree, and their search-index removal is queued in the
|
||||
// same transaction: after the commit nothing else knows what went away.
|
||||
// An empty subtree means the delete matched nothing, which is reported
|
||||
// as an error rather than a silent success.
|
||||
func (i *input) DeleteDocument(id xid.ID) error {
|
||||
// the parent is read before the row goes, to tell the subtree that
|
||||
// changed. A document that cannot be read leaves the root's.
|
||||
var parentID null.Value[xid.ID]
|
||||
|
||||
if doc, err := i.FetchDocument(id); err == nil {
|
||||
parentID = doc.ParentID
|
||||
}
|
||||
|
||||
var tx Tx
|
||||
|
||||
if err := i.db.BeginTx(i.ctx, &tx); err != nil {
|
||||
|
|
@ -519,6 +495,7 @@ func (i *input) DeleteDocument(id xid.ID) error {
|
|||
}
|
||||
|
||||
i.searchTrigger.Trigger()
|
||||
i.notifyTreeChange(parentID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -530,9 +507,13 @@ func (i *input) DeleteDocument(id xid.ID) error {
|
|||
// subtree, is a broken tree, and the check belongs with the write that
|
||||
// would cause it.
|
||||
func (i *input) MoveDocument(doc *document.Document, parentID null.Value[xid.ID]) error {
|
||||
if parentID.Valid {
|
||||
if parentID.Valid { //nolint:nestif // the branching is sequential and readable
|
||||
if err := i.db.CheckDocumentExists(i.ctx, parentID.V, i.orgID); err != nil {
|
||||
return fmt.Errorf("new parent %s: %w", parentID.V, errUnknownParent(err))
|
||||
if errutil.IsNotFound(err) {
|
||||
return fmt.Errorf("new parent %s: %w", parentID.V, errUnknownParent)
|
||||
}
|
||||
|
||||
return fmt.Errorf("checking parent: %w", err)
|
||||
}
|
||||
|
||||
cycle, err := i.db.CheckDocumentCycle(i.ctx, doc.ID, parentID.V, i.orgID)
|
||||
|
|
@ -549,17 +530,42 @@ func (i *input) MoveDocument(doc *document.Document, parentID null.Value[xid.ID]
|
|||
return fmt.Errorf("update: %w", err)
|
||||
}
|
||||
|
||||
// the parents' own content is unchanged — only the tree shape
|
||||
// around them — so the moved document is the one to record, on the
|
||||
// branch it was fetched on.
|
||||
i.recordTouched(doc.ID, doc.BranchID)
|
||||
// the move changes the shape of both subtrees; when they are the
|
||||
// same one, one notification covers it.
|
||||
if doc.ParentID != parentID {
|
||||
i.notifyTreeChange(doc.ParentID)
|
||||
}
|
||||
|
||||
i.notifyTreeChange(parentID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchBlocks returns blocks whose text matches the query.
|
||||
func (i *input) SearchBlocks(query string, limit int) ([]search.Block, error) {
|
||||
return i.search.SearchDocumentBlocks(i.ctx, i.orgID, query, limit)
|
||||
// RenameDocument sets the document's name and icon through the realtime
|
||||
// service, so an open editor shows them as they land. An empty name or
|
||||
// icon stays as it is.
|
||||
func (i *input) RenameDocument(doc *document.Document, name, icon string) error {
|
||||
var ops []edit.Operation
|
||||
|
||||
if name != "" {
|
||||
ops = append(ops, edit.SetName(name))
|
||||
}
|
||||
|
||||
if icon != "" {
|
||||
ops = append(ops, edit.SetIcon(icon))
|
||||
}
|
||||
|
||||
if len(ops) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := i.ApplyEdit(doc.ID, doc.BranchID, ops); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.notifyTreeChange(doc.ParentID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Warn records something a tool carried on through.
|
||||
|
|
@ -568,12 +574,6 @@ func (i *input) Warn(msg string, attrs ...slog.Attr) {
|
|||
append([]slog.Attr{slog.String("tool", string(i.name))}, attrs...)...)
|
||||
}
|
||||
|
||||
// ReadOffloaded returns a tool result that was moved out of the
|
||||
// conversation for size.
|
||||
func (i *input) ReadOffloaded(path string) (string, error) {
|
||||
return i.offload.Read(i.ctx, path)
|
||||
}
|
||||
|
||||
// errBranchProtected is what a content write reports for a branch that
|
||||
// is protected. Reading it stays allowed; the caller is told which of
|
||||
// the document's branches would take the write instead.
|
||||
|
|
@ -584,28 +584,6 @@ var errBranchProtected = errors.New("this branch is protected and takes no edits
|
|||
// a stale or guessed id is the usual cause.
|
||||
var ErrUnknownBranch = errors.New("this document has no branch with that id")
|
||||
|
||||
// resolveDoc loads the branch branchID names of the given document and
|
||||
// returns the ids the edit client needs. The lookup
|
||||
// also acts as the cross-org safety check — Document scopes by orgID so
|
||||
// a docID from another organisation surfaces as NotFound.
|
||||
func (i *input) resolveDoc(documentID, branchID xid.ID) (docRef, error) {
|
||||
doc, err := i.FetchBranch(documentID, branchID)
|
||||
if err != nil {
|
||||
return docRef{}, fmt.Errorf("fetching document: %w", err)
|
||||
}
|
||||
|
||||
// a protected branch takes no write but core's own, and this is not
|
||||
// one: the operations endpoint applies the batch to the live Y.Doc
|
||||
// regardless, and only the persist behind it is refused. Without
|
||||
// this the call reports success, the change shows up in every open
|
||||
// editor, and it is gone at the next load.
|
||||
if doc.Protected {
|
||||
return docRef{}, i.protectedBranch(doc)
|
||||
}
|
||||
|
||||
return docRef{DocumentID: doc.ID, BranchID: doc.BranchID}, nil
|
||||
}
|
||||
|
||||
// protectedBranch builds the refusal for a write to a protected branch,
|
||||
// naming the document's unprotected branches so the caller has
|
||||
// somewhere to write, or saying there is none.
|
||||
|
|
@ -615,221 +593,80 @@ func (i *input) protectedBranch(doc *document.Document) error {
|
|||
return fmt.Errorf("branch %s: %w; %w", doc.BranchName, errBranchProtected, err)
|
||||
}
|
||||
|
||||
open := branchLabels(branches, func(b document.BranchSummary) bool { return !b.Protected })
|
||||
open := slices.DeleteFunc(branches, func(b document.BranchSummary) bool { return b.Protected })
|
||||
if len(open) == 0 {
|
||||
return fmt.Errorf("branch %s: %w; the document has no unprotected branch to write to", doc.BranchName, errBranchProtected)
|
||||
}
|
||||
|
||||
return fmt.Errorf("branch %s: %w; write to one of %s", doc.BranchName, errBranchProtected, strings.Join(open, ", "))
|
||||
return fmt.Errorf("branch %s: %w; write to one of %s", doc.BranchName, errBranchProtected, branchLabels(open))
|
||||
}
|
||||
|
||||
// branchLabels lists the branches keep accepts, or all of them when keep
|
||||
// is nil, each as "name (id)": the name is what a person calls it and
|
||||
// the id is what a tool call takes.
|
||||
func branchLabels(branches []document.BranchSummary, keep func(document.BranchSummary) bool) []string {
|
||||
out := make([]string, 0, len(branches))
|
||||
// branchLabels lists the branches, each as "name (id)": the name is what
|
||||
// a person calls it and the id is what a tool call takes.
|
||||
func branchLabels(branches []document.BranchSummary) string {
|
||||
labels := make([]string, 0, len(branches))
|
||||
|
||||
for _, b := range branches {
|
||||
if keep == nil || keep(b) {
|
||||
out = append(out, fmt.Sprintf("%s (%s)", b.BranchName, b.BranchID))
|
||||
}
|
||||
labels = append(labels, fmt.Sprintf("%s (%s)", b.BranchName, b.BranchID))
|
||||
}
|
||||
|
||||
return out
|
||||
return strings.Join(labels, ", ")
|
||||
}
|
||||
|
||||
// ApplyEdit is the shared tail of every content-mutating write tool: it
|
||||
// resolves the document and branch to a (documentID, branchID) pair, ships the
|
||||
// operation batch to Node, and reports any operation Node refused.
|
||||
// Outcomes are logged so partial failures on the Node side (uid not
|
||||
// found, malformed block) are visible without re-running the
|
||||
// conversation. What the write produced is the tool's own to describe;
|
||||
// the persist behind the live document is debounced, so a re-read here
|
||||
// would not reliably show it.
|
||||
// loads the branch, ships the operation batch to Node, and returns the
|
||||
// operation Node refused, if any, in which case nothing was applied. What
|
||||
// the write produced is the tool's own to describe, which spares a second
|
||||
// fetch of the document. The branch lookup is scoped to the organisation,
|
||||
// so a document of another one is refused as unknown.
|
||||
func (i *input) ApplyEdit(documentID, branchID xid.ID, ops []edit.Operation) error {
|
||||
ref, err := i.resolveDoc(documentID, branchID)
|
||||
doc, err := i.FetchBranch(documentID, branchID)
|
||||
if err != nil {
|
||||
i.log.Warn(
|
||||
"edit resolve failed",
|
||||
slog.String("document_id", documentID.String()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return fmt.Errorf("fetching document: %w", err)
|
||||
}
|
||||
|
||||
return err
|
||||
// a protected branch takes no write but core's own, and this is not
|
||||
// one: the operations endpoint applies the batch to the live Y.Doc
|
||||
// regardless, and only the persist behind it is refused. Without
|
||||
// this the call reports success, the change shows up in every open
|
||||
// editor, and it is gone at the next load.
|
||||
if doc.Protected {
|
||||
return i.protectedBranch(doc)
|
||||
}
|
||||
|
||||
res, err := i.applier.Apply(
|
||||
i.ctx,
|
||||
ref.DocumentID,
|
||||
ref.BranchID,
|
||||
documentID,
|
||||
branchID,
|
||||
ops,
|
||||
i.userID,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
i.log.Error(
|
||||
"edit apply failed",
|
||||
slog.String("document_id", ref.DocumentID.String()),
|
||||
slog.String("branch_id", ref.BranchID.String()),
|
||||
slog.Int("op_count", len(ops)),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
|
||||
return fmt.Errorf("applying edit: %w", err)
|
||||
}
|
||||
|
||||
// nothing committed and something to say why is a failed call, not
|
||||
// a result describing a no-op: every write tool ships a single
|
||||
// operation, so there is no partial success to report. Returning an
|
||||
// error is what makes the surfaces treat it as one — the MCP bridge
|
||||
// marks the result isError, and the assistant sees a failure it can
|
||||
// correct rather than a success it will summarise.
|
||||
if res.Applied == 0 && len(res.Errors) > 0 {
|
||||
return fmt.Errorf("applying edit: %s", edit.JoinOpErrors(res.Errors))
|
||||
// an operation refused is a failed call: the MCP bridge then marks
|
||||
// the result isError, and the assistant sees a failure it can
|
||||
// correct. A batch applies whole or not at all, so nothing landed.
|
||||
if err := res.Err(len(ops)); err != nil {
|
||||
return fmt.Errorf("applying edit: %w", err)
|
||||
}
|
||||
|
||||
if len(res.Errors) > 0 {
|
||||
i.log.Warn(
|
||||
"edit partial failure",
|
||||
slog.String("document_id", ref.DocumentID.String()),
|
||||
slog.String("branch_id", ref.BranchID.String()),
|
||||
slog.Int("applied", res.Applied),
|
||||
slog.Any("errors", res.Errors),
|
||||
)
|
||||
} else {
|
||||
i.log.Debug(
|
||||
"edit applied",
|
||||
slog.String("document_id", ref.DocumentID.String()),
|
||||
slog.String("branch_id", ref.BranchID.String()),
|
||||
slog.Int("applied", res.Applied),
|
||||
)
|
||||
}
|
||||
|
||||
// the resolved id, not the argument: the caller may have named the
|
||||
// document any way resolveDoc accepts.
|
||||
i.recordTouched(ref.DocumentID, ref.BranchID)
|
||||
i.log.Debug(
|
||||
"edit applied",
|
||||
slog.String("document_id", documentID.String()),
|
||||
slog.String("branch_id", branchID.String()),
|
||||
slog.Int("op_count", len(ops)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePlacement validates a block that is about to land next to, or
|
||||
// in place of, the block referenceUID names. The reference's parent is
|
||||
// what decides legality — the document root takes the root set, a macro
|
||||
// container takes its own — so the check resolves the reference in the
|
||||
// fetched content and validates against that container. The edit
|
||||
// backend applies no schema of its own, so an illegal type let through
|
||||
// here would land in the Y.Doc unchallenged.
|
||||
func (i *input) ValidatePlacement(documentID, branchID xid.ID, referenceUID string, b block.Block) error {
|
||||
content, err := i.FetchDocumentContent(documentID, branchID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching content: %w", err)
|
||||
}
|
||||
|
||||
parent, ok := content.Content.FindParentTypeByUID(referenceUID)
|
||||
if !ok {
|
||||
// the reference does not resolve; the edit backend reports
|
||||
// that as the call's result. Still reject a malformed block.
|
||||
return block.Validate(b)
|
||||
}
|
||||
|
||||
return block.ValidateInContainer(parent, b)
|
||||
}
|
||||
|
||||
// ValidateAttrUpdate checks the attributes an update would leave on the
|
||||
// block. The update names some attributes and preserves the rest, so
|
||||
// the rules run against the merge rather than the payload — otherwise
|
||||
// setting one attribute would be judged as if every other were absent.
|
||||
//
|
||||
// A block whose ProseMirror type has no canonical counterpart is a
|
||||
// wrapper item (a list item, a macro internal). Those carry attributes
|
||||
// the canonical model does not describe, so there is nothing to check.
|
||||
func (i *input) ValidateAttrUpdate(documentID, branchID xid.ID, blockUID string, attrs map[string]any) error {
|
||||
content, err := i.FetchDocumentContent(documentID, branchID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching content: %w", err)
|
||||
}
|
||||
|
||||
target, ok := content.Content.FindByUID(blockUID)
|
||||
if !ok {
|
||||
// the uid does not resolve; the edit backend reports that as
|
||||
// the call's result.
|
||||
return nil
|
||||
}
|
||||
|
||||
t, ok := block.CanonicalType(target.Type)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
current := target.Attrs
|
||||
|
||||
// a titled code block keeps its title and language on its children
|
||||
// rather than on itself.
|
||||
if target.Type == document.BlockNodeTitledCodeBlock {
|
||||
c, err := block.Compact(target)
|
||||
if err != nil {
|
||||
// NOCOV: compacting a titled code block cannot fail.
|
||||
return fmt.Errorf("compacting block: %w", err)
|
||||
}
|
||||
|
||||
current = c.Attrs
|
||||
}
|
||||
|
||||
merged := make(document.Attributes, len(current)+len(attrs))
|
||||
maps.Copy(merged, current)
|
||||
maps.Copy(merged, attrs)
|
||||
|
||||
return block.ValidateAttrs(t, merged)
|
||||
}
|
||||
|
||||
// ValidateMove checks that the moved block may live where the move
|
||||
// would put it: among the children of whatever holds the reference.
|
||||
// Only placement is in question — the block is already in the document,
|
||||
// so its content has been through validation once already and may
|
||||
// contain shapes this layer can no longer express.
|
||||
//
|
||||
// A wrapper item (a list item, a macro internal) has no canonical type
|
||||
// to check against a container's allowed set, so it is held to a
|
||||
// narrower rule instead: it may only land in a container of the kind it
|
||||
// already sits in, which permits reordering and moving between two
|
||||
// lists while keeping a list item from landing at the document root.
|
||||
func (i *input) ValidateMove(documentID, branchID xid.ID, blockUID, referenceUID string) error {
|
||||
content, err := i.FetchDocumentContent(documentID, branchID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching content: %w", err)
|
||||
}
|
||||
|
||||
moved, ok := content.Content.FindByUID(blockUID)
|
||||
if !ok {
|
||||
// neither uid resolving is the edit backend's report to make.
|
||||
return nil
|
||||
}
|
||||
|
||||
target, ok := content.Content.FindParentTypeByUID(referenceUID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
t, ok := block.CanonicalType(moved.Type)
|
||||
if !ok {
|
||||
source, ok := content.Content.FindParentTypeByUID(blockUID)
|
||||
if !ok || source == target {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"%s cannot move from %s into %s; it belongs to the block that holds it",
|
||||
moved.Type, source, target,
|
||||
)
|
||||
}
|
||||
|
||||
return block.AllowedInContainer(target, t)
|
||||
}
|
||||
|
||||
// NotifyTreeChange invokes the tree notifier when one is configured.
|
||||
// notifyTreeChange invokes the tree notifier when one is configured.
|
||||
// Safe to call from any tool — a nil notifier silently no-ops so tests
|
||||
// that don't wire one don't trip.
|
||||
func (i *input) NotifyTreeChange(parentID null.Value[xid.ID]) {
|
||||
func (i *input) notifyTreeChange(parentID null.Value[xid.ID]) {
|
||||
if i.tree == nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -837,19 +674,6 @@ func (i *input) NotifyTreeChange(parentID null.Value[xid.ID]) {
|
|||
i.tree.NotifyTreeChange(i.orgID, parentID)
|
||||
}
|
||||
|
||||
// NotifyTreeChangeForDocument looks up the document's current parent
|
||||
// and fires a tree-change for that parent. Used by rename/icon ops
|
||||
// which don't carry a parent in their args. Failures (e.g. doc fetched
|
||||
// after delete) silently skip the notification.
|
||||
func (i *input) NotifyTreeChangeForDocument(documentID xid.ID) {
|
||||
doc, err := i.FetchDocument(documentID)
|
||||
if err != nil || doc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
i.NotifyTreeChange(doc.ParentID)
|
||||
}
|
||||
|
||||
// FetchTagTree returns every tag in the organisation in display order, each
|
||||
// with the documents whose default branch carries it and whether the
|
||||
// session's user hides it.
|
||||
|
|
@ -885,7 +709,13 @@ func (i *input) FetchBranchTags(documentID, branchID xid.ID) ([]tag.Tag, error)
|
|||
// name the organisation already uses is refused in the repository's own
|
||||
// words, which name the clash.
|
||||
func (i *input) CreateTag(t tag.Tag) error {
|
||||
return i.db.InsertTag(i.ctx, t)
|
||||
if err := i.db.InsertTag(i.ctx, t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.notifyTagTreeChange()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTag renames and/or recolours the tag the id names.
|
||||
|
|
@ -898,6 +728,8 @@ func (i *input) UpdateTag(tagID xid.ID, inp tag.UpdateInput) error {
|
|||
return err
|
||||
}
|
||||
|
||||
i.notifyTagTreeChange()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -912,6 +744,8 @@ func (i *input) DeleteTag(tagID xid.ID) error {
|
|||
return err
|
||||
}
|
||||
|
||||
i.notifyTagTreeChange()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -932,7 +766,8 @@ func (i *input) AssignTag(documentID, branchID, tagID xid.ID) error {
|
|||
return err
|
||||
}
|
||||
|
||||
i.recordTouched(doc.ID, doc.BranchID)
|
||||
i.notifyTagTreeChange()
|
||||
i.notifyBranchTagsChange(doc.ID, doc.BranchID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -955,7 +790,8 @@ func (i *input) UnassignTag(documentID, branchID, tagID xid.ID) error {
|
|||
return err
|
||||
}
|
||||
|
||||
i.recordTouched(doc.ID, doc.BranchID)
|
||||
i.notifyTagTreeChange()
|
||||
i.notifyBranchTagsChange(doc.ID, doc.BranchID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -978,12 +814,18 @@ func (i *input) MoveTag(tagID xid.ID, sortIndex int) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return i.db.UpdateTagTree(i.ctx, moved, i.orgID)
|
||||
if err := i.db.UpdateTagTree(i.ctx, moved, i.orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.notifyTagTreeChange()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyTagTreeChange invokes the tag notifier when one is configured.
|
||||
// notifyTagTreeChange invokes the tag notifier when one is configured.
|
||||
// A nil notifier silently no-ops, like the document tree's.
|
||||
func (i *input) NotifyTagTreeChange() {
|
||||
func (i *input) notifyTagTreeChange() {
|
||||
if i.tags == nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -991,9 +833,9 @@ func (i *input) NotifyTagTreeChange() {
|
|||
i.tags.NotifyTreeChange(i.orgID)
|
||||
}
|
||||
|
||||
// NotifyBranchTagsChange invokes the tag notifier for one branch's tags
|
||||
// notifyBranchTagsChange invokes the tag notifier for one branch's tags
|
||||
// when one is configured. A nil notifier silently no-ops.
|
||||
func (i *input) NotifyBranchTagsChange(documentID, branchID xid.ID) {
|
||||
func (i *input) notifyBranchTagsChange(documentID, branchID xid.ID) {
|
||||
if i.tags == nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -1025,25 +867,19 @@ func (i *input) FetchHook(documentID, hookID xid.ID) (*hook.Hook, error) {
|
|||
hk, err := i.db.FetchDocumentHook(i.ctx, hookID, i.orgID)
|
||||
if err != nil {
|
||||
if errutil.IsNotFound(err) {
|
||||
return nil, i.unknownHook(documentID, hookID)
|
||||
return nil, fmt.Errorf("hook %s on document %s: %w", hookID, documentID, errUnknownHook)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("fetching hook: %w", err)
|
||||
}
|
||||
|
||||
if !hk.DocumentID.Valid || hk.DocumentID.V != documentID {
|
||||
return nil, i.unknownHook(documentID, hookID)
|
||||
return nil, fmt.Errorf("hook %s on document %s: %w", hookID, documentID, errUnknownHook)
|
||||
}
|
||||
|
||||
return hk, nil
|
||||
}
|
||||
|
||||
// unknownHook builds the refusal for a hook the document does not hold,
|
||||
// naming the document the call addressed.
|
||||
func (i *input) unknownHook(documentID, hookID xid.ID) error {
|
||||
return fmt.Errorf("hook %s on document %s: %w", hookID, documentID, errUnknownHook)
|
||||
}
|
||||
|
||||
// CreateHook creates a hook on the branch branchID names, anchored to the
|
||||
// block blockUID names when one is given. The branch is checked here, so
|
||||
// a hook cannot land on a branch of another document. A type whose
|
||||
|
|
@ -1083,8 +919,6 @@ func (i *input) CreateHook(documentID, branchID xid.ID, blockUID string, tp hook
|
|||
return nil, err
|
||||
}
|
||||
|
||||
i.recordTouched(hk.DocumentID.V, hk.BranchID.V)
|
||||
|
||||
return hk, nil
|
||||
}
|
||||
|
||||
|
|
@ -1098,8 +932,6 @@ func (i *input) UpdateHook(hk *hook.Hook, settings processor.Settings) error {
|
|||
|
||||
*hk = *updated
|
||||
|
||||
i.recordTouched(hk.DocumentID.V, hk.BranchID.V)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1112,34 +944,19 @@ func (i *input) ResetHook(hk *hook.Hook) error {
|
|||
|
||||
*hk = *reset
|
||||
|
||||
i.recordTouched(hk.DocumentID.V, hk.BranchID.V)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteHook tears down what the hook holds outside the document and
|
||||
// removes its row. The branch stays, and the editor showing it has to
|
||||
// redraw, so the delete records the branch as touched.
|
||||
// removes its row.
|
||||
func (i *input) DeleteHook(hk *hook.Hook) error {
|
||||
if err := i.hookMan.DeleteHook(i.ctx, hk.ID, hk.DocumentID.V, i.orgID, i.userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.recordTouched(hk.DocumentID.V, hk.BranchID.V)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// docRef wraps the (documentID, branchID) pair the edit client needs to
|
||||
// address a live Y.Doc.
|
||||
type docRef struct {
|
||||
// DocumentID is the document's id.
|
||||
DocumentID xid.ID
|
||||
|
||||
// BranchID is the id of the addressed branch.
|
||||
BranchID xid.ID
|
||||
}
|
||||
|
||||
// DataSourceRunners hands out the runner for a data source. The
|
||||
// datasource package's Manager satisfies it.
|
||||
//
|
||||
|
|
@ -1241,15 +1058,15 @@ type TagDB interface {
|
|||
DeleteTag(ctx context.Context, id xid.ID, organizationID string) error
|
||||
|
||||
// AssignBranchTag should make a document's branch carry a tag,
|
||||
// changing nothing when it already does. Used by assign_tag.
|
||||
// changing nothing when it already does.
|
||||
AssignBranchTag(ctx context.Context, organizationID string, documentID, branchID, tagID xid.ID) error
|
||||
|
||||
// UnassignBranchTag should stop a document's branch carrying a tag,
|
||||
// changing nothing when it does not. Used by unassign_tag.
|
||||
// changing nothing when it does not.
|
||||
UnassignBranchTag(ctx context.Context, organizationID string, documentID, branchID, tagID xid.ID) error
|
||||
|
||||
// UpdateTagTree should rewrite the display order of the org's tags to
|
||||
// the order of the given tree. Used by move_tag.
|
||||
// the order of the given tree.
|
||||
UpdateTagTree(ctx context.Context, tree tag.Summaries, organizationID string) error
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,315 +0,0 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/block"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
)
|
||||
|
||||
// _blockSchema is the block argument's schema for the write tools: one
|
||||
// variant per type the assistant can write, each naming its type as a
|
||||
// constant and taking only its own fields. file is absent, since it is
|
||||
// only ever created by uploading in the editor.
|
||||
var _blockSchema = blockSchema()
|
||||
|
||||
// blockSchema builds the block argument's schema, one variant per
|
||||
// writable type in documentation order.
|
||||
func blockSchema() map[string]any {
|
||||
types := block.Types()
|
||||
variants := make([]map[string]any, 0, len(types))
|
||||
|
||||
for _, t := range types {
|
||||
if v, ok := blockVariant(block.Type(t)); ok {
|
||||
variants = append(variants, v)
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"description": "A canonical block. Its type decides which fields it takes.",
|
||||
"anyOf": variants,
|
||||
}
|
||||
}
|
||||
|
||||
// blockVariant builds the schema of one block type: its type as a
|
||||
// constant, its uid, the fields it takes and which of them are
|
||||
// required, and no other field. The second result is false for a type
|
||||
// the assistant cannot write.
|
||||
func blockVariant(t block.Type) (map[string]any, bool) {
|
||||
var (
|
||||
props map[string]any
|
||||
required []string
|
||||
)
|
||||
|
||||
switch t {
|
||||
case block.BlockParagraph:
|
||||
props = map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one."},
|
||||
"children": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Only when the paragraph is a list or task-list entry: the blocks indented under it, each a paragraph, bullet_list, ordered_list or task_list.",
|
||||
"items": map[string]any{"type": "object"},
|
||||
},
|
||||
}
|
||||
case block.BlockHeading:
|
||||
props = map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Plain text; a heading takes no formatting, so no markdown is parsed."},
|
||||
"attrs": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
document.AttrLevel: map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The heading level.",
|
||||
"enum": []int{1, 2, 3},
|
||||
},
|
||||
},
|
||||
"required": []string{document.AttrLevel},
|
||||
},
|
||||
}
|
||||
required = []string{"attrs"}
|
||||
case block.BlockBlockquote:
|
||||
props = map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both."},
|
||||
"items": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The blocks it quotes, of any type legal at the document root. Use text or items, not both.",
|
||||
"items": map[string]any{"type": "object"},
|
||||
},
|
||||
}
|
||||
case block.BlockBulletList, block.BlockOrderedList:
|
||||
props = map[string]any{
|
||||
"items": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.",
|
||||
"items": map[string]any{"type": "object"},
|
||||
"minItems": 1,
|
||||
},
|
||||
}
|
||||
required = []string{"items"}
|
||||
case block.BlockTaskList:
|
||||
props = map[string]any{
|
||||
"task_items": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The rows, each a checked flag and a paragraph block. Blocks nested under a row go in that paragraph's children.",
|
||||
"minItems": 1,
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"checked": map[string]any{"type": "boolean"},
|
||||
"block": map[string]any{"type": "object"},
|
||||
},
|
||||
"required": []string{"block"},
|
||||
},
|
||||
},
|
||||
}
|
||||
required = []string{"task_items"}
|
||||
case block.BlockCallout:
|
||||
props = map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both."},
|
||||
"items": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The blocks it holds, each a paragraph, bullet_list, ordered_list or task_list. Use text or items, not both.",
|
||||
"items": map[string]any{"type": "object"},
|
||||
},
|
||||
"attrs": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
document.AttrIcon: map[string]any{"type": "string", "description": "Iconify identifier; defaults to lucide:text."},
|
||||
},
|
||||
},
|
||||
}
|
||||
case block.BlockCode:
|
||||
props = map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Raw source; no markdown."},
|
||||
"attrs": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
document.AttrLanguage: map[string]any{"type": "string", "description": "Optional; leave empty rather than guess."},
|
||||
},
|
||||
},
|
||||
}
|
||||
case block.BlockTitledCode:
|
||||
props = map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Raw source; no markdown."},
|
||||
"attrs": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
document.AttrTitle: map[string]any{"type": "string", "description": "Plain-text label naming what the code is. For an endpoint, the method and route, such as POST /v1/assets/{id}/due-date/preview."},
|
||||
document.AttrLanguage: map[string]any{"type": "string", "description": "Optional; leave empty rather than guess."},
|
||||
},
|
||||
"required": []string{document.AttrTitle},
|
||||
},
|
||||
}
|
||||
required = []string{"attrs"}
|
||||
case block.BlockMermaid:
|
||||
props = map[string]any{"text": map[string]any{"type": "string", "description": "Raw Mermaid source; no markdown. Mermaid ends a gantt task name or a timeline period at its first colon, so write any colon inside one as #58; (13#58;00)."}}
|
||||
case block.BlockHorizontalRule:
|
||||
props = map[string]any{}
|
||||
case block.BlockImage:
|
||||
props = map[string]any{
|
||||
"attrs": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
document.AttrSrc: map[string]any{"type": "string", "description": "The image address."},
|
||||
document.AttrAlt: map[string]any{"type": "string", "description": "Alternative text."},
|
||||
document.AttrTitle: map[string]any{"type": "string", "description": "Caption."},
|
||||
document.AttrWidth: map[string]any{"type": "integer", "description": "Display width in pixels."},
|
||||
},
|
||||
"required": []string{document.AttrSrc},
|
||||
},
|
||||
}
|
||||
required = []string{"attrs"}
|
||||
case block.BlockFigma:
|
||||
props = map[string]any{
|
||||
"attrs": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
document.AttrSrc: map[string]any{"type": "string", "description": "The Figma frame address."},
|
||||
document.AttrWidth: map[string]any{"type": "integer", "description": "Display width in pixels."},
|
||||
document.AttrHeight: map[string]any{"type": "integer", "description": "Display height in pixels."},
|
||||
},
|
||||
"required": []string{document.AttrSrc},
|
||||
},
|
||||
}
|
||||
required = []string{"attrs"}
|
||||
case block.BlockMetric:
|
||||
props = map[string]any{
|
||||
"attrs": map[string]any{
|
||||
"type": "object",
|
||||
"description": "The chart configuration: the data source, the queries, the window and the display settings, as the block model describes them. Legal only inside a metric_grid or on a split_doc right side.",
|
||||
"properties": metricAttrProps(),
|
||||
},
|
||||
}
|
||||
case block.BlockMetricGrid:
|
||||
props = map[string]any{
|
||||
"items": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The metric blocks it holds.",
|
||||
"items": map[string]any{"type": "object"},
|
||||
"minItems": 1,
|
||||
},
|
||||
}
|
||||
required = []string{"items"}
|
||||
case block.BlockSplitDoc:
|
||||
props = map[string]any{
|
||||
"left": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The concept side. Must start with a level-1 heading; then paragraphs, lists and callouts; then any split_doc_param_list, which is legal only here.",
|
||||
"items": map[string]any{"type": "object"},
|
||||
"minItems": 1,
|
||||
},
|
||||
"right": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The example side. Holds titled_code, metric or mermaid only, and is the one place titled_code is legal.",
|
||||
"items": map[string]any{"type": "object"},
|
||||
"minItems": 1,
|
||||
},
|
||||
"attrs": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
document.AttrInversed: map[string]any{"type": "boolean", "description": "Flips the two sides."},
|
||||
},
|
||||
},
|
||||
}
|
||||
required = []string{"left", "right"}
|
||||
case block.BlockParamList:
|
||||
props = map[string]any{
|
||||
"header": map[string]any{"type": "string", "description": "The section header, as plain text."},
|
||||
"params": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The rows.",
|
||||
"minItems": 1,
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "The parameter name, as plain text."},
|
||||
"type": map[string]any{"type": "string", "description": "The parameter type, as plain text."},
|
||||
"description": map[string]any{"type": "string", "description": "The description, in minimal markdown."},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
}
|
||||
required = []string{"header", "params"}
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
props["type"] = map[string]any{"const": string(t)}
|
||||
props["uid"] = map[string]any{"type": "string", "description": "Leave unset; generated server-side."}
|
||||
|
||||
return map[string]any{
|
||||
"title": string(t),
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": append([]string{"type"}, required...),
|
||||
"additionalProperties": false,
|
||||
}, true
|
||||
}
|
||||
|
||||
// _enumAttrs are the metric attributes whose legal values are a fixed
|
||||
// set, listed in the order the block model introduces them.
|
||||
var _enumAttrs = []struct {
|
||||
// Name is the attribute's key inside attrs.
|
||||
Name string
|
||||
|
||||
// Desc says what the attribute sets.
|
||||
Desc string
|
||||
}{
|
||||
{document.AttrVisualizationType, "The chart kind."},
|
||||
{document.AttrTimeRange, "The window queried."},
|
||||
{document.AttrRefreshInterval, "How often the chart re-queries."},
|
||||
{document.AttrUnitType, "The unit values are read in. With custom, put the label in unitCustom."},
|
||||
{document.AttrWidth, "The chart's size."},
|
||||
{document.AttrSimulationPreset, "Draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query."},
|
||||
}
|
||||
|
||||
// metricAttrProps builds the metric attrs sub-schema. An attribute
|
||||
// with a fixed value set is published as an enum, which lets a client
|
||||
// reject a bad value itself rather than learning the values from prose
|
||||
// it may never have been given.
|
||||
func metricAttrProps() map[string]any {
|
||||
enums := block.MetricEnums()
|
||||
out := make(map[string]any)
|
||||
|
||||
for _, a := range _enumAttrs {
|
||||
out[a.Name] = map[string]any{
|
||||
"type": "string",
|
||||
"description": a.Desc,
|
||||
"enum": enums[a.Name],
|
||||
}
|
||||
}
|
||||
|
||||
out[document.AttrDataSourceID] = map[string]any{"type": "string", "description": "The data source the chart queries, an id from list_data_sources. Required for the block to render."}
|
||||
out[document.AttrQueries] = map[string]any{
|
||||
"type": "array",
|
||||
"description": "The queries charted, in PromQL or SQL as the data source takes. A SQL chart selects a time column aliased \"time\" plus one or more numeric columns, and may use the $__ macros.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "The query's name."},
|
||||
"query": map[string]any{"type": "string", "description": "The query itself."},
|
||||
"legendFormat": map[string]any{"type": "string", "description": "Optional series label template; may be empty."},
|
||||
},
|
||||
"required": []string{"name", "query"},
|
||||
},
|
||||
}
|
||||
out[document.AttrTitle] = map[string]any{"type": "string", "description": "Optional chart title."}
|
||||
out[document.AttrUnitCustom] = map[string]any{"type": "string", "description": "The unit label, read when unitType is custom."}
|
||||
out[document.AttrDecimals] = map[string]any{"type": "number", "description": "Optional number of decimals shown."}
|
||||
out[document.AttrThresholds] = map[string]any{
|
||||
"type": "array",
|
||||
"description": "Optional threshold rows, each colouring the values from its value up.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"value": map[string]any{"type": "number"},
|
||||
"label": map[string]any{"type": "string"},
|
||||
"color": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
out[document.AttrBaseThresholdColor] = map[string]any{"type": "string", "description": "Optional colour of the values below the first threshold."}
|
||||
out[document.AttrAxisBoundsMin] = map[string]any{"type": "number", "description": "Optional fixed lower bound of the value axis."}
|
||||
out[document.AttrAxisBoundsMax] = map[string]any{"type": "number", "description": "Optional fixed upper bound of the value axis."}
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/block"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_blockSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
variants, ok := _blockSchema["anyOf"].([]map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
// one variant per canonical type in documentation order, less file,
|
||||
// which the assistant never writes.
|
||||
want := slices.DeleteFunc(block.Types(), func(t string) bool { return t == string(block.BlockFile) })
|
||||
require.Len(t, variants, len(want))
|
||||
|
||||
for i, v := range variants {
|
||||
tp := want[i]
|
||||
|
||||
assert.Equal(t, tp, v["title"])
|
||||
assert.Equal(t, false, v["additionalProperties"])
|
||||
|
||||
props, pok := v["properties"].(map[string]any)
|
||||
require.True(t, pok, tp)
|
||||
assert.Equal(t, map[string]any{"const": tp}, props["type"], tp)
|
||||
assert.Contains(t, props, "uid", tp)
|
||||
|
||||
// everything required is a field the variant publishes.
|
||||
required, rok := v["required"].([]string)
|
||||
require.True(t, rok, tp)
|
||||
assert.Equal(t, "type", required[0], tp)
|
||||
|
||||
for _, key := range required {
|
||||
assert.Contains(t, props, key, tp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_blockVariant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, ok := blockVariant(block.BlockFile)
|
||||
assert.False(t, ok)
|
||||
|
||||
// heading level is the one non-metric enum, and the only integer
|
||||
// one: a client sending "2" rather than 2 is sending the wrong type.
|
||||
heading, ok := blockVariant(block.BlockHeading)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{"type", "attrs"}, heading["required"])
|
||||
|
||||
attrs := heading["properties"].(map[string]any)["attrs"].(map[string]any)
|
||||
assert.Equal(t, []string{document.AttrLevel}, attrs["required"])
|
||||
|
||||
level := attrs["properties"].(map[string]any)[document.AttrLevel].(map[string]any)
|
||||
assert.Equal(t, "integer", level["type"])
|
||||
assert.Equal(t, []int{1, 2, 3}, level["enum"])
|
||||
|
||||
// the atoms that point somewhere require their src.
|
||||
for _, tp := range []block.Type{block.BlockImage, block.BlockFigma} {
|
||||
v, vok := blockVariant(tp)
|
||||
require.True(t, vok)
|
||||
assert.Equal(t, []string{"type", "attrs"}, v["required"], tp)
|
||||
assert.Equal(t, []string{document.AttrSrc}, v["properties"].(map[string]any)["attrs"].(map[string]any)["required"], tp)
|
||||
}
|
||||
|
||||
// the metric enums are reachable from the metric variant, which is
|
||||
// what a client actually reads.
|
||||
metric, ok := blockVariant(block.BlockMetric)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, metricAttrProps(), metric["properties"].(map[string]any)["attrs"].(map[string]any)["properties"])
|
||||
}
|
||||
|
||||
func Test_metricAttrProps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := metricAttrProps()
|
||||
|
||||
// every metric enum is published, width included: the metric attrs
|
||||
// are their own schema, so the name no longer has to mean the same
|
||||
// thing as an image's pixel width.
|
||||
enums := block.MetricEnums()
|
||||
|
||||
for attr, values := range enums {
|
||||
prop, pok := got[attr].(map[string]any)
|
||||
require.True(t, pok, "attr %q is missing from the schema", attr)
|
||||
|
||||
assert.Equal(t, "string", prop["type"])
|
||||
assert.Equal(t, values, prop["enum"], "attr %q publishes the wrong values", attr)
|
||||
assert.NotEmpty(t, prop["description"], "attr %q has no description", attr)
|
||||
}
|
||||
|
||||
// the attrs without a fixed value set are published too, typed as
|
||||
// the validator reads them, so a client sees every attr it may send.
|
||||
free := map[string]string{
|
||||
document.AttrDataSourceID: "string",
|
||||
document.AttrQueries: "array",
|
||||
document.AttrTitle: "string",
|
||||
document.AttrUnitCustom: "string",
|
||||
document.AttrDecimals: "number",
|
||||
document.AttrThresholds: "array",
|
||||
document.AttrBaseThresholdColor: "string",
|
||||
document.AttrAxisBoundsMin: "number",
|
||||
document.AttrAxisBoundsMax: "number",
|
||||
}
|
||||
|
||||
require.Len(t, got, len(enums)+len(free))
|
||||
|
||||
for attr, typ := range free {
|
||||
prop, pok := got[attr].(map[string]any)
|
||||
require.True(t, pok, "attr %q is missing from the schema", attr)
|
||||
|
||||
assert.Equal(t, typ, prop["type"], "attr %q has the wrong type", attr)
|
||||
assert.NotEmpty(t, prop["description"], "attr %q has no description", attr)
|
||||
}
|
||||
}
|
||||
|
|
@ -44,21 +44,18 @@ func (a searchDocumentsArgs) Validate() error {
|
|||
}
|
||||
|
||||
// searchDocuments runs a full-text search across the organisation.
|
||||
type searchDocuments struct {
|
||||
plainSummary
|
||||
plainTraits
|
||||
}
|
||||
type searchDocuments struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (searchDocuments) Info() Info {
|
||||
return Info{
|
||||
Name: NameSearchDocuments,
|
||||
Description: "Full-text search across every branch of every document in the organisation for blocks whose text matches the query. Returns hits with document_id, document_name, branch_id, branch_name, default (whether that branch is the document's default), block_uid and text; branch_id is what a follow-up get_document or read_block takes. A block a fork copied unchanged is found on every branch that holds it. Use it to find where a topic is discussed or which documents the user might mean; use list_documents when you know the title. A hit names the innermost block holding the text, which may sit below anything get_document lists; read_block resolves it.",
|
||||
Description: "Full-text search for blocks whose text matches the query, across every branch of every document. Returns {documents: [{document_id, document_name, branch_id, branch_name, default, hits: [{block_uid, text}]}]}, best match first. A block a fork copied unchanged is found on every branch holding it. A hit is the innermost block holding the text; get_document with its block_uid reads the element holding it. Use list_documents to find a document by title.",
|
||||
Properties: map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "The full-text search query (typo-tolerant, matches block text)."},
|
||||
"query": map[string]any{"type": "string", "description": "The text to search for; matching is typo-tolerant."},
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of hits to return. Defaults to 20; cap is 50.",
|
||||
"description": "Optional. The most hits to return: 20 by default, 50 at most.",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
|
|
@ -66,18 +63,18 @@ func (searchDocuments) Info() Info {
|
|||
}
|
||||
|
||||
// Title announces the query being run.
|
||||
func (searchDocuments) Title(inp DescribeInput) (string, error) {
|
||||
func (searchDocuments) Title(inp DescribeInput) string {
|
||||
var in searchDocumentsArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Searching for %q", in.Query), nil
|
||||
return fmt.Sprintf("Searching for %q", in.Query)
|
||||
}
|
||||
|
||||
// Execute searches the index and decorates hits with document names.
|
||||
func (searchDocuments) Execute(inp Input) (string, error) {
|
||||
func (searchDocuments) Execute(inp *input) (string, error) {
|
||||
var in searchDocumentsArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -93,9 +90,9 @@ func (searchDocuments) Execute(inp Input) (string, error) {
|
|||
limit = _searchLimitMax
|
||||
}
|
||||
|
||||
blocks, err := inp.SearchBlocks(in.Query, limit)
|
||||
blocks, err := inp.search.SearchDocumentBlocks(inp.Context(), inp.orgID, in.Query, limit)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search_documents: search: %w", err)
|
||||
return "", fmt.Errorf("search: %w", err)
|
||||
}
|
||||
|
||||
// the index stores block text keyed by (branch, block uid) but not
|
||||
|
|
@ -121,34 +118,47 @@ func (searchDocuments) Execute(inp Input) (string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
hits := make([]searchHit, 0, len(blocks))
|
||||
// hits of one branch are listed under it once, in the order its
|
||||
// first hit ranked.
|
||||
var (
|
||||
out = searchResult{Documents: []searchDocument{}}
|
||||
index = map[xid.ID]int{}
|
||||
)
|
||||
|
||||
for _, b := range blocks {
|
||||
hits = append(hits, searchHit{
|
||||
DocumentID: b.DocumentID,
|
||||
DocumentName: names[b.DocumentID].DocumentName,
|
||||
BranchID: b.BranchID,
|
||||
BranchName: b.BranchName,
|
||||
Default: b.BranchDefault,
|
||||
BlockUID: strings.TrimPrefix(b.ID, b.BranchID.String()+"-"),
|
||||
Text: b.Text,
|
||||
at, seen := index[b.BranchID]
|
||||
if !seen {
|
||||
at = len(out.Documents)
|
||||
index[b.BranchID] = at
|
||||
|
||||
out.Documents = append(out.Documents, searchDocument{
|
||||
DocumentID: b.DocumentID,
|
||||
DocumentName: names[b.DocumentID].DocumentName,
|
||||
BranchID: b.BranchID,
|
||||
BranchName: b.BranchName,
|
||||
Default: b.BranchDefault,
|
||||
})
|
||||
}
|
||||
|
||||
out.Documents[at].Hits = append(out.Documents[at].Hits, searchHit{
|
||||
BlockUID: strings.TrimPrefix(b.ID, b.BranchID.String()+"-"),
|
||||
Text: b.Text,
|
||||
})
|
||||
}
|
||||
|
||||
return result(searchResult{
|
||||
Hits: hits,
|
||||
})
|
||||
return result(out)
|
||||
}
|
||||
|
||||
// searchResult is what search_documents returns.
|
||||
type searchResult struct {
|
||||
// Hits are the matching blocks, in relevance order.
|
||||
Hits []searchHit `json:"hits"`
|
||||
// Documents are the document branches holding a match, in the order
|
||||
// of their best hit.
|
||||
Documents []searchDocument `json:"documents"`
|
||||
}
|
||||
|
||||
// searchHit is one search_documents result row.
|
||||
type searchHit struct {
|
||||
// DocumentID is the document containing the matching block.
|
||||
// searchDocument is one document branch holding search hits.
|
||||
type searchDocument struct {
|
||||
// DocumentID is the document containing the matching blocks.
|
||||
DocumentID xid.ID `json:"document_id"`
|
||||
|
||||
// DocumentName is the document's display name, when resolvable from
|
||||
|
|
@ -156,7 +166,7 @@ type searchHit struct {
|
|||
// and the search.
|
||||
DocumentName string `json:"document_name,omitempty"`
|
||||
|
||||
// BranchID is the branch the hit was indexed from, which is what a
|
||||
// BranchID is the branch the hits were indexed from, which is what a
|
||||
// follow-up read takes.
|
||||
BranchID xid.ID `json:"branch_id"`
|
||||
|
||||
|
|
@ -166,8 +176,14 @@ type searchHit struct {
|
|||
// Default reports whether that branch is the document's default.
|
||||
Default bool `json:"default"`
|
||||
|
||||
// Hits are the matching blocks of the branch, in relevance order.
|
||||
Hits []searchHit `json:"hits"`
|
||||
}
|
||||
|
||||
// searchHit is one matching block.
|
||||
type searchHit struct {
|
||||
// BlockUID is the matching block's uid attribute, usable with
|
||||
// read_block and the edit tools.
|
||||
// get_document and the edit tools.
|
||||
BlockUID string `json:"block_uid"`
|
||||
|
||||
// Text is the block's indexed text.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/rs/xid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_searchDocumentsArgs_Validate(t *testing.T) {
|
||||
|
|
@ -28,36 +27,29 @@ func Test_searchDocuments_Info(t *testing.T) {
|
|||
|
||||
info := searchDocuments{}.Info()
|
||||
|
||||
assert.Equal(t, Traits{}, info.Traits)
|
||||
|
||||
assert.Equal(t, NameSearchDocuments, info.Name)
|
||||
assert.Equal(t, []string{"query"}, info.Required)
|
||||
assert.Contains(t, info.Properties, "limit")
|
||||
}
|
||||
|
||||
func Test_searchDocuments_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, Traits{}, searchDocuments{}.Traits())
|
||||
}
|
||||
|
||||
func Test_searchDocuments_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := testDeps(nil, nil, nil)
|
||||
|
||||
got, err := searchDocuments{}.Title(testInput(d, NameSearchDocuments, `{"query":"rate limit"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `Searching for "rate limit"`, got)
|
||||
|
||||
_, err = searchDocuments{}.Title(testInput(d, NameSearchDocuments, `{}`))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, `Searching for "rate limit"`, searchDocuments{}.Title(testInput(d, NameSearchDocuments, `{"query":"rate limit"}`)))
|
||||
assert.Empty(t, searchDocuments{}.Title(testInput(d, NameSearchDocuments, `{}`)))
|
||||
}
|
||||
|
||||
func Test_searchDocuments_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
docID, branchID := xid.New(), xid.New()
|
||||
docID, branchID, mainID := xid.New(), xid.New(), xid.New()
|
||||
|
||||
branchFields := `"branch_id":"` + branchID.String() + `","branch_name":"draft","default":false,`
|
||||
hit := `"hits":[{"block_uid":"b1","text":"rate limiting"}]`
|
||||
|
||||
hitSearcher := func(limit *int) *SearcherMock {
|
||||
return &SearcherMock{
|
||||
|
|
@ -124,7 +116,7 @@ func Test_searchDocuments_Execute(t *testing.T) {
|
|||
DB: &DBMock{},
|
||||
Args: `{"query":"rate limit"}`,
|
||||
Limit: _searchLimitDefault,
|
||||
Result: `{"hits":[]}`,
|
||||
Result: `{"documents":[]}`,
|
||||
},
|
||||
"Names are joined in": {
|
||||
Searcher: hitSearcher(nil),
|
||||
|
|
@ -133,10 +125,9 @@ func Test_searchDocuments_Execute(t *testing.T) {
|
|||
return document.Summaries{{ID: docID, DocumentName: "Runbook"}}, nil
|
||||
},
|
||||
},
|
||||
Args: `{"query":"rate limit"}`,
|
||||
Limit: _searchLimitDefault,
|
||||
Result: `{"hits":[{"document_id":"` + docID.String() + `","document_name":"Runbook",` +
|
||||
branchFields + `"block_uid":"b1","text":"rate limiting"}]}`,
|
||||
Args: `{"query":"rate limit"}`,
|
||||
Limit: _searchLimitDefault,
|
||||
Result: `{"documents":[{"document_id":"` + docID.String() + `","document_name":"Runbook",` + branchFields + hit + `}]}`,
|
||||
},
|
||||
"Losing the names does not fail the search": {
|
||||
Searcher: hitSearcher(nil),
|
||||
|
|
@ -147,7 +138,7 @@ func Test_searchDocuments_Execute(t *testing.T) {
|
|||
},
|
||||
Args: `{"query":"rate limit"}`,
|
||||
Limit: _searchLimitDefault,
|
||||
Result: `{"hits":[{"document_id":"` + docID.String() + `",` + branchFields + `"block_uid":"b1","text":"rate limiting"}]}`,
|
||||
Result: `{"documents":[{"document_id":"` + docID.String() + `",` + branchFields + hit + `}]}`,
|
||||
Logged: "cannot fetch the document tree for search hit names",
|
||||
},
|
||||
"Requested limit is honoured": {
|
||||
|
|
@ -155,16 +146,33 @@ func Test_searchDocuments_Execute(t *testing.T) {
|
|||
DB: &DBMock{},
|
||||
Args: `{"query":"rate limit","limit":5}`,
|
||||
Limit: 5,
|
||||
Result: `{"hits":[{"document_id":"` + docID.String() + `",` + branchFields + `"block_uid":"b1",` +
|
||||
`"text":"rate limiting"}]}`,
|
||||
Result: `{"documents":[{"document_id":"` + docID.String() + `",` + branchFields + hit + `}]}`,
|
||||
},
|
||||
// hits of one branch sit under it once, in the order its best hit
|
||||
// ranked; another branch of the same document is its own entry.
|
||||
"Hits are grouped by branch": {
|
||||
Searcher: &SearcherMock{
|
||||
SearchDocumentBlocksFunc: func(context.Context, string, string, int) ([]search.Block, error) {
|
||||
return []search.Block{
|
||||
{DocumentID: docID, BranchID: branchID, BranchName: "draft", ID: branchID.String() + "-b1", Text: "one"},
|
||||
{DocumentID: docID, BranchID: mainID, BranchName: "main", BranchDefault: true, ID: mainID.String() + "-b2", Text: "two"},
|
||||
{DocumentID: docID, BranchID: branchID, BranchName: "draft", ID: branchID.String() + "-b3", Text: "three"},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
DB: &DBMock{},
|
||||
Args: `{"query":"rate limit"}`,
|
||||
Limit: _searchLimitDefault,
|
||||
Result: `{"documents":[` +
|
||||
`{"document_id":"` + docID.String() + `",` + branchFields + `"hits":[{"block_uid":"b1","text":"one"},{"block_uid":"b3","text":"three"}]},` +
|
||||
`{"document_id":"` + docID.String() + `","branch_id":"` + mainID.String() + `","branch_name":"main","default":true,"hits":[{"block_uid":"b2","text":"two"}]}]}`,
|
||||
},
|
||||
"Oversized limit is clipped": {
|
||||
Searcher: hitSearcher(nil),
|
||||
DB: &DBMock{},
|
||||
Args: `{"query":"rate limit","limit":5000}`,
|
||||
Limit: _searchLimitMax,
|
||||
Result: `{"hits":[{"document_id":"` + docID.String() + `",` + branchFields + `"block_uid":"b1",` +
|
||||
`"text":"rate limiting"}]}`,
|
||||
Result: `{"documents":[{"document_id":"` + docID.String() + `",` + branchFields + hit + `}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package tools
|
|||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/oxynote/server/core/internal/tag"
|
||||
|
|
@ -20,23 +20,19 @@ func (listTagsArgs) Validate() error {
|
|||
|
||||
// listTags returns the organisation's tags with the documents carrying
|
||||
// them.
|
||||
type listTags struct {
|
||||
plainSummary
|
||||
plainTraits
|
||||
plainTitle
|
||||
}
|
||||
type listTags struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (listTags) Info() Info {
|
||||
return Info{
|
||||
Name: NameListTags,
|
||||
Description: "List the organisation's tags in their sidebar order as [{id, name, color, hidden, documents}], where color is a palette name, or unknown for a tag whose stored colour is outside the palette and wants recolouring with update_tag, and documents is [{id, name, default_branch_id}] for every document whose default branch carries the tag. Use it to find a tag by name before assigning it, and to learn the id that update_tag, delete_tag, move_tag, assign_tag and unassign_tag take as tag_id. A tag assigned to a non-default branch shows under tags on get_document for that branch but not here. hidden says whether the current user keeps the tag out of their own sidebar; it does not stop the tag being assigned.",
|
||||
Description: "List the organisation's tags in sidebar order as [{id, name, color, hidden, documents}]. color is a palette name, or unknown for a colour outside the palette that update_tag can fix. documents are the documents whose default branch carries the tag; a tag on another branch shows only on get_document for that branch. hidden is whether the current user keeps the tag out of their sidebar, and does not stop it being assigned.",
|
||||
Properties: map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute lists every tag with the documents carrying it.
|
||||
func (listTags) Execute(inp Input) (string, error) {
|
||||
func (listTags) Execute(inp *input) (string, error) {
|
||||
var in listTagsArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -45,7 +41,7 @@ func (listTags) Execute(inp Input) (string, error) {
|
|||
|
||||
tree, err := inp.FetchTagTree()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list_tags: fetch tags: %w", err)
|
||||
return "", fmt.Errorf("fetch tags: %w", err)
|
||||
}
|
||||
|
||||
out := tagListResult{Tags: make([]tagEntry, 0, len(tree))}
|
||||
|
|
@ -133,22 +129,14 @@ func (a createTagArgs) Validate() error {
|
|||
return errRequired("color")
|
||||
}
|
||||
|
||||
inp, err := a.input()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return inp.Validate()
|
||||
return a.input().Validate()
|
||||
}
|
||||
|
||||
// input returns the arguments as the domain's create input.
|
||||
func (a createTagArgs) input() (tag.CreateInput, error) {
|
||||
hex := tag.ColorHex(a.ColorName)
|
||||
if hex == "" {
|
||||
return tag.CreateInput{}, tag.ErrInvalidTagColor
|
||||
}
|
||||
|
||||
return tag.CreateInput{TagName: a.Name, Color: hex}, nil
|
||||
// input returns the arguments as the domain's create input. A colour
|
||||
// outside the palette maps to no colour, which the input's Validate
|
||||
// refuses.
|
||||
func (a createTagArgs) input() tag.CreateInput {
|
||||
return tag.CreateInput{TagName: a.Name, Color: tag.ColorHex(a.ColorName)}
|
||||
}
|
||||
|
||||
// createTag creates a new tag in the organisation.
|
||||
|
|
@ -158,31 +146,16 @@ type createTag struct{}
|
|||
func (createTag) Info() Info {
|
||||
return Info{
|
||||
Name: NameCreateTag,
|
||||
Description: "Create a tag and return {tag_id}. name is the display name, which has to be unused in the organisation, and color one of the palette names the schema lists; a colour outside the palette or a name already in use is refused. The new tag lands last in the sidebar order, so use move_tag to place it, and assign_tag to put it on a document branch.",
|
||||
Traits: Traits{Write: true},
|
||||
Description: "Create a tag and return {tag_id}. The name has to be unused in the organisation. The tag lands last in the sidebar order; update_tag moves it and set_tag_assignment puts it on a document.",
|
||||
Properties: map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "Display name for the new tag; unique within the organisation."},
|
||||
"color": map[string]any{"type": "string", "enum": tag.ColorNames(), "description": "The tag's colour, one of the palette names."},
|
||||
"name": map[string]any{"type": "string", "description": "The display name."},
|
||||
"color": map[string]any{"type": "string", "enum": tag.ColorNames(), "description": "The colour."},
|
||||
},
|
||||
Required: []string{"name", "color"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a write.
|
||||
func (createTag) Traits() Traits {
|
||||
return Traits{Write: true}
|
||||
}
|
||||
|
||||
// Title announces the tag being created.
|
||||
func (createTag) Title(inp DescribeInput) (string, error) {
|
||||
var in createTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "Creating tag " + in.Name, nil
|
||||
}
|
||||
|
||||
// Summary describes the tag the model wants to create.
|
||||
func (createTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in createTagArgs
|
||||
|
|
@ -198,26 +171,19 @@ func (createTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
}
|
||||
|
||||
// Execute creates the tag and refreshes the tag tree.
|
||||
func (createTag) Execute(inp Input) (string, error) {
|
||||
func (createTag) Execute(inp *input) (string, error) {
|
||||
var in createTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ci, err := in.input()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
t := tag.NewTag(ci, inp.OrganizationID(), inp.UserID())
|
||||
t := tag.NewTag(in.input(), inp.orgID, inp.userID)
|
||||
|
||||
if err := inp.CreateTag(t); err != nil {
|
||||
return "", fmt.Errorf("create_tag: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
inp.NotifyTagTreeChange()
|
||||
|
||||
return result(createdTagResult{TagID: t.ID})
|
||||
}
|
||||
|
||||
|
|
@ -238,6 +204,10 @@ type updateTagArgs struct {
|
|||
// ColorName is the new colour as a palette name; empty leaves it
|
||||
// unchanged.
|
||||
ColorName string `json:"color"`
|
||||
|
||||
// SortIndex is the 0-based position the tag moves to; absent leaves
|
||||
// it where it is.
|
||||
SortIndex null.Value[int] `json:"sort_index"`
|
||||
}
|
||||
|
||||
// Validate checks the arguments name a tag and change something.
|
||||
|
|
@ -246,17 +216,27 @@ func (a updateTagArgs) Validate() error {
|
|||
return errRequired("tag_id")
|
||||
}
|
||||
|
||||
inp, err := a.input()
|
||||
if err != nil {
|
||||
return err
|
||||
if !a.renames() {
|
||||
if !a.SortIndex.Valid {
|
||||
return errors.New("give at least one of name, color or sort_index")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return inp.Validate()
|
||||
return a.input().Validate()
|
||||
}
|
||||
|
||||
// renames reports whether the call changes the name or the colour, which
|
||||
// is the tag's own update rather than a move.
|
||||
func (a updateTagArgs) renames() bool {
|
||||
return a.Name != "" || a.ColorName != ""
|
||||
}
|
||||
|
||||
// input returns the arguments as the domain's update input, with only
|
||||
// the fields that were given set.
|
||||
func (a updateTagArgs) input() (tag.UpdateInput, error) {
|
||||
// the fields that were given set. A colour outside the palette maps to
|
||||
// no colour, which the input's Validate refuses.
|
||||
func (a updateTagArgs) input() tag.UpdateInput {
|
||||
var inp tag.UpdateInput
|
||||
|
||||
if a.Name != "" {
|
||||
|
|
@ -264,15 +244,10 @@ func (a updateTagArgs) input() (tag.UpdateInput, error) {
|
|||
}
|
||||
|
||||
if a.ColorName != "" {
|
||||
hex := tag.ColorHex(a.ColorName)
|
||||
if hex == "" {
|
||||
return tag.UpdateInput{}, tag.ErrInvalidTagColor
|
||||
}
|
||||
|
||||
inp.Color = null.StringFrom(hex)
|
||||
inp.Color = null.StringFrom(tag.ColorHex(a.ColorName))
|
||||
}
|
||||
|
||||
return inp, nil
|
||||
return inp
|
||||
}
|
||||
|
||||
// changes lists the requested changes in the words the confirm card
|
||||
|
|
@ -288,47 +263,32 @@ func (a updateTagArgs) changes(currentName string) []string {
|
|||
out = append(out, "set the colour to "+a.ColorName)
|
||||
}
|
||||
|
||||
if a.SortIndex.Valid {
|
||||
out = append(out, fmt.Sprintf("move it to position %d", a.SortIndex.V+1))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// updateTag renames or recolours a tag, or both in one call.
|
||||
// updateTag renames, recolours or moves a tag, any of them in one call.
|
||||
type updateTag struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (updateTag) Info() Info {
|
||||
return Info{
|
||||
Name: NameUpdateTag,
|
||||
Description: "Rename and/or recolour a tag; give only the fields to change, and the other keeps its value. name is the new display name, which has to be unused in the organisation, and color one of the palette names the schema lists; a call with neither is refused. Returns {tag_id} with the fields that changed. To change which documents carry the tag use assign_tag and unassign_tag instead.",
|
||||
Traits: Traits{Write: true},
|
||||
Description: "Rename, recolour or move a tag in the sidebar order; give only what changes, and a call changing nothing is refused. A new name has to be unused in the organisation. Returns {tag_id} with what changed. set_tag_assignment changes which documents carry it.",
|
||||
Properties: map[string]any{
|
||||
"tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
|
||||
"name": map[string]any{"type": "string", "description": "Optional. The new display name; omit to keep the current one."},
|
||||
"color": map[string]any{"type": "string", "enum": tag.ColorNames(), "description": "Optional. The new colour, one of the palette names; omit to keep the current one."},
|
||||
"tag_id": map[string]any{"type": "string", "description": _tagIDDescription},
|
||||
"name": map[string]any{"type": "string", "description": "Optional. The new display name."},
|
||||
"color": map[string]any{"type": "string", "enum": tag.ColorNames(), "description": "Optional. The new colour."},
|
||||
"sort_index": map[string]any{"type": "integer", "description": "Optional. The 0-based position in list_tags order; 0 is first."},
|
||||
},
|
||||
Required: []string{"tag_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a write.
|
||||
func (updateTag) Traits() Traits {
|
||||
return Traits{Write: true}
|
||||
}
|
||||
|
||||
// Title announces which tag is being updated.
|
||||
func (updateTag) Title(inp DescribeInput) (string, error) {
|
||||
var in updateTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tg, err := inp.FetchTag(in.TagID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch tag: %w", NameUpdateTag, err)
|
||||
}
|
||||
|
||||
return "Updating tag " + tg.TagName, nil
|
||||
}
|
||||
|
||||
// Summary lists exactly the changes the model asked for.
|
||||
func (updateTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in updateTagArgs
|
||||
|
|
@ -339,38 +299,55 @@ func (updateTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
|
||||
tg, err := inp.FetchTag(in.TagID)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: fetch tag: %w", NameUpdateTag, err)
|
||||
return ActionSummary{}, fmt.Errorf("fetch tag: %w", err)
|
||||
}
|
||||
|
||||
changes := in.changes(tg.TagName)
|
||||
summary := strings.Join(changes, " and ")
|
||||
|
||||
return ActionSummary{
|
||||
Tool: NameUpdateTag,
|
||||
Summary: strings.ToUpper(summary[:1]) + summary[1:],
|
||||
Summary: sentence(in.changes(tg.TagName)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute applies the change and refreshes the tag tree.
|
||||
func (updateTag) Execute(inp Input) (string, error) {
|
||||
func (updateTag) Execute(inp *input) (string, error) {
|
||||
var in updateTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ui, err := in.input()
|
||||
if err != nil {
|
||||
return "", err
|
||||
// the rename and the move are two writes. The move is tried on the
|
||||
// current order first, so a position it would refuse fails the call
|
||||
// before the rename is saved.
|
||||
if in.renames() && in.SortIndex.Valid {
|
||||
tree, err := inp.FetchTagTree()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching tags: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tree.Swap(in.TagID, in.SortIndex.V); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if err = inp.UpdateTag(in.TagID, ui); err != nil {
|
||||
return "", fmt.Errorf("update_tag: %w", err)
|
||||
if in.renames() {
|
||||
if err := inp.UpdateTag(in.TagID, in.input()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
inp.NotifyTagTreeChange()
|
||||
if in.SortIndex.Valid {
|
||||
if err := inp.MoveTag(in.TagID, in.SortIndex.V); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return result(updatedTagResult(in))
|
||||
out := updatedTagResult{TagID: in.TagID, Name: in.Name, ColorName: in.ColorName}
|
||||
if in.SortIndex.Valid {
|
||||
out.SortIndex = &in.SortIndex.V
|
||||
}
|
||||
|
||||
return result(out)
|
||||
}
|
||||
|
||||
// updatedTagResult is what update_tag returns: the tag and the fields
|
||||
|
|
@ -384,6 +361,9 @@ type updatedTagResult struct {
|
|||
|
||||
// ColorName is the new colour's palette name, when one was set.
|
||||
ColorName string `json:"color,omitempty"`
|
||||
|
||||
// SortIndex is the tag's new 0-based position, when it was moved.
|
||||
SortIndex *int `json:"sort_index,omitempty"`
|
||||
}
|
||||
|
||||
// deleteTagArgs is what delete_tag is called with.
|
||||
|
|
@ -408,36 +388,15 @@ type deleteTag struct{}
|
|||
func (deleteTag) Info() Info {
|
||||
return Info{
|
||||
Name: NameDeleteTag,
|
||||
Description: "Delete a tag. Every document carrying it loses it, on every branch, and the tag cannot be restored; use unassign_tag when the aim is to take it off one document rather than remove it. Returns {tag_id, deleted}.",
|
||||
Traits: Traits{Write: true, Destructive: true},
|
||||
Description: "Delete a tag. Every document loses it, on every branch, and it cannot be restored; set_tag_assignment takes it off one document instead. Returns {tag_id, deleted}.",
|
||||
Properties: map[string]any{
|
||||
"tag_id": map[string]any{"type": "string", "description": "The id of the tag to delete."},
|
||||
"tag_id": map[string]any{"type": "string", "description": _tagIDDescription},
|
||||
},
|
||||
Required: []string{"tag_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a destructive write, which stays outside any "approve
|
||||
// all" answer.
|
||||
func (deleteTag) Traits() Traits {
|
||||
return Traits{Write: true, Destructive: true}
|
||||
}
|
||||
|
||||
// Title announces which tag is being deleted.
|
||||
func (deleteTag) Title(inp DescribeInput) (string, error) {
|
||||
var in deleteTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tg, err := inp.FetchTag(in.TagID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch tag: %w", NameDeleteTag, err)
|
||||
}
|
||||
|
||||
return "Deleting tag " + tg.TagName, nil
|
||||
}
|
||||
|
||||
// Summary describes the tag the model wants to delete.
|
||||
func (deleteTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in deleteTagArgs
|
||||
|
|
@ -448,7 +407,7 @@ func (deleteTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
|
||||
tg, err := inp.FetchTag(in.TagID)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: fetch tag: %w", NameDeleteTag, err)
|
||||
return ActionSummary{}, fmt.Errorf("fetch tag: %w", err)
|
||||
}
|
||||
|
||||
summary := "Delete tag " + tg.TagName
|
||||
|
|
@ -457,7 +416,7 @@ func (deleteTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
// only the tag would have the user approve a change they were never
|
||||
// shown.
|
||||
if n := len(tg.Documents); n > 0 {
|
||||
summary += fmt.Sprintf(" and take it off the %s carrying it", pluralPages(n))
|
||||
summary += fmt.Sprintf(" and take it off the %s carrying it", countPhrase(n, "page"))
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
|
|
@ -467,7 +426,7 @@ func (deleteTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
}
|
||||
|
||||
// Execute deletes the tag and refreshes the tag tree.
|
||||
func (deleteTag) Execute(inp Input) (string, error) {
|
||||
func (deleteTag) Execute(inp *input) (string, error) {
|
||||
var in deleteTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
|
|
@ -475,11 +434,9 @@ func (deleteTag) Execute(inp Input) (string, error) {
|
|||
}
|
||||
|
||||
if err := inp.DeleteTag(in.TagID); err != nil {
|
||||
return "", fmt.Errorf("delete_tag: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
inp.NotifyTagTreeChange()
|
||||
|
||||
return result(deletedTagResult{TagID: in.TagID, Deleted: true})
|
||||
}
|
||||
|
||||
|
|
@ -493,18 +450,23 @@ type deletedTagResult struct {
|
|||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// branchTagArgs is what assign_tag and unassign_tag are called with: a
|
||||
// branch and a tag.
|
||||
type branchTagArgs struct {
|
||||
// setTagAssignmentArgs is what set_tag_assignment is called with: a
|
||||
// branch, a tag, and whether the branch carries it.
|
||||
type setTagAssignmentArgs struct {
|
||||
docTarget
|
||||
|
||||
// TagID names the tag.
|
||||
TagID xid.ID `json:"tag_id"`
|
||||
|
||||
// Assigned says whether the branch carries the tag after the call.
|
||||
// Required, since false is an answer.
|
||||
Assigned null.Value[bool] `json:"assigned"`
|
||||
}
|
||||
|
||||
// Validate checks the arguments name a branch and a tag.
|
||||
func (a branchTagArgs) Validate() error {
|
||||
if err := a.validate(); err != nil {
|
||||
// Validate checks the arguments name a branch and a tag and say which
|
||||
// way the assignment goes.
|
||||
func (a setTagAssignmentArgs) Validate() error {
|
||||
if err := a.docTarget.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -512,50 +474,35 @@ func (a branchTagArgs) Validate() error {
|
|||
return errRequired("tag_id")
|
||||
}
|
||||
|
||||
if !a.Assigned.Valid {
|
||||
return errRequired("assigned")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// assignTag puts a tag on a document branch.
|
||||
type assignTag struct{}
|
||||
// setTagAssignment puts a tag on a document branch or takes it off.
|
||||
type setTagAssignment struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (assignTag) Info() Info {
|
||||
func (setTagAssignment) Info() Info {
|
||||
return Info{
|
||||
Name: NameAssignTag,
|
||||
Description: "Put a tag on one branch of a document, so the document is listed under the tag in the sidebar when the branch is its default one. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from list_tags. A tag the branch already carries is left as it is, and a hidden tag can still be assigned. Returns {document_id, branch_id, tag_id}.",
|
||||
Name: NameSetTagAssignment,
|
||||
Traits: Traits{Write: true},
|
||||
Description: "Put a tag on one branch of a document, or take it off. The document is listed under the tag in the sidebar while its default branch carries it. Setting what is already the case succeeds and changes nothing; delete_tag removes a tag everywhere. Returns {document_id, branch_id, tag_id, assigned}.",
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"branch_id": map[string]any{"type": "string", "description": "The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists."},
|
||||
"tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
|
||||
"document_id": map[string]any{"type": "string", "description": _documentIDDescription},
|
||||
"branch_id": map[string]any{"type": "string", "description": _branchIDDescription},
|
||||
"tag_id": map[string]any{"type": "string", "description": _tagIDDescription},
|
||||
"assigned": map[string]any{"type": "boolean", "description": "true puts the tag on the branch, false takes it off."},
|
||||
},
|
||||
Required: []string{"document_id", "branch_id", "tag_id"},
|
||||
Required: []string{"document_id", "branch_id", "tag_id", "assigned"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a write.
|
||||
func (assignTag) Traits() Traits {
|
||||
return Traits{Write: true}
|
||||
}
|
||||
|
||||
// Title announces which document is being tagged.
|
||||
func (assignTag) Title(inp DescribeInput) (string, error) {
|
||||
var in branchTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch document: %w", NameAssignTag, err)
|
||||
}
|
||||
|
||||
return "Tagging " + doc.Title(), nil
|
||||
}
|
||||
|
||||
// Summary names the tag and the document it goes on.
|
||||
func (assignTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in branchTagArgs
|
||||
// Summary names the tag and the document it goes on or comes off.
|
||||
func (setTagAssignment) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in setTagAssignmentArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return ActionSummary{}, err
|
||||
|
|
@ -563,124 +510,53 @@ func (assignTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
|||
|
||||
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameAssignTag, err)
|
||||
return ActionSummary{}, fmt.Errorf("fetch document: %w", err)
|
||||
}
|
||||
|
||||
tg, err := inp.FetchTag(in.TagID)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: fetch tag: %w", NameAssignTag, err)
|
||||
return ActionSummary{}, fmt.Errorf("fetch tag: %w", err)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Take tag %s off %s", tg.TagName, doc.Title())
|
||||
if in.Assigned.V {
|
||||
summary = fmt.Sprintf("Put tag %s on %s", tg.TagName, doc.Title())
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
Tool: NameAssignTag,
|
||||
Tool: NameSetTagAssignment,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: fmt.Sprintf("Put tag %s on %s", tg.TagName, doc.Title()),
|
||||
Summary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute assigns the tag and refreshes the tag tree.
|
||||
func (assignTag) Execute(inp Input) (string, error) {
|
||||
var in branchTagArgs
|
||||
// Execute assigns or unassigns the tag and refreshes the tag tree.
|
||||
func (setTagAssignment) Execute(inp *input) (string, error) {
|
||||
var in setTagAssignmentArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := inp.AssignTag(in.DocumentID, in.BranchID, in.TagID); err != nil {
|
||||
return "", fmt.Errorf("assign_tag: %w", err)
|
||||
}
|
||||
|
||||
inp.NotifyTagTreeChange()
|
||||
inp.NotifyBranchTagsChange(in.DocumentID, in.BranchID)
|
||||
|
||||
return result(branchTagResult{DocumentID: in.DocumentID, BranchID: in.BranchID, TagID: in.TagID})
|
||||
}
|
||||
|
||||
// unassignTag takes a tag off a document branch.
|
||||
type unassignTag struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (unassignTag) Info() Info {
|
||||
return Info{
|
||||
Name: NameUnassignTag,
|
||||
Description: "Take a tag off one branch of a document; the tag itself stays for other documents, so use delete_tag to remove it everywhere. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from the document's tags on get_document or from list_tags. A tag the branch does not carry is nothing to do, and the call still succeeds. Returns {document_id, branch_id, tag_id}.",
|
||||
Properties: map[string]any{
|
||||
"document_id": map[string]any{"type": "string", "description": "The document id."},
|
||||
"branch_id": map[string]any{"type": "string", "description": "The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists."},
|
||||
"tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
|
||||
},
|
||||
Required: []string{"document_id", "branch_id", "tag_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a write.
|
||||
func (unassignTag) Traits() Traits {
|
||||
return Traits{Write: true}
|
||||
}
|
||||
|
||||
// Title announces which document is being untagged.
|
||||
func (unassignTag) Title(inp DescribeInput) (string, error) {
|
||||
var in branchTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
if in.Assigned.V {
|
||||
if err := inp.AssignTag(in.DocumentID, in.BranchID, in.TagID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if err := inp.UnassignTag(in.DocumentID, in.BranchID, in.TagID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch document: %w", NameUnassignTag, err)
|
||||
}
|
||||
|
||||
return "Untagging " + doc.Title(), nil
|
||||
return result(tagAssignmentResult{
|
||||
DocumentID: in.DocumentID,
|
||||
BranchID: in.BranchID,
|
||||
TagID: in.TagID,
|
||||
Assigned: in.Assigned.V,
|
||||
})
|
||||
}
|
||||
|
||||
// Summary names the tag and the document it comes off.
|
||||
func (unassignTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in branchTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return ActionSummary{}, err
|
||||
}
|
||||
|
||||
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameUnassignTag, err)
|
||||
}
|
||||
|
||||
tg, err := inp.FetchTag(in.TagID)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: fetch tag: %w", NameUnassignTag, err)
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
Tool: NameUnassignTag,
|
||||
DocumentID: doc.ID,
|
||||
DocumentName: doc.DocumentName,
|
||||
Summary: fmt.Sprintf("Take tag %s off %s", tg.TagName, doc.Title()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute unassigns the tag and refreshes the tag tree.
|
||||
func (unassignTag) Execute(inp Input) (string, error) {
|
||||
var in branchTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := inp.UnassignTag(in.DocumentID, in.BranchID, in.TagID); err != nil {
|
||||
return "", fmt.Errorf("unassign_tag: %w", err)
|
||||
}
|
||||
|
||||
inp.NotifyTagTreeChange()
|
||||
inp.NotifyBranchTagsChange(in.DocumentID, in.BranchID)
|
||||
|
||||
return result(branchTagResult{DocumentID: in.DocumentID, BranchID: in.BranchID, TagID: in.TagID})
|
||||
}
|
||||
|
||||
// branchTagResult is what assign_tag and unassign_tag return.
|
||||
type branchTagResult struct {
|
||||
// tagAssignmentResult is what set_tag_assignment returns.
|
||||
type tagAssignmentResult struct {
|
||||
// DocumentID is the document whose branch changed.
|
||||
DocumentID xid.ID `json:"document_id"`
|
||||
|
||||
|
|
@ -689,109 +565,7 @@ type branchTagResult struct {
|
|||
|
||||
// TagID is the tag.
|
||||
TagID xid.ID `json:"tag_id"`
|
||||
}
|
||||
|
||||
// moveTagArgs is what move_tag is called with.
|
||||
type moveTagArgs struct {
|
||||
// TagID names the tag being moved.
|
||||
TagID xid.ID `json:"tag_id"`
|
||||
|
||||
// SortIndex is the 0-based position the tag should end up at.
|
||||
// Required, since the first position is a valid answer.
|
||||
SortIndex null.Value[int] `json:"sort_index"`
|
||||
}
|
||||
|
||||
// Validate checks the arguments are complete.
|
||||
func (a moveTagArgs) Validate() error {
|
||||
if a.TagID.IsNil() {
|
||||
return errRequired("tag_id")
|
||||
}
|
||||
|
||||
if !a.SortIndex.Valid {
|
||||
return errRequired("sort_index")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// moveTag changes a tag's position in the sidebar order.
|
||||
type moveTag struct{}
|
||||
|
||||
// Info returns the tool's model-facing description.
|
||||
func (moveTag) Info() Info {
|
||||
return Info{
|
||||
Name: NameMoveTag,
|
||||
Description: "Move a tag to a position in the sidebar order, which is the order list_tags returns. sort_index is the 0-based position the tag should end up at, so 0 puts it first, and a position past the last tag is refused; the other tags keep their relative order. Returns {tag_id, sort_index}.",
|
||||
Properties: map[string]any{
|
||||
"tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
|
||||
"sort_index": map[string]any{"type": "integer", "description": "The 0-based position the tag should end up at among the organisation's tags."},
|
||||
},
|
||||
Required: []string{"tag_id", "sort_index"},
|
||||
}
|
||||
}
|
||||
|
||||
// Traits reports a write.
|
||||
func (moveTag) Traits() Traits {
|
||||
return Traits{Write: true}
|
||||
}
|
||||
|
||||
// Title announces which tag is being moved.
|
||||
func (moveTag) Title(inp DescribeInput) (string, error) {
|
||||
var in moveTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tg, err := inp.FetchTag(in.TagID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: fetch tag: %w", NameMoveTag, err)
|
||||
}
|
||||
|
||||
return "Moving tag " + tg.TagName, nil
|
||||
}
|
||||
|
||||
// Summary names the tag and where it goes.
|
||||
func (moveTag) Summary(inp DescribeInput) (ActionSummary, error) {
|
||||
var in moveTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return ActionSummary{}, err
|
||||
}
|
||||
|
||||
tg, err := inp.FetchTag(in.TagID)
|
||||
if err != nil {
|
||||
return ActionSummary{}, fmt.Errorf("%s: fetch tag: %w", NameMoveTag, err)
|
||||
}
|
||||
|
||||
return ActionSummary{
|
||||
Tool: NameMoveTag,
|
||||
Summary: fmt.Sprintf("Move tag %s to position %d", tg.TagName, in.SortIndex.V+1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute moves the tag and refreshes the tag tree.
|
||||
func (moveTag) Execute(inp Input) (string, error) {
|
||||
var in moveTagArgs
|
||||
|
||||
if err := inp.Decode(&in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := inp.MoveTag(in.TagID, in.SortIndex.V); err != nil {
|
||||
return "", fmt.Errorf("move_tag: %w", err)
|
||||
}
|
||||
|
||||
inp.NotifyTagTreeChange()
|
||||
|
||||
return result(movedTagResult{TagID: in.TagID, SortIndex: in.SortIndex.V})
|
||||
}
|
||||
|
||||
// movedTagResult is what move_tag returns.
|
||||
type movedTagResult struct {
|
||||
// TagID is the tag that was moved.
|
||||
TagID xid.ID `json:"tag_id"`
|
||||
|
||||
// SortIndex is the 0-based position it now has.
|
||||
SortIndex int `json:"sort_index"`
|
||||
|
||||
// Assigned is whether the branch now carries the tag.
|
||||
Assigned bool `json:"assigned"`
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
|
@ -2,16 +2,10 @@ package tools
|
|||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/block"
|
||||
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
|
||||
"github.com/oxynote/oxynote/server/core/internal/datasource"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document/hook"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document/hook/processor"
|
||||
"github.com/oxynote/oxynote/server/core/internal/search"
|
||||
"github.com/oxynote/oxynote/server/core/internal/tag"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
|
@ -38,6 +32,10 @@ type Info struct {
|
|||
// Required lists the arguments the model must supply. Empty means
|
||||
// the schema says nothing about required arguments.
|
||||
Required []string
|
||||
|
||||
// Traits are the facts that decide how the assistant treats the
|
||||
// tool. The zero value is a plain read.
|
||||
Traits Traits
|
||||
}
|
||||
|
||||
// Schema returns the tool's arguments as a JSON-schema object.
|
||||
|
|
@ -60,27 +58,6 @@ func (info Info) Schema() map[string]any {
|
|||
return out
|
||||
}
|
||||
|
||||
// Available tool access constants.
|
||||
const (
|
||||
// AccessNone is held by no surface outside this process: the tool
|
||||
// addresses conversation state only the assistant has.
|
||||
AccessNone Access = "none"
|
||||
|
||||
// AccessRead reads the organization's documents.
|
||||
AccessRead Access = "read"
|
||||
|
||||
// AccessWrite mutates the organization's documents.
|
||||
AccessWrite Access = "write"
|
||||
|
||||
// AccessDataSource reads one of the organization's outbound
|
||||
// data-source connections, which is not the same permission as
|
||||
// reading its documents.
|
||||
AccessDataSource Access = "data-source"
|
||||
)
|
||||
|
||||
// Access is the permission a surface must hold to offer a tool.
|
||||
type Access string
|
||||
|
||||
// Traits are the facts about a tool that decide how the assistant
|
||||
// treats it. A tool states them in one place in its own file rather
|
||||
// than being interrogated for them from several.
|
||||
|
|
@ -123,94 +100,37 @@ type Traits struct {
|
|||
Internal bool
|
||||
}
|
||||
|
||||
// DestroysContent reports whether a write loses work the caller did not
|
||||
// name: an outright removal, or an overwrite that takes nested blocks,
|
||||
// uids, comments and hooks with it. Both are what a client deciding
|
||||
// whether to confirm first wants to know.
|
||||
func (t Traits) DestroysContent() bool {
|
||||
return t.Destructive || t.Overwrites
|
||||
}
|
||||
|
||||
// OpenWorld reports whether the tool reaches outside the organization's
|
||||
// own documents: a data-source tool reaches whatever the connection
|
||||
// points at.
|
||||
func (t Traits) OpenWorld() bool {
|
||||
return t.DataSource
|
||||
}
|
||||
|
||||
// Access reports the permission a surface must hold to offer the tool.
|
||||
func (t Traits) Access() Access {
|
||||
switch {
|
||||
case t.Internal:
|
||||
return AccessNone
|
||||
case t.DataSource:
|
||||
return AccessDataSource
|
||||
case t.Write:
|
||||
return AccessWrite
|
||||
default:
|
||||
return AccessRead
|
||||
}
|
||||
}
|
||||
|
||||
// Tool is a tool the model can call. What a tool is — whether it
|
||||
// mutates, whether it survives an approve-all, whether it belongs
|
||||
// outside this process — it states in Traits rather than in the set of
|
||||
// interfaces it satisfies, so there is one answer to ask and nothing to
|
||||
// hold in step.
|
||||
// outside this process — it states in its Info's Traits, so there is one
|
||||
// answer to ask and nothing to hold in step.
|
||||
type Tool interface {
|
||||
// Info should return the tool's model-facing description.
|
||||
// Info should return the tool's model-facing description and traits.
|
||||
Info() Info
|
||||
|
||||
// Traits should return the facts that decide how the assistant
|
||||
// treats this tool.
|
||||
Traits() Traits
|
||||
|
||||
// Title should return a short line describing what the tool is
|
||||
// about to do, or an empty string for tools too noisy or too
|
||||
// generic to announce, and reject arguments it cannot read.
|
||||
Title(inp DescribeInput) (string, error)
|
||||
|
||||
// Summary should describe the change the tool is proposing, for
|
||||
// the card asking the user to approve it, without performing it. It
|
||||
// should reject arguments it cannot read: a payload Execute would
|
||||
// refuse is not worth asking the user to approve.
|
||||
//
|
||||
// Only a tool whose Traits report a write is ever asked; the rest
|
||||
// embed plainSummary.
|
||||
Summary(inp DescribeInput) (ActionSummary, error)
|
||||
|
||||
// Execute should perform the tool's work and return the result
|
||||
// serialised for the model.
|
||||
Execute(inp Input) (string, error)
|
||||
// serialised for the model. inp is the call's arguments together
|
||||
// with every read and write the tools share, scoped to the session's
|
||||
// organisation.
|
||||
Execute(inp *input) (string, error)
|
||||
}
|
||||
|
||||
// plainSummary implements the Tool interface to avoid Summary
|
||||
// re-implementation in tools that propose nothing.
|
||||
type plainSummary struct{}
|
||||
|
||||
// Summary describes nothing: a tool that does not write is never gated,
|
||||
// so it is never asked what it is about to change.
|
||||
func (plainSummary) Summary(_ DescribeInput) (ActionSummary, error) {
|
||||
return ActionSummary{}, nil
|
||||
// titler is a read that announces itself while it runs. A read without
|
||||
// a title runs silently; a write is announced by its summary.
|
||||
type titler interface {
|
||||
// Title should return a short line describing what the tool is
|
||||
// about to do, or an empty string when its arguments name nothing it
|
||||
// can describe.
|
||||
Title(inp DescribeInput) string
|
||||
}
|
||||
|
||||
// plainTraits implements the Tool interface to avoid Traits
|
||||
// re-implementation in tools that are plain reads.
|
||||
type plainTraits struct{}
|
||||
|
||||
// Traits reports a plain read: no write, no outbound connection, nothing
|
||||
// internal.
|
||||
func (plainTraits) Traits() Traits {
|
||||
return Traits{}
|
||||
}
|
||||
|
||||
// plainTitle implements the Tool interface to avoid Title
|
||||
// re-implementation in tools too generic to announce.
|
||||
type plainTitle struct{}
|
||||
|
||||
// Title returns no status line.
|
||||
func (plainTitle) Title(_ DescribeInput) (string, error) {
|
||||
return "", nil
|
||||
// summarizer is a write that describes the change it proposes, for the
|
||||
// card asking the user to approve it. Every write is one.
|
||||
type summarizer interface {
|
||||
// Summary should describe the change without performing it, and
|
||||
// reject arguments it cannot read: a payload Execute would refuse is
|
||||
// not worth asking the user to approve.
|
||||
Summary(inp DescribeInput) (ActionSummary, error)
|
||||
}
|
||||
|
||||
// Args is one tool call's decoded arguments. Validate is what Decode
|
||||
|
|
@ -237,7 +157,7 @@ type DescribeInput interface {
|
|||
|
||||
// Decode should decode the call's arguments into dst and validate
|
||||
// them, reporting a payload that is malformed or incomplete as an
|
||||
// error naming the tool. It is the only way into a call's
|
||||
// error. It is the only way into a call's
|
||||
// arguments: a description that cannot read them says so rather
|
||||
// than describing a call from zero values.
|
||||
Decode(dst Args) error
|
||||
|
|
@ -268,226 +188,6 @@ type DescribeInput interface {
|
|||
FetchHook(documentID, hookID xid.ID) (*hook.Hook, error)
|
||||
}
|
||||
|
||||
// DataSources is the organisation's outbound data-source connections as
|
||||
// a tool reads them. Every method is already scoped to the session's
|
||||
// organisation, so a tool can never reach another one's connections.
|
||||
type DataSources interface {
|
||||
// FetchDataSources should return every data source the organisation
|
||||
// owns.
|
||||
FetchDataSources() ([]datasource.DataSource, error)
|
||||
|
||||
// DataSourceRunner should return the runner that operates the data
|
||||
// source the id names, or an error when the organisation owns no
|
||||
// data source with that id.
|
||||
DataSourceRunner(id xid.ID) (datasource.Runner, error)
|
||||
|
||||
// CheckDataSources should refuse a write naming a data source the
|
||||
// organisation does not own.
|
||||
CheckDataSources(ids []string) error
|
||||
}
|
||||
|
||||
// Documents is the organisation's document tree as a tool reads it.
|
||||
// Every method is already scoped to the session's organisation, so a
|
||||
// tool can never reach another one's documents.
|
||||
type Documents interface {
|
||||
// FetchDocument should return the document on its default branch.
|
||||
FetchDocument(documentID xid.ID) (*document.Document, error)
|
||||
|
||||
// FetchBranch should return the document on the branch branchID names,
|
||||
// refusing a branch the document does not have.
|
||||
FetchBranch(documentID, branchID xid.ID) (*document.Document, error)
|
||||
|
||||
// FetchDocumentContent should return the parsed content of the branch
|
||||
// branchID names, for the ops that only need the block tree.
|
||||
FetchDocumentContent(documentID, branchID xid.ID) (document.Content, error)
|
||||
|
||||
// FetchDocumentBlock should return the block blockUID names on the
|
||||
// branch, refusing a uid the branch does not hold.
|
||||
FetchDocumentBlock(documentID, branchID xid.ID, blockUID string) (document.Block, error)
|
||||
|
||||
// FetchDocumentBranches should list every branch of the document,
|
||||
// refusing a document the organisation does not have.
|
||||
FetchDocumentBranches(documentID xid.ID) ([]document.BranchSummary, error)
|
||||
|
||||
// FetchDocumentTree should return every document in the organisation as
|
||||
// a nested summary tree.
|
||||
FetchDocumentTree() (document.Summaries, error)
|
||||
|
||||
// FetchDocumentChildren should return the direct children of parentID; a
|
||||
// null parent means the organisation's root.
|
||||
FetchDocumentChildren(parentID null.Value[xid.ID]) (document.Summaries, error)
|
||||
}
|
||||
|
||||
// DocumentWriter changes the shape of the document tree and announces
|
||||
// what it changed, so connected sidebars stay in step.
|
||||
type DocumentWriter interface {
|
||||
// CreateDocument should insert the document together with its first
|
||||
// maintainer and its search job, atomically, refusing a parent that
|
||||
// does not exist.
|
||||
CreateDocument(doc document.Document) error
|
||||
|
||||
// DeleteDocument should remove the document.
|
||||
DeleteDocument(id xid.ID) error
|
||||
|
||||
// MoveDocument should re-parent the document doc describes, refusing
|
||||
// a parent that does not exist or that would put the document under
|
||||
// itself.
|
||||
MoveDocument(doc *document.Document, parentID null.Value[xid.ID]) error
|
||||
|
||||
// NotifyTreeChange should tell subscribers that the tree under
|
||||
// parentID changed.
|
||||
NotifyTreeChange(parentID null.Value[xid.ID])
|
||||
|
||||
// NotifyTreeChangeForDocument should look up the document's current
|
||||
// parent and fire a tree-change for it, for the ops whose arguments
|
||||
// carry no parent.
|
||||
NotifyTreeChangeForDocument(documentID xid.ID)
|
||||
}
|
||||
|
||||
// Tags is the organisation's tags as a tool reads them. Every method is
|
||||
// already scoped to the session's organisation.
|
||||
type Tags interface {
|
||||
// FetchTagTree should return every tag in the organisation in display
|
||||
// order, each with the documents whose default branch carries it and
|
||||
// whether the session's user hides it.
|
||||
FetchTagTree() (tag.Summaries, error)
|
||||
|
||||
// FetchBranchTags should return the tags the branch branchID names
|
||||
// carries, in the tags' display order.
|
||||
FetchBranchTags(documentID, branchID xid.ID) ([]tag.Tag, error)
|
||||
}
|
||||
|
||||
// TagWriter changes the organisation's tags and what carries them, and
|
||||
// announces the change so connected sidebars stay in step.
|
||||
type TagWriter interface {
|
||||
// CreateTag should insert the tag at the end of the organisation's
|
||||
// tags, refusing a name the organisation already uses.
|
||||
CreateTag(t tag.Tag) error
|
||||
|
||||
// UpdateTag should rename and/or recolour the tag the id names,
|
||||
// refusing an id that names nothing.
|
||||
UpdateTag(tagID xid.ID, inp tag.UpdateInput) error
|
||||
|
||||
// DeleteTag should remove the tag the id names and every assignment
|
||||
// of it, refusing an id that names nothing.
|
||||
DeleteTag(tagID xid.ID) error
|
||||
|
||||
// AssignTag should make the branch branchID names carry the tag,
|
||||
// refusing a document, branch or tag that does not exist.
|
||||
AssignTag(documentID, branchID, tagID xid.ID) error
|
||||
|
||||
// UnassignTag should stop the branch branchID names carrying the
|
||||
// tag, refusing a document, branch or tag that does not exist.
|
||||
UnassignTag(documentID, branchID, tagID xid.ID) error
|
||||
|
||||
// MoveTag should move the tag to the 0-based position among the
|
||||
// organisation's tags, refusing a position outside them.
|
||||
MoveTag(tagID xid.ID, sortIndex int) error
|
||||
|
||||
// NotifyTagTreeChange should tell subscribers that the tag tree
|
||||
// changed.
|
||||
NotifyTagTreeChange()
|
||||
|
||||
// NotifyBranchTagsChange should tell the document's subscribers that
|
||||
// the tags the branch branchID names carries changed, so an open
|
||||
// header refreshes its pills.
|
||||
NotifyBranchTagsChange(documentID, branchID xid.ID)
|
||||
}
|
||||
|
||||
// Hooks is the freshness hooks on a document's branches as a tool reads
|
||||
// them. Every method is already scoped to the session's organisation.
|
||||
type Hooks interface {
|
||||
// FetchHooks should return every hook on the branch branchID names,
|
||||
// refusing a branch the document does not have.
|
||||
FetchHooks(documentID, branchID xid.ID) ([]hook.Hook, error)
|
||||
}
|
||||
|
||||
// HookWriter changes the hooks on a document's branches and announces
|
||||
// the change so an open editor stays in step.
|
||||
type HookWriter interface {
|
||||
// CreateHook should create a hook of the type with the settings on
|
||||
// the branch branchID names, anchored to the block blockUID names
|
||||
// when one is given, refusing a branch or block that does not exist
|
||||
// and a type whose integration the deployment lacks.
|
||||
CreateHook(documentID, branchID xid.ID, blockUID string, t hook.Type, settings processor.Settings) (*hook.Hook, error)
|
||||
|
||||
// UpdateHook should replace the hook's settings and reset its score
|
||||
// and state.
|
||||
UpdateHook(hk *hook.Hook, settings processor.Settings) error
|
||||
|
||||
// ResetHook should restore the hook's score and state, keeping its
|
||||
// settings.
|
||||
ResetHook(hk *hook.Hook) error
|
||||
|
||||
// DeleteHook should tear down what the hook holds outside the
|
||||
// document and remove it.
|
||||
DeleteHook(hk *hook.Hook) error
|
||||
}
|
||||
|
||||
// Editor applies content changes to a live document through the
|
||||
// realtime service, so connected editors see them as they land.
|
||||
type Editor interface {
|
||||
// ApplyEdit should ship an operation batch to the realtime service
|
||||
// for the branch branchID names of the document, reporting any
|
||||
// operation it refused.
|
||||
ApplyEdit(documentID, branchID xid.ID, ops []edit.Operation) error
|
||||
|
||||
// ValidatePlacement should check that a block is legal next to, or
|
||||
// in place of, the block referenceUID names on the branch.
|
||||
ValidatePlacement(documentID, branchID xid.ID, referenceUID string, b block.Block) error
|
||||
|
||||
// ValidateAttrUpdate should check that setting attrs on the block
|
||||
// blockUID names on the branch leaves it with attributes its type
|
||||
// allows.
|
||||
ValidateAttrUpdate(documentID, branchID xid.ID, blockUID string, attrs map[string]any) error
|
||||
|
||||
// ValidateMove should check that the block blockUID names may sit
|
||||
// where a move relative to referenceUID would put it on the branch.
|
||||
ValidateMove(documentID, branchID xid.ID, blockUID, referenceUID string) error
|
||||
}
|
||||
|
||||
// Input is everything a tool needs to do its work: the call's arguments
|
||||
// and context, plus the organisation's documents and the services that
|
||||
// change them.
|
||||
//
|
||||
// It is a facade over the narrower surfaces above rather than a list of
|
||||
// its own, and it is built per call, so a tool holds no state between
|
||||
// them.
|
||||
//
|
||||
//nolint:interfacebloat // facade composed of the narrower surfaces above
|
||||
type Input interface {
|
||||
DescribeInput
|
||||
Documents
|
||||
DocumentWriter
|
||||
DataSources
|
||||
Tags
|
||||
TagWriter
|
||||
Hooks
|
||||
HookWriter
|
||||
Editor
|
||||
|
||||
// OrganizationID should return the organisation every call is
|
||||
// scoped to, for the tools that stamp it onto a new row.
|
||||
OrganizationID() string
|
||||
|
||||
// UserID should return the user the assistant is acting for, for
|
||||
// the tools that record who acted.
|
||||
UserID() string
|
||||
|
||||
// SearchBlocks should return blocks whose text matches the query,
|
||||
// capped at limit hits.
|
||||
SearchBlocks(query string, limit int) ([]search.Block, error)
|
||||
|
||||
// ReadOffloaded should return a tool result that was moved out of
|
||||
// the conversation for size.
|
||||
ReadOffloaded(path string) (string, error)
|
||||
|
||||
// Warn should record something a tool decided to carry on through,
|
||||
// so a degraded result is diagnosable without re-running the
|
||||
// conversation.
|
||||
Warn(msg string, attrs ...slog.Attr)
|
||||
}
|
||||
|
||||
// OffloadReader is the retrieval half of the store holding tool results
|
||||
// that were too large to keep in the conversation. The persist
|
||||
// package's Offload satisfies it.
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_plainSummary_Summary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// a tool that proposes nothing describes nothing, and never fails
|
||||
// doing so: the gate is only ever applied to a write.
|
||||
got, err := plainSummary{}.Summary(testInput(testDeps(nil, nil, nil), NameGetDocument, `{`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ActionSummary{}, got)
|
||||
}
|
||||
|
||||
func Test_plainTraits_Traits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// a plain read writes nothing, reaches no outbound connection and
|
||||
// belongs on every surface.
|
||||
assert.Equal(t, Traits{}, plainTraits{}.Traits())
|
||||
}
|
||||
|
||||
func Test_Traits_DestroysContent(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Traits Traits
|
||||
Result bool
|
||||
}{
|
||||
"Read": {},
|
||||
"Plain write": {Traits: Traits{Write: true}},
|
||||
"Destructive": {Traits: Traits{Write: true, Destructive: true}, Result: true},
|
||||
"Overwriting": {Traits: Traits{Write: true, Overwrites: true}, Result: true},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, c.Traits.DestroysContent())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Traits_OpenWorld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.False(t, Traits{}.OpenWorld())
|
||||
assert.True(t, Traits{DataSource: true}.OpenWorld())
|
||||
}
|
||||
|
||||
func Test_Traits_Access(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Traits Traits
|
||||
Result Access
|
||||
}{
|
||||
"Read": {Result: AccessRead},
|
||||
"Write": {Traits: Traits{Write: true}, Result: AccessWrite},
|
||||
"Data source": {Traits: Traits{DataSource: true}, Result: AccessDataSource},
|
||||
"Internal": {Traits: Traits{Internal: true}, Result: AccessNone},
|
||||
"Internal write": {Traits: Traits{Write: true, Internal: true}, Result: AccessNone},
|
||||
"Data source over write": {Traits: Traits{Write: true, DataSource: true}, Result: AccessDataSource},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, c.Traits.Access())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_plainTitle_Title(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// a tool too generic to announce says nothing, and never fails
|
||||
// doing so: the arguments are not even read.
|
||||
got, err := plainTitle{}.Title(testInput(testDeps(nil, nil, nil), NameListDocuments, `{`))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ import (
|
|||
"slices"
|
||||
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// Name is the canonical identifier of one tool the assistant
|
||||
|
|
@ -26,7 +25,6 @@ type Name string
|
|||
const (
|
||||
NameListDocuments Name = "list_documents"
|
||||
NameGetDocument Name = "get_document"
|
||||
NameReadBlock Name = "read_block"
|
||||
NameSearchDocuments Name = "search_documents"
|
||||
NameListTags Name = "list_tags"
|
||||
NameListHooks Name = "list_hooks"
|
||||
|
|
@ -37,15 +35,11 @@ const (
|
|||
// gates by scope asks Traits.DataSource rather than lumping them in
|
||||
// with the document reads.
|
||||
const (
|
||||
NameListDataSources Name = "list_data_sources"
|
||||
NameGetPrometheusMetadata Name = "get_prometheus_metadata"
|
||||
NameListPrometheusLabelNames Name = "list_prometheus_label_names"
|
||||
NameListPrometheusLabelValues Name = "list_prometheus_label_values"
|
||||
NameListPrometheusSeries Name = "list_prometheus_series"
|
||||
NameQueryPrometheus Name = "query_prometheus"
|
||||
NameGetSQLMetadata Name = "get_sql_metadata"
|
||||
NameGetSQLQueryLabels Name = "get_sql_query_labels"
|
||||
NameQuerySQL Name = "query_sql"
|
||||
NameListDataSources Name = "list_data_sources"
|
||||
NameGetDataSourceMetadata Name = "get_data_source_metadata"
|
||||
NameListPrometheusLabels Name = "list_prometheus_labels"
|
||||
NameListPrometheusSeries Name = "list_prometheus_series"
|
||||
NameQueryDataSource Name = "query_data_source"
|
||||
)
|
||||
|
||||
// Write tools — always confirmed. Deletes are the most
|
||||
|
|
@ -54,21 +48,16 @@ const (
|
|||
NameCreateDocument Name = "create_document"
|
||||
NameDeleteDocument Name = "delete_document"
|
||||
NameUpdateDocument Name = "update_document"
|
||||
NameInsertBlock Name = "insert_block"
|
||||
NameReplaceBlock Name = "replace_block"
|
||||
NameUpdateBlockText Name = "update_block_text"
|
||||
NameUpdateBlockAttrs Name = "update_block_attrs"
|
||||
NameInsertBlocks Name = "insert_blocks"
|
||||
NameReplaceBlocks Name = "replace_blocks"
|
||||
NameDeleteBlock Name = "delete_block"
|
||||
NameMoveBlock Name = "move_block"
|
||||
NameCreateTag Name = "create_tag"
|
||||
NameUpdateTag Name = "update_tag"
|
||||
NameDeleteTag Name = "delete_tag"
|
||||
NameAssignTag Name = "assign_tag"
|
||||
NameUnassignTag Name = "unassign_tag"
|
||||
NameMoveTag Name = "move_tag"
|
||||
NameSetTagAssignment Name = "set_tag_assignment"
|
||||
NameCreateHook Name = "create_hook"
|
||||
NameUpdateHook Name = "update_hook"
|
||||
NameResetHook Name = "reset_hook"
|
||||
NameDeleteHook Name = "delete_hook"
|
||||
)
|
||||
|
||||
|
|
@ -103,38 +92,28 @@ func New(deps *Deps) *Set {
|
|||
all := []Tool{
|
||||
listDocuments{},
|
||||
getDocument{},
|
||||
readBlock{},
|
||||
listTags{},
|
||||
listHooks{},
|
||||
searchDocuments{},
|
||||
listDataSources{},
|
||||
getPrometheusMetadata{},
|
||||
listPrometheusLabelNames{},
|
||||
listPrometheusLabelValues{},
|
||||
getDataSourceMetadata{},
|
||||
listPrometheusLabels{},
|
||||
listPrometheusSeries{},
|
||||
queryPrometheus{},
|
||||
getSQLMetadata{},
|
||||
getSQLQueryLabels{},
|
||||
querySQL{},
|
||||
queryDataSource{},
|
||||
|
||||
createDocument{},
|
||||
deleteDocument{},
|
||||
updateDocument{},
|
||||
insertBlock{},
|
||||
replaceBlock{},
|
||||
updateBlockText{},
|
||||
updateBlockAttrs{},
|
||||
insertBlocks{},
|
||||
replaceBlocks{},
|
||||
deleteBlock{},
|
||||
moveBlock{},
|
||||
createTag{},
|
||||
updateTag{},
|
||||
deleteTag{},
|
||||
assignTag{},
|
||||
unassignTag{},
|
||||
moveTag{},
|
||||
setTagAssignment{},
|
||||
createHook{},
|
||||
updateHook{},
|
||||
resetHook{},
|
||||
deleteHook{},
|
||||
|
||||
readToolOutput{},
|
||||
|
|
@ -144,13 +123,11 @@ func New(deps *Deps) *Set {
|
|||
|
||||
for _, tl := range all {
|
||||
et := newEinoTool(tl, deps)
|
||||
tr := tl.Traits()
|
||||
tr := et.info.Traits
|
||||
|
||||
s.entries = append(s.entries, Entry{
|
||||
Traits: tr,
|
||||
Name: et.info.Name,
|
||||
Info: et.info,
|
||||
Tool: et,
|
||||
Info: et.info,
|
||||
Tool: et,
|
||||
})
|
||||
|
||||
var it registryTool = et
|
||||
|
|
@ -171,18 +148,12 @@ func New(deps *Deps) *Set {
|
|||
return s
|
||||
}
|
||||
|
||||
// Entry describes one registered tool: what kind of tool it is, its
|
||||
// name, how it describes itself, and its implementation with no
|
||||
// confirmation gate applied.
|
||||
// Entry describes one registered tool: how it describes itself, and its
|
||||
// implementation with no confirmation gate applied.
|
||||
type Entry struct {
|
||||
Traits
|
||||
|
||||
// Name is the tool's canonical identifier.
|
||||
Name Name
|
||||
|
||||
// Info is the tool's own description. A surface that has to
|
||||
// announce the tool builds its description from this, so no surface
|
||||
// has to restate a schema the tool already owns.
|
||||
// Info is the tool's own description and traits. A surface that has
|
||||
// to announce the tool builds its description from this, so no
|
||||
// surface has to restate a schema the tool already owns.
|
||||
Info Info
|
||||
|
||||
// Tool is the tool without its confirmation gate. Running it
|
||||
|
|
@ -205,7 +176,7 @@ func (s *Set) Entries() []Entry {
|
|||
// addressed.
|
||||
func (s *Set) Entry(name Name) (Entry, bool) {
|
||||
for _, e := range s.entries {
|
||||
if e.Name == name {
|
||||
if e.Info.Name == name {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
|
|
@ -240,12 +211,7 @@ func (s *Set) Label(ctx context.Context, name Name, args json.RawMessage) string
|
|||
return ""
|
||||
}
|
||||
|
||||
label, err := t.Title(ctx, args)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return label
|
||||
return t.Title(ctx, args)
|
||||
}
|
||||
|
||||
// WriteNames returns every tool that mutates a document.
|
||||
|
|
@ -258,41 +224,14 @@ func (s *Set) WriteNames() []string {
|
|||
return slices.Clone(s.writes)
|
||||
}
|
||||
|
||||
// Result is one tool call's outcome: the payload the model reads, and
|
||||
// the documents the call changed.
|
||||
type Result struct {
|
||||
// Output is the tool's result, serialised for the caller.
|
||||
Output string
|
||||
|
||||
// Documents lists the branches this call created or changed, in
|
||||
// the order it touched them.
|
||||
//
|
||||
// It is recorded by the Input as the call mutates rather than read
|
||||
// back out of the arguments, so it is right for a call that changes
|
||||
// several documents and empty for one that changes none — neither
|
||||
// of which an argument can be asked about. A delete records
|
||||
// nothing: the document it names no longer exists to point at.
|
||||
Documents []Touched
|
||||
}
|
||||
|
||||
// Touched names one branch of a document a call changed, which is what
|
||||
// a surface needs to link back to it.
|
||||
type Touched struct {
|
||||
// DocumentID is the document changed.
|
||||
DocumentID xid.ID
|
||||
|
||||
// BranchID is the branch of it the change landed on.
|
||||
BranchID xid.ID
|
||||
}
|
||||
|
||||
// Runner runs one tool call in this package's own vocabulary: the raw
|
||||
// JSON arguments in, the outcome out. Surfaces outside this package
|
||||
// reach a tool through it, so none of them has to speak the agent
|
||||
// framework's interface to run one.
|
||||
type Runner interface {
|
||||
// Run should perform the call and report what it produced and what
|
||||
// it changed.
|
||||
Run(ctx context.Context, args json.RawMessage) (Result, error)
|
||||
// Run should perform the call and return its result serialised for
|
||||
// the caller.
|
||||
Run(ctx context.Context, args json.RawMessage) (string, error)
|
||||
}
|
||||
|
||||
// registryTool is what the registry stores: a tool the agent can invoke
|
||||
|
|
@ -305,5 +244,5 @@ type registryTool interface {
|
|||
// Title should return a short line describing what the tool is
|
||||
// about to do, or an empty string for tools too noisy or too
|
||||
// generic to announce.
|
||||
Title(ctx context.Context, args json.RawMessage) (string, error)
|
||||
Title(ctx context.Context, args json.RawMessage) string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,37 +19,27 @@ func allToolNames() []Name {
|
|||
return []Name{
|
||||
NameListDocuments,
|
||||
NameGetDocument,
|
||||
NameReadBlock,
|
||||
NameListTags,
|
||||
NameListHooks,
|
||||
NameSearchDocuments,
|
||||
NameListDataSources,
|
||||
NameGetPrometheusMetadata,
|
||||
NameListPrometheusLabelNames,
|
||||
NameListPrometheusLabelValues,
|
||||
NameGetDataSourceMetadata,
|
||||
NameListPrometheusLabels,
|
||||
NameListPrometheusSeries,
|
||||
NameQueryPrometheus,
|
||||
NameGetSQLMetadata,
|
||||
NameGetSQLQueryLabels,
|
||||
NameQuerySQL,
|
||||
NameQueryDataSource,
|
||||
NameCreateDocument,
|
||||
NameDeleteDocument,
|
||||
NameUpdateDocument,
|
||||
NameInsertBlock,
|
||||
NameReplaceBlock,
|
||||
NameUpdateBlockText,
|
||||
NameUpdateBlockAttrs,
|
||||
NameInsertBlocks,
|
||||
NameReplaceBlocks,
|
||||
NameDeleteBlock,
|
||||
NameMoveBlock,
|
||||
NameCreateTag,
|
||||
NameUpdateTag,
|
||||
NameDeleteTag,
|
||||
NameAssignTag,
|
||||
NameUnassignTag,
|
||||
NameMoveTag,
|
||||
NameSetTagAssignment,
|
||||
NameCreateHook,
|
||||
NameUpdateHook,
|
||||
NameResetHook,
|
||||
NameDeleteHook,
|
||||
NameReadToolOutput,
|
||||
}
|
||||
|
|
@ -89,16 +79,19 @@ func Test_New(t *testing.T) {
|
|||
assert.Equal(t, string(name), info.Name)
|
||||
|
||||
tl := adapter(t, it).tl
|
||||
tr := tl.Traits()
|
||||
tr := tl.Info().Traits
|
||||
|
||||
// a write has to describe what it proposes, and nothing else
|
||||
// has anything to propose. Every tool satisfies the interface,
|
||||
// so this asks what a compiler cannot: that the summary is
|
||||
// actually there.
|
||||
sum, serr := tl.Summary(testInput(testDeps(stubDocumentDB(), nil, nil), name, requiredArgs(t, name)))
|
||||
require.NoError(t, serr)
|
||||
assert.Equal(t, tr.Write, sum.Summary != "",
|
||||
"%s declares Write=%v but Summary=%q", name, tr.Write, sum.Summary)
|
||||
// has anything to propose: this asks what a compiler cannot,
|
||||
// that every write's summary is actually there.
|
||||
s, summarizes := tl.(summarizer)
|
||||
require.Equal(t, tr.Write, summarizes, "%s declares Write=%v but summarizer=%v", name, tr.Write, summarizes)
|
||||
|
||||
if summarizes {
|
||||
sum, serr := s.Summary(testInput(testDeps(stubContentDB(nil), nil, nil), name, requiredArgs(t, name)))
|
||||
require.NoError(t, serr)
|
||||
assert.NotEmpty(t, sum.Summary, "%s has an empty summary", name)
|
||||
}
|
||||
|
||||
// a destructive tool is a write first; nothing else can be
|
||||
// destructive.
|
||||
|
|
@ -152,11 +145,7 @@ func Test_Set_Entries(t *testing.T) {
|
|||
destructive := []Name{NameDeleteDocument, NameDeleteBlock, NameDeleteTag, NameDeleteHook}
|
||||
|
||||
for i, e := range entries {
|
||||
assert.Equal(t, allToolNames()[i], e.Name)
|
||||
|
||||
// the entry carries the tool's own description, so a surface
|
||||
// outside this package never has to ask the tool for it.
|
||||
assert.Equal(t, e.Name, e.Info.Name)
|
||||
assert.Equal(t, allToolNames()[i], e.Info.Name)
|
||||
|
||||
// every model-facing string follows the rules a description keeps
|
||||
// on both surfaces: it says nothing about a confirmation flow,
|
||||
|
|
@ -192,26 +181,27 @@ func Test_Set_Entries(t *testing.T) {
|
|||
walk(e.Info.Properties, "")
|
||||
|
||||
for path, text := range texts {
|
||||
assert.NotEmpty(t, text, "%s: %s has no description", e.Name, path)
|
||||
assert.NotEmpty(t, text, "%s: %s has no description", e.Info.Name, path)
|
||||
|
||||
for _, banned := range []string{"confirm", "approv", "\u2014", "organization"} {
|
||||
assert.NotContains(t, strings.ToLower(text), banned, "%s: %s", e.Name, path)
|
||||
assert.NotContains(t, strings.ToLower(text), banned, "%s: %s", e.Info.Name, path)
|
||||
}
|
||||
}
|
||||
|
||||
// the entry carries the tool without its confirmation gate,
|
||||
// while the registry keeps the gated one for the chat loop.
|
||||
_, gated := e.Tool.(*confirming)
|
||||
assert.False(t, gated, "%s entry must be ungated", e.Name)
|
||||
assert.False(t, gated, "%s entry must be ungated", e.Info.Name)
|
||||
|
||||
assert.Equal(t, slices.Contains(s.WriteNames(), string(e.Name)), e.Write, "%s write flag", e.Name)
|
||||
assert.Equal(t, slices.Contains(destructive, e.Name), e.Destructive, "%s destructive flag", e.Name)
|
||||
assert.Equal(t, e.Name == NameReadToolOutput, e.Internal, "%s internal flag", e.Name)
|
||||
tr := e.Info.Traits
|
||||
assert.Equal(t, slices.Contains(s.WriteNames(), string(e.Info.Name)), tr.Write, "%s write flag", e.Info.Name)
|
||||
assert.Equal(t, slices.Contains(destructive, e.Info.Name), tr.Destructive, "%s destructive flag", e.Info.Name)
|
||||
assert.Equal(t, e.Info.Name == NameReadToolOutput, tr.Internal, "%s internal flag", e.Info.Name)
|
||||
}
|
||||
|
||||
// mutating the returned slice must not affect the registry.
|
||||
entries[0].Name = "clobbered"
|
||||
assert.Equal(t, allToolNames()[0], s.Entries()[0].Name)
|
||||
entries[0].Info.Name = "clobbered"
|
||||
assert.Equal(t, allToolNames()[0], s.Entries()[0].Info.Name)
|
||||
}
|
||||
|
||||
func Test_Set_Entry(t *testing.T) {
|
||||
|
|
@ -258,10 +248,10 @@ func Test_Set_Entry(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Result, e.Name)
|
||||
assert.Equal(t, c.Result, e.Info.Name)
|
||||
|
||||
_, gated := e.Tool.(*confirming)
|
||||
assert.False(t, gated, "%s entry must be ungated", e.Name)
|
||||
assert.False(t, gated, "%s entry must be ungated", e.Info.Name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -279,21 +269,16 @@ func Test_Set_WriteNames(t *testing.T) {
|
|||
string(NameCreateDocument),
|
||||
string(NameDeleteDocument),
|
||||
string(NameUpdateDocument),
|
||||
string(NameInsertBlock),
|
||||
string(NameReplaceBlock),
|
||||
string(NameUpdateBlockText),
|
||||
string(NameUpdateBlockAttrs),
|
||||
string(NameInsertBlocks),
|
||||
string(NameReplaceBlocks),
|
||||
string(NameDeleteBlock),
|
||||
string(NameMoveBlock),
|
||||
string(NameCreateTag),
|
||||
string(NameUpdateTag),
|
||||
string(NameDeleteTag),
|
||||
string(NameAssignTag),
|
||||
string(NameUnassignTag),
|
||||
string(NameMoveTag),
|
||||
string(NameSetTagAssignment),
|
||||
string(NameCreateHook),
|
||||
string(NameUpdateHook),
|
||||
string(NameResetHook),
|
||||
string(NameDeleteHook),
|
||||
}, got)
|
||||
|
||||
|
|
@ -325,15 +310,17 @@ func Test_Set_Label(t *testing.T) {
|
|||
Args: `{` + targetArgs(_stubMainBranchID) + `}`,
|
||||
Result: "Reading Runbook",
|
||||
},
|
||||
"Write names the document": {
|
||||
Name: NameUpdateBlockText,
|
||||
Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"b","text":"t"}`,
|
||||
Result: "Updating Runbook",
|
||||
// a write is announced by the summary its approval card shows.
|
||||
"Write is announced by its summary": {
|
||||
DB: stubContentDB(nil),
|
||||
Name: NameReplaceBlocks,
|
||||
Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"a","content":"<p>t</p>"}`,
|
||||
Result: "Replace a block in Runbook with 1 block",
|
||||
},
|
||||
"Tag write names the tag": {
|
||||
"Tag write is announced by its summary": {
|
||||
Name: NameDeleteTag,
|
||||
Args: `{"tag_id":"` + _testTagID.String() + `"}`,
|
||||
Result: "Deleting tag Production",
|
||||
Result: "Delete tag Production and take it off the 1 page carrying it",
|
||||
},
|
||||
"Unresolvable document is not announced": {
|
||||
DB: &DBMock{
|
||||
|
|
|
|||
|
|
@ -1,19 +1,76 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Descriptions of the arguments several tools share, so each is worded
|
||||
// once.
|
||||
const (
|
||||
// _documentIDDescription describes a document_id argument.
|
||||
_documentIDDescription = "The document id."
|
||||
|
||||
// _branchIDDescription describes a branch_id argument.
|
||||
_branchIDDescription = "The branch id, from list_documents, search_documents or get_document. A protected branch refuses writes."
|
||||
|
||||
// _dataSourceIDDescription describes a data_source_id argument.
|
||||
_dataSourceIDDescription = "The data source id, from list_data_sources."
|
||||
|
||||
// _tagIDDescription describes a tag_id argument.
|
||||
_tagIDDescription = "The tag id, from list_tags or a document's tags."
|
||||
|
||||
// _hookIDDescription describes a hook_id argument.
|
||||
_hookIDDescription = "The hook id, from list_hooks."
|
||||
|
||||
// _fromDescription describes the start of a time range.
|
||||
_fromDescription = "Optional. Range start, RFC 3339. Defaults to an hour before to."
|
||||
|
||||
// _toDescription describes the end of a time range.
|
||||
_toDescription = "Optional. Range end, RFC 3339. Defaults to now."
|
||||
|
||||
// _matchersDescription describes a list of PromQL series selectors.
|
||||
_matchersDescription = `PromQL series selectors, such as ["up", "{job=\"api\"}"].`
|
||||
)
|
||||
|
||||
// result is the single place tool result envelopes are serialised;
|
||||
// centralising it keeps the JSON shape consistent.
|
||||
// centralising it keeps the JSON shape consistent. <, > and & stay as
|
||||
// they are: escaped, they cost the model six characters each, and
|
||||
// document content is full of them.
|
||||
func result(v any) (string, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
var buf bytes.Buffer
|
||||
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return "", fmt.Errorf("marshalling tool result: %w", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
return strings.TrimSuffix(buf.String(), "\n"), nil
|
||||
}
|
||||
|
||||
// countPhrase phrases a count of the noun for a summary, such as
|
||||
// "1 page" or "3 pages".
|
||||
func countPhrase(n int, noun string) string {
|
||||
if n == 1 {
|
||||
return "1 " + noun
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d %ss", n, noun)
|
||||
}
|
||||
|
||||
// sentence joins clauses as "A, b and c", with the first letter
|
||||
// capitalised.
|
||||
func sentence(clauses []string) string {
|
||||
s := clauses[len(clauses)-1]
|
||||
if len(clauses) > 1 {
|
||||
s = strings.Join(clauses[:len(clauses)-1], ", ") + " and " + s
|
||||
}
|
||||
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// errRequired reports an argument the tool cannot act without.
|
||||
|
|
|
|||
|
|
@ -25,3 +25,40 @@ func Test_errRequired(t *testing.T) {
|
|||
|
||||
assert.EqualError(t, errRequired("document_id"), "document_id is required")
|
||||
}
|
||||
|
||||
func Test_countPhrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "1 page", countPhrase(1, "page"))
|
||||
assert.Equal(t, "3 blocks", countPhrase(3, "block"))
|
||||
}
|
||||
|
||||
func Test_sentence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cc := map[string]struct {
|
||||
Clauses []string
|
||||
Result string
|
||||
}{
|
||||
"One clause": {
|
||||
Clauses: []string{"rename it"},
|
||||
Result: "Rename it",
|
||||
},
|
||||
"Two clauses": {
|
||||
Clauses: []string{"rename it", "move it"},
|
||||
Result: "Rename it and move it",
|
||||
},
|
||||
"Three clauses": {
|
||||
Clauses: []string{"rename it", "recolour it", "move it"},
|
||||
Result: "Rename it, recolour it and move it",
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.Result, sentence(c.Clauses))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ func Test_turn_relayMessage(t *testing.T) {
|
|||
},
|
||||
"Narration before a tool call is transient": {
|
||||
Msg: schema.AssistantMessage("let me look", []schema.ToolCall{
|
||||
{ID: "1", Function: schema.FunctionCall{Name: "read_block"}},
|
||||
{ID: "1", Function: schema.FunctionCall{Name: "get_document"}},
|
||||
}),
|
||||
Result: "let me look",
|
||||
Kind: protocol.TextEndKindStatus,
|
||||
|
|
@ -287,7 +287,7 @@ func Test_turn_relayStream(t *testing.T) {
|
|||
"Narration before a tool call is transient": {
|
||||
Stream: schema.StreamReaderFromArray([]*schema.Message{
|
||||
schema.AssistantMessage("let me look", []schema.ToolCall{
|
||||
{ID: "1", Function: schema.FunctionCall{Name: "read_block"}},
|
||||
{ID: "1", Function: schema.FunctionCall{Name: "get_document"}},
|
||||
}),
|
||||
}),
|
||||
Result: "let me look",
|
||||
|
|
@ -379,7 +379,7 @@ func Test_turn_confirmation(t *testing.T) {
|
|||
InterruptContexts: []*adk.InterruptCtx{{
|
||||
ID: "i1",
|
||||
Info: tools.ActionSummary{
|
||||
Tool: tools.NameUpdateBlockText,
|
||||
Tool: tools.NameReplaceBlocks,
|
||||
DocumentID: docID,
|
||||
DocumentName: "Runbook",
|
||||
Summary: "Reword the intro",
|
||||
|
|
@ -388,7 +388,7 @@ func Test_turn_confirmation(t *testing.T) {
|
|||
},
|
||||
IDs: []string{"i1"},
|
||||
Actions: []protocol.ConfirmAction{{
|
||||
Tool: string(tools.NameUpdateBlockText),
|
||||
Tool: string(tools.NameReplaceBlocks),
|
||||
DocumentID: docID.String(),
|
||||
DocumentName: "Runbook",
|
||||
Summary: "Reword the intro",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user