silverbullet/plugs/editor/complete.ts

213 lines
7.0 KiB
TypeScript

import type {
AttachmentMeta,
CompleteEvent,
FileMeta,
PageMeta,
QueryExpression,
} from "@silverbulletmd/silverbullet/types";
import { listFilesCached } from "../federation/federation.ts";
import { queryObjects } from "../index/plug_api.ts";
import { folderName } from "@silverbulletmd/silverbullet/lib/resolve";
import type { AspiringPageObject } from "../index/page_links.ts";
import { localDateString } from "$lib/dates.ts";
// A meta page is a page tagged with either #template or #meta
const isMetaPageFilter: QueryExpression = ["or", ["=", ["attr", "tags"], [
"string",
"template",
]], ["=", [
"attr",
"tags",
], ["string", "meta"]]];
// Completion
export async function pageComplete(completeEvent: CompleteEvent) {
// Try to match [[wikilink]]
let isWikilink = true;
let match = /\[\[([^\]@$#:\{}]*)$/.exec(completeEvent.linePrefix);
if (!match) {
// Try to match [markdown link]()
match = /\[.*\]\(([^\]\)@$#:\{}]*)$/.exec(completeEvent.linePrefix);
isWikilink = false;
}
if (!match) {
return null;
}
const prefix = match[1];
let allPages: (PageMeta | AttachmentMeta)[] = [];
if (prefix.startsWith("^")) {
// A carrot prefix means we're looking for a meta page
allPages = await queryObjects<PageMeta>("page", {
filter: isMetaPageFilter,
}, 5);
// Let's prefix the names with a caret to make them match
allPages = allPages.map((page) => ({
...page,
name: "^" + page.name,
}));
} // Let's try to be smart about the types of completions we're offering based on the context
else if (
completeEvent.parentNodes.find((node) => node.startsWith("FencedCode")) &&
// either a render [[bla]] clause
/(render\s+|template\()\[\[/.test(
completeEvent.linePrefix,
)
) {
// We're quite certainly in a template context, let's only complete templates
allPages = await queryObjects<PageMeta>("template", {}, 5);
} else if (
completeEvent.parentNodes.find((node) =>
node.startsWith("FencedCode:include") ||
node.startsWith("FencedCode:template")
)
) {
// Include both pages and meta in page completion in ```include and ```template blocks
allPages = await queryObjects<PageMeta>("page", {}, 5);
} else {
// This is the most common case, we're combining three types of completions here:
allPages = (await Promise.all([
// All non-meta pages
queryObjects<PageMeta>("page", {
filter: ["not", isMetaPageFilter],
}, 5),
// All attachments
queryObjects<AttachmentMeta>("attachment", {
// All attachment that do not start with a _ (internal attachments)
filter: ["!=~", ["attr", "name"], ["regexp", "^_", ""]],
}, 5),
// And all links to non-existing pages (to augment the existing ones)
queryObjects<AspiringPageObject>("aspiring-page", {
distinct: true,
select: [{ name: "name" }],
}, 5).then((aspiringPages) =>
// Rewrite them to PageMeta shaped objects
aspiringPages.map((aspiringPage): PageMeta => ({
ref: aspiringPage.name,
tag: "page",
tags: ["non-existing"], // Picked up later in completion
name: aspiringPage.name,
created: "",
lastModified: "",
perm: "rw",
}))
),
])).flat();
}
// Don't complete hidden pages
allPages = allPages.filter((page) => !(page.pageDecoration?.hide === true));
if (prefix.startsWith("!")) {
// Federation!
// Let's see if this URI is complete enough to try to fetch index.json
if (prefix.includes("/")) {
// Yep
const domain = prefix.split("/")[0];
// Cached listing
const federationPages = (await listFilesCached(domain)).filter((fm) =>
fm.name.endsWith(".md")
).map(fileMetaToPageMeta);
if (federationPages.length > 0) {
allPages = allPages.concat(federationPages);
}
}
}
const folder = folderName(completeEvent.pageName);
return {
from: completeEvent.pos - prefix.length,
options: allPages.map((pageMeta) => {
const completions: any[] = [];
const namePrefix = (pageMeta as PageMeta).pageDecoration?.prefix || "";
const cssClass = ((pageMeta as PageMeta).pageDecoration?.cssClasses || [])
.join(" ").replaceAll(/[^a-zA-Z0-9-_ ]/g, "");
if (isWikilink) {
// A [[wikilink]]
if (pageMeta.displayName) {
const decoratedName = namePrefix + pageMeta.displayName;
let boost = new Date(pageMeta.lastModified).getTime();
if (pageMeta._isAspiring) {
boost = -Infinity;
}
completions.push({
label: pageMeta.displayName,
displayLabel: decoratedName,
boost,
apply: pageMeta.tag === "template"
? pageMeta.name
: `${pageMeta.name}|${pageMeta.displayName}`,
detail: `displayName for: ${pageMeta.name}`,
type: "page",
cssClass,
});
}
if (Array.isArray(pageMeta.aliases)) {
for (const alias of pageMeta.aliases) {
const decoratedName = namePrefix + alias;
completions.push({
label: alias,
displayLabel: decoratedName,
boost: new Date(pageMeta.lastModified).getTime(),
apply: pageMeta.tag === "template"
? pageMeta.name
: `${pageMeta.name}|${alias}`,
detail: `alias to: ${pageMeta.name}`,
type: "page",
cssClass,
});
}
}
const decoratedName = namePrefix + pageMeta.name;
completions.push({
label: pageMeta.name,
displayLabel: decoratedName,
boost: new Date(pageMeta.lastModified).getTime(),
detail: pageMeta.tags?.includes("non-existing")
? "Linked but not created"
: undefined,
type: "page",
cssClass,
});
} else {
// A markdown link []()
let labelText = pageMeta.name;
let boost = new Date(pageMeta.lastModified).getTime();
// Relative path if in the same folder or a subfolder
if (folder.length > 0 && labelText.startsWith(folder)) {
labelText = labelText.slice(folder.length + 1);
boost = boost * 1.1;
} else {
// Absolute path otherwise
labelText = "/" + labelText;
}
completions.push({
label: labelText,
displayLabel: namePrefix + labelText,
boost: boost,
apply: labelText.includes(" ") ? "<" + labelText + ">" : labelText,
type: "page",
cssClass,
});
}
return completions;
}).flat(),
};
}
function fileMetaToPageMeta(fileMeta: FileMeta): PageMeta {
const name = fileMeta.name.substring(0, fileMeta.name.length - 3);
return {
...fileMeta,
ref: fileMeta.name,
tag: "page",
name,
created: localDateString(new Date(fileMeta.created)),
lastModified: localDateString(new Date(fileMeta.lastModified)),
} as PageMeta;
}