Files
plainleaf/plugs/editor/complete.ts
T

187 lines
6.2 KiB
TypeScript

import { folderName } from "@silverbulletmd/silverbullet/lib/resolve";
import { queryLuaObjects } from "../index/api.ts";
import { language, lua } from "@silverbulletmd/silverbullet/syscalls";
import type { DocumentMeta, PageMeta } from "../../type/index.ts";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";
// Page completion
export async function pageComplete(completeEvent: CompleteEvent) {
const isDocumentQuery = {
objectVariable: "_",
where: await lua.parseExpression(`not string.startsWith(_.name, "_")`),
};
const isMetaPageQuery = {
objectVariable: "_",
where: await lua.parseExpression(`table.find(_.tags, function(tag)
return string.startsWith(tag, "meta")
end)`),
};
const isntMetaPageQuery = {
objectVariable: "_",
where: await lua.parseExpression(`not table.find(_.tags, function(tag)
return string.startsWith(tag, "meta")
end)`),
};
// Try to match [[wikilink]]
let isWikilink = true;
// This negative lookbehind is to prevent matching query[[. This requires negative lookbehind, which generally supported now (it seems), in versions of iOS Safari 13.1 and later
// https://caniuse.com/js-regexp-lookbehind
let match = /(?<!query)\[\[([^\]@$#:\{}]*)$/.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 | DocumentMeta)[] = [];
if (prefix.startsWith("^")) {
// A carrot prefix means we're looking for a meta page
allPages = await queryLuaObjects<PageMeta>("page", isMetaPageQuery!, {}, 5);
// Let's prefix the names with a caret to make them match
allPages = allPages.map((page) => ({
...page,
name: "^" + page.name,
}));
} else {
// This is the most common case, we're combining three types of completions here:
allPages = (await Promise.all([
// All non-meta pages
queryLuaObjects<PageMeta>("page", isntMetaPageQuery!, {}, 5),
// All documents
queryLuaObjects<DocumentMeta>("document", isDocumentQuery!, {}, 5),
// And all links to non-existing pages (to augment the existing ones)
queryLuaObjects<string>(
"aspiring-page",
{
select: { type: "Variable", name: "name", ctx: {} as any },
distinct: true,
},
{},
5,
).then((aspiringPages) =>
// Rewrite them to PageMeta shaped objects
aspiringPages.map((aspiringPage: string): PageMeta => ({
ref: aspiringPage,
tag: "page",
tags: ["non-existing"], // Picked up later in completion
name: aspiringPage,
created: "",
lastModified: "",
perm: "rw",
}))
),
])).flat();
}
// Don't complete hidden pages
allPages = allPages.filter((page) => !(page.pageDecoration?.hide === true));
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(),
};
}
export async function languageComplete(completeEvent: CompleteEvent) {
const languagePrefix = /^(?:```+|~~~+)(\w*)$/.exec(
completeEvent.linePrefix,
);
if (!languagePrefix) {
return null;
}
const allLanguages = await language.listLanguages();
return {
from: completeEvent.pos - languagePrefix[1].length,
options: allLanguages.map(
(lang) => ({
label: lang,
type: "language",
}),
),
};
}