Meta links (#954)

Add carret page link support (for meta pages)
pull/958/head
Zef Hemel 2024-07-17 17:03:25 +02:00 committed by GitHub
parent fefda09c2b
commit 11967b82a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 90 additions and 115 deletions

View File

@ -21,6 +21,9 @@ Deno.test("Page utility functions", () => {
pos: 1,
});
// Meta page
assertEquals(parsePageRef("^foo"), { page: "foo", meta: true });
// Edge cases
assertEquals(parsePageRef(""), { page: "" });
assertEquals(parsePageRef("user@domain.com"), { page: "user@domain.com" });

View File

@ -23,6 +23,7 @@ export type PageRef = {
pos?: number;
anchor?: string;
header?: string;
meta?: boolean;
};
const posRegex = /@(\d+)$/;
@ -36,6 +37,11 @@ export function parsePageRef(name: string): PageRef {
name = name.slice(2, -2);
}
const pageRef: PageRef = { page: name };
if (pageRef.page.startsWith("^")) {
// A carrot prefix means we're looking for a meta page, but that doesn't matter for most use cases
pageRef.page = pageRef.page.slice(1);
pageRef.meta = true;
}
const posMatch = pageRef.page.match(posRegex);
if (posMatch) {
pageRef.pos = parseInt(posMatch[1]);

View File

@ -3,11 +3,22 @@ import {
CompleteEvent,
FileMeta,
PageMeta,
QueryExpression,
} from "$sb/types.ts";
import { listFilesCached } from "../federation/federation.ts";
import { queryObjects } from "../index/plug_api.ts";
import { folderName } from "$sb/lib/resolve.ts";
import { decoration } from "$sb/syscalls.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]]
@ -22,16 +33,29 @@ export async function pageComplete(completeEvent: CompleteEvent) {
return null;
}
const prefix = match[1];
let allPages: (PageMeta | AttachmentMeta)[] = [];
if (
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 in a template context, let's only complete templates
// 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) =>
@ -44,10 +68,7 @@ export async function pageComplete(completeEvent: CompleteEvent) {
} else {
// Otherwise, just complete non-meta pages
allPages = await queryObjects<PageMeta>("page", {
filter: ["and", ["!=", ["attr", "tags"], ["string", "template"]], ["!=", [
"attr",
"tags",
], ["string", "meta"]]],
filter: ["not", isMetaPageFilter],
}, 5);
// and attachments
allPages = allPages.concat(
@ -55,34 +76,29 @@ export async function pageComplete(completeEvent: CompleteEvent) {
);
}
const prefix = match[1];
if (prefix.startsWith("!")) {
// Federation prefix, let's first see if we're matching anything from federation that is locally synced
const prefixMatches = allPages.filter((pageMeta) =>
pageMeta.name.startsWith(prefix)
);
if (prefixMatches.length === 0) {
// Ok, nothing synced in via 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);
}
// 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);
// Decorate the pages
allPages = await decoration.applyDecorationsToPages(allPages as PageMeta[]);
return {
from: completeEvent.pos - match[1].length,
from: completeEvent.pos - prefix.length,
options: allPages.map((pageMeta) => {
const completions: any[] = [];
let namePrefix = "";
@ -90,10 +106,11 @@ export async function pageComplete(completeEvent: CompleteEvent) {
namePrefix = pageMeta.pageDecoration?.prefix;
}
if (isWikilink) {
// A [[wikilink]]
if (pageMeta.displayName) {
const decoratedName = namePrefix + pageMeta.displayName;
completions.push({
label: `${pageMeta.displayName}`,
label: pageMeta.displayName,
displayLabel: decoratedName,
boost: new Date(pageMeta.lastModified).getTime(),
apply: pageMeta.tag === "template"
@ -107,7 +124,7 @@ export async function pageComplete(completeEvent: CompleteEvent) {
for (const alias of pageMeta.aliases) {
const decoratedName = namePrefix + alias;
completions.push({
label: `${alias}`,
label: alias,
displayLabel: decoratedName,
boost: new Date(pageMeta.lastModified).getTime(),
apply: pageMeta.tag === "template"
@ -120,12 +137,13 @@ export async function pageComplete(completeEvent: CompleteEvent) {
}
const decoratedName = namePrefix + pageMeta.name;
completions.push({
label: `${pageMeta.name}`,
label: pageMeta.name,
displayLabel: decoratedName,
boost: new Date(pageMeta.lastModified).getTime(),
type: "page",
});
} 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

View File

@ -1,36 +0,0 @@
import { readSetting } from "$sb/lib/settings_page.ts";
type FederationConfig = {
uri: string;
perm?: "ro" | "rw";
// TODO: alias?: string;
};
let federationConfigs: FederationConfig[] = [];
let lastFederationUrlFetch = 0;
export async function readFederationConfigs(): Promise<FederationConfig[]> {
// Update at most every 5 seconds
if (Date.now() > lastFederationUrlFetch + 5000) {
federationConfigs = await readSetting("federate", []);
if (!Array.isArray(federationConfigs)) {
console.error("'federate' setting should be an array of objects");
return [];
}
// Normalize URIs
for (const config of federationConfigs) {
if (!config.uri) {
console.error(
"'federate' setting should be an array of objects with at least an 'uri' property",
config,
);
continue;
}
if (!config.uri.startsWith("!")) {
config.uri = `!${config.uri}`;
}
}
lastFederationUrlFetch = Date.now();
}
return federationConfigs;
}

View File

@ -2,12 +2,6 @@ name: federation
requiredPermissions:
- fetch
functions:
listFiles:
path: ./federation.ts:listFiles
env: server
pageNamespace:
pattern: "!.+"
operation: listFiles
readFile:
path: ./federation.ts:readFile
pageNamespace:

View File

@ -1,31 +1,20 @@
import "$sb/lib/native_fetch.ts";
import { federatedPathToUrl } from "$sb/lib/resolve.ts";
import { readFederationConfigs } from "./config.ts";
import { datastore } from "$sb/syscalls.ts";
import type { FileMeta } from "../../plug-api/types.ts";
import { wildcardPathToRegex } from "./util.ts";
async function responseToFileMeta(
function responseToFileMeta(
r: Response,
name: string,
): Promise<FileMeta> {
const federationConfigs = await readFederationConfigs();
// Default permission is "ro" unless explicitly set otherwise
let perm: "ro" | "rw" = "ro";
const federationConfig = federationConfigs.find((config) =>
name.startsWith(config.uri)
);
if (federationConfig?.perm) {
perm = federationConfig.perm;
}
): FileMeta {
return {
name: name,
size: r.headers.get("Content-length")
? +r.headers.get("Content-length")!
: 0,
contentType: r.headers.get("Content-type")!,
perm,
perm: "ro",
created: +(r.headers.get("X-Created") || "0"),
lastModified: +(r.headers.get("X-Last-Modified") || "0"),
};
@ -40,23 +29,6 @@ type FileListingCacheEntry = {
lastUpdated: number;
};
export async function listFiles(): Promise<FileMeta[]> {
let fileMetas: FileMeta[] = [];
// Fetch them all in parallel
try {
await Promise.all((await readFederationConfigs()).map(async (config) => {
const items = await listFilesCached(config.uri);
fileMetas = fileMetas.concat(items);
}));
// console.log("All of em: ", fileMetas);
return fileMetas;
} catch (e: any) {
console.error("Error listing federation files", e);
return [];
}
}
export async function listFilesCached(
uri: string,
supportWildcards = false,
@ -145,7 +117,7 @@ export async function readFile(
if (r.status === 503) {
throw new Error("Offline");
}
const fileMeta = await responseToFileMeta(r, name);
const fileMeta = responseToFileMeta(r, name);
if (r.status === 404) {
throw Error("Not found");
}
@ -223,7 +195,7 @@ export async function getFileMeta(name: string): Promise<FileMeta> {
if (r.status === 503) {
throw new Error("Offline");
}
const fileMeta = await responseToFileMeta(r, name);
const fileMeta = responseToFileMeta(r, name);
if (!r.ok) {
throw new Error("Not found");
}

View File

@ -69,9 +69,13 @@ export function cleanWikiLinkPlugin(client: Client) {
const pageMeta = client.ui.viewState.allPages.find((p) =>
p.name == url
);
let cleanLinkText = url.includes("/") ? url.split("/").pop()! : url;
if (cleanLinkText.startsWith("^")) {
// Hide the ^ prefix
cleanLinkText = cleanLinkText.slice(1);
}
const linkText = alias ||
(pageMeta?.pageDecoration?.prefix ?? "") +
(url.includes("/") ? url.split("/").pop()! : url);
((pageMeta?.pageDecoration?.prefix ?? "") + cleanLinkText);
// And replace it with a widget
widgets.push(

View File

@ -2,7 +2,6 @@ import { FilterList } from "./filter.tsx";
import { FilterOption } from "$lib/web.ts";
import { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { PageMeta } from "../../plug-api/types.ts";
import { isFederationPath } from "$sb/lib/resolve.ts";
import { tagRegex as mdTagRegex } from "$common/markdown_parser/parser.ts";
const tagRegex = new RegExp(mdTagRegex.source, "g");
@ -10,6 +9,7 @@ const tagRegex = new RegExp(mdTagRegex.source, "g");
export function PageNavigator({
allPages,
onNavigate,
onModeSwitch,
completer,
vimMode,
mode,
@ -21,6 +21,7 @@ export function PageNavigator({
darkMode: boolean;
mode: "page" | "meta";
onNavigate: (page: string | undefined) => void;
onModeSwitch: (mode: "page" | "meta") => void;
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
currentPage?: string;
}) {
@ -41,10 +42,6 @@ export function PageNavigator({
// ... then we put it all the way to the end
orderId = Infinity;
}
// And deprioritize federated pages too
if (isFederationPath(pageMeta.name)) {
orderId = Math.round(orderId / 10); // Just 10x lower the timestamp to push them down, should work
}
if (mode === "page") {
// Special behavior for regular pages
@ -101,6 +98,11 @@ export function PageNavigator({
phrase = phrase.replaceAll(tagRegex, "").trim();
return phrase;
}}
onKeyPress={(key, text) => {
if (mode === "page" && key === "^" && text === "^") {
onModeSwitch("meta");
}
}}
preFilter={(options, phrase) => {
if (mode === "page") {
const allTags = phrase.match(tagRegex);

View File

@ -115,6 +115,12 @@ export class MainUI {
completer={client.miniEditorComplete.bind(client)}
vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode}
onModeSwitch={(mode) => {
dispatch({ type: "stop-navigate" });
setTimeout(() => {
dispatch({ type: "start-navigate", mode });
});
}}
onNavigate={(page) => {
dispatch({ type: "stop-navigate" });
setTimeout(() => {

View File

@ -9,8 +9,10 @@ _These features are not yet properly released, you need to use [the edge builds]
* [[Page Decorations]] are here (initial implementation by [Deepak Narayan](https://github.com/silverbulletmd/silverbullet/pull/940), later refined by Zef)
* New type of [[Shortcuts|shortcut]]: `slashCommand`
* Naming is hard. Renamed the `source` attribute of [[Libraries]] to `import`. egacy references to `source` will keep working.
* Added support for [[Links#Caret page links]] making it slightly more convenient to link to [[Meta Pages]]
* **Fix:** very large spaces would let the server blow up when saving snapshots. This is now fixed.
* **Fix:** Conflict copies could no longer be edited, whoops (initial fix by [Semyon Novikov](https://github.com/silverbulletmd/silverbullet/pull/939), later refined by Zef)
* **Fix**: `silverbullet upgrade` should now work again (or at least on the next upgrade)
---

View File

@ -5,10 +5,13 @@ You can create three types of links in SilverBullet:
* Internal links using the `[[page name]]` syntax
# Internal link format
Internal links have different formats:
Internal links can have various formats:
* `[[CHANGELOG]]`: a simple link to another page that appears like this: [[CHANGELOG]].
* `[[CHANGELOG|The Change Log]]`: a link with an alias that appears like this: [[CHANGELOG|The Change Log]].
* `[[CHANGELOG$edge]]`: a link referencing a particular [[Markdown/Anchors|anchor]]: [[CHANGELOG$edge]].
* `[[CHANGELOG#Edge]]`: a link referencing a particular header: [[CHANGELOG#Edge]]
* `[[CHANGELOG$edge]]`: a link referencing a particular [[Markdown/Anchors|anchor]]: [[CHANGELOG$edge]]. When the page name is omitted, the anchor is expected to be local to the current page.
* `[[CHANGELOG#Edge]]`: a link referencing a particular header: [[CHANGELOG#Edge]]. When the page name is omitted, the header is expected to be local to the current page.
* `[[CHANGELOG@1234]]`: a link referencing a particular position in a page (characters from the start of the document). This notation is generally automatically generated through templates.
# Caret page links
[[Meta Pages]] are excluded from link auto complete in many contexts. However, you may still want to reference a meta page outside of a “meta context.” To make it easier to reference, you can use the caret syntax: `[[^SETTINGS]]`. Semantically this has the same meaning as `[[SETTINGS]]`. The only difference is that auto complete will _only_ complete meta pages.

View File

@ -7,4 +7,5 @@ The most obvious example is [[SETTINGS]], which is not really a page that you ca
# How are meta pages identified?
Meta pages at a technical level are [[Pages]] like any other, the only technical difference is that they are either tagged with `#template` or `#meta`. This is picked up by the [[Page Picker]] and [[Meta Picker]].
Thats it.
# How do you link to meta pages?
You can link to a meta page like any other page, that is using the `[[page name]]` syntax. However, in the context of regular content, meta pages will not appear in auto complete. To get auto completion for meta pages, you can use the `[[^page name]]` caret syntax. More information: [[Links#Caret page links]].

View File

@ -1,7 +1,7 @@
There are a few rules regarding page names:
* Page names cannot be empty
* Page names cannot start with a `.`
* Page names cannot `@` or `$`, due to ambiguity with referencing specific positions or anchors inside a page
* Page names cannot start with a `.` nor `^`
* Page names cannot contain `@` or `$`, due to ambiguity with referencing specific positions or anchors inside a page
* Page names should not contain `!`
* Page names cannot end with a _file extension_ containing just letters. That is, a page name like `test.md` is not allowed, whereas `test.123` would be.