213 lines
7.0 KiB
TypeScript
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;
|
|
}
|