silverbullet/plugs/index/attributes.ts

253 lines
6.8 KiB
TypeScript

import type {
CompleteEvent,
ObjectValue,
QueryExpression,
} from "../../plug-api/types.ts";
import { events, system } from "@silverbulletmd/silverbullet/syscalls";
import { queryObjects } from "./api.ts";
import { determineTags } from "$lib/cheap_yaml.ts";
import type { TagObject } from "./tags.ts";
export type SimpleJSONType = {
type?: "string" | "number" | "boolean" | "any" | "array" | "object" | "null";
items?: SimpleJSONType;
properties?: Record<string, SimpleJSONType>;
anyOf?: SimpleJSONType[];
};
export type AdhocAttributeObject = ObjectValue<{
name: string;
schema: SimpleJSONType;
tagName: string;
page: string;
}>;
export type AttributeCompleteEvent = {
source: string;
prefix: string;
};
export type AttributeCompletion = {
name: string;
source: string;
// String version of JSON schema
attributeType: string;
readOnly?: boolean;
};
/**
* Triggered by the `attribute:complete:*` event (that is: gimme all attribute completions)
* @param attributeCompleteEvent
* @returns
*/
export async function objectAttributeCompleter(
attributeCompleteEvent: AttributeCompleteEvent,
): Promise<AttributeCompletion[]> {
const attributeFilter: QueryExpression | undefined =
attributeCompleteEvent.source === ""
? undefined
: ["=", ["attr", "tagName"], ["string", attributeCompleteEvent.source]];
const schema = await system.getSpaceConfig("schema");
const allAttributes = (await queryObjects<AdhocAttributeObject>("ah-attr", {
filter: attributeFilter,
distinct: true,
select: [{ name: "name" }, { name: "schema" }, { name: "tag" }, {
name: "tagName",
}],
}, 5)).map((value) => {
return {
name: value.name,
source: value.tagName,
attributeType: jsonTypeToString(value.schema),
} as AttributeCompletion;
});
// Add attributes from the direct schema
addAttributeCompletionsForTag(
schema,
attributeCompleteEvent.source,
allAttributes,
);
// Look up the tag so we can check the parent as well
const sourceTags = await queryObjects<TagObject>("tag", {
filter: ["=", ["attr", "name"], ["string", attributeCompleteEvent.source]],
});
if (sourceTags.length > 0) {
addAttributeCompletionsForTag(schema, sourceTags[0].parent, allAttributes);
}
return allAttributes;
}
function addAttributeCompletionsForTag(
schema: any,
tag: string,
allAttributes: AttributeCompletion[],
) {
if (schema.tag[tag]) {
for (
const [name, value] of Object.entries(
schema.tag[tag].properties as Record<
string,
any
>,
)
) {
allAttributes.push({
name,
source: tag,
attributeType: jsonTypeToString(value),
readOnly: value.readOnly,
});
}
}
}
/**
* Offer completions for _setting_ attributes on objects (either in frontmatter or inline)
* Triggered by `editor:complete` events from the editor
*/
export async function attributeComplete(completeEvent: CompleteEvent) {
if (/([\-\*]\s+\[)([^\]]+)$/.test(completeEvent.linePrefix)) {
// Don't match task states, which look similar
return null;
}
// Inline attribute completion (e.g. [myAttr: 10])
const inlineAttributeMatch = /([^\[\{}]|^)\[(\w+)$/.exec(
completeEvent.linePrefix,
);
if (inlineAttributeMatch) {
// console.log("Parents", completeEvent.parentNodes);
let type = "page";
if (completeEvent.parentNodes.includes("Task")) {
type = "task";
} else if (completeEvent.parentNodes.includes("ListItem")) {
type = "item";
}
const completions = (await events.dispatchEvent(
`attribute:complete:${type}`,
{
source: type,
prefix: inlineAttributeMatch[2],
} as AttributeCompleteEvent,
)).flat() as AttributeCompletion[];
return {
from: completeEvent.pos - inlineAttributeMatch[2].length,
options: attributeCompletionsToCMCompletion(
// Filter out read-only attributes
completions.filter((completion) => !completion.readOnly),
),
};
}
// Frontmatter attribute completion
const attributeMatch = /^(\w+)$/.exec(completeEvent.linePrefix);
if (attributeMatch) {
const frontmatterParent = completeEvent.parentNodes.find((node) =>
node.startsWith("FrontMatter:")
);
if (frontmatterParent) {
const tags = [
"page",
...determineTags(frontmatterParent.slice("FrontMatter:".length)),
];
const completions = (await Promise.all(tags.map((tag) =>
events.dispatchEvent(
`attribute:complete:${tag}`,
{
source: tag,
prefix: attributeMatch[1],
} as AttributeCompleteEvent,
)
))).flat(2) as AttributeCompletion[];
// console.log("Completions", completions);
return {
from: completeEvent.pos - attributeMatch[1].length,
options: attributeCompletionsToCMCompletion(
completions.filter((completion) =>
!completion.readOnly
),
),
};
}
}
return null;
}
export function attributeCompletionsToCMCompletion(
completions: AttributeCompletion[],
) {
return completions.map(
(completion) => ({
label: completion.name,
apply: `${completion.name}: `,
detail: `${completion.attributeType} (${completion.source})`,
type: "attribute",
}),
);
}
/**
* Attempt some reasonable stringification of a JSON schema
* @param schema
* @returns
*/
export function jsonTypeToString(schema: SimpleJSONType): string {
if (schema.anyOf) {
return schema.anyOf.map(jsonTypeToString).join(" | ");
} else if (schema.type === "array") {
if (schema.items) {
return `${jsonTypeToString(schema.items)}[]`;
} else {
return "any[]";
}
} else if (schema.type === "object") {
if (schema.properties) {
return `{${
Object.entries(schema.properties).map(([k, v]) =>
`${k}: ${jsonTypeToString(v)};`
).join(" ")
}}`;
} else {
return "{}";
}
}
return schema.type!;
}
export function determineType(v: any): SimpleJSONType {
const t = typeof v;
if (t === "undefined" || v === null) {
return { type: "null" };
} else if (t === "object") {
if (Array.isArray(v)) {
if (v.length === 0) {
return {
type: "array",
};
} else {
return {
type: "array",
items: determineType(v[0]),
};
}
} else {
return {
type: "object",
properties: Object.fromEntries(
Object.entries(v).map(([k, v]) => [k, determineType(v)]),
),
};
}
} else if (t === "number") {
return { type: "number" };
} else if (t === "boolean") {
return { type: "boolean" };
} else if (t === "string") {
return { type: "string" };
} else {
return { type: "any" };
}
}