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, pos: 1,
}); });
// Meta page
assertEquals(parsePageRef("^foo"), { page: "foo", meta: true });
// Edge cases // Edge cases
assertEquals(parsePageRef(""), { page: "" }); assertEquals(parsePageRef(""), { page: "" });
assertEquals(parsePageRef("user@domain.com"), { page: "user@domain.com" }); assertEquals(parsePageRef("user@domain.com"), { page: "user@domain.com" });

View File

@ -23,6 +23,7 @@ export type PageRef = {
pos?: number; pos?: number;
anchor?: string; anchor?: string;
header?: string; header?: string;
meta?: boolean;
}; };
const posRegex = /@(\d+)$/; const posRegex = /@(\d+)$/;
@ -36,6 +37,11 @@ export function parsePageRef(name: string): PageRef {
name = name.slice(2, -2); name = name.slice(2, -2);
} }
const pageRef: PageRef = { page: name }; 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); const posMatch = pageRef.page.match(posRegex);
if (posMatch) { if (posMatch) {
pageRef.pos = parseInt(posMatch[1]); pageRef.pos = parseInt(posMatch[1]);

View File

@ -3,11 +3,22 @@ import {
CompleteEvent, CompleteEvent,
FileMeta, FileMeta,
PageMeta, PageMeta,
QueryExpression,
} from "$sb/types.ts"; } from "$sb/types.ts";
import { listFilesCached } from "../federation/federation.ts"; import { listFilesCached } from "../federation/federation.ts";
import { queryObjects } from "../index/plug_api.ts"; import { queryObjects } from "../index/plug_api.ts";
import { folderName } from "$sb/lib/resolve.ts"; import { folderName } from "$sb/lib/resolve.ts";
import { decoration } from "$sb/syscalls.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 // Completion
export async function pageComplete(completeEvent: CompleteEvent) { export async function pageComplete(completeEvent: CompleteEvent) {
// Try to match [[wikilink]] // Try to match [[wikilink]]
@ -22,16 +33,29 @@ export async function pageComplete(completeEvent: CompleteEvent) {
return null; return null;
} }
const prefix = match[1];
let allPages: (PageMeta | AttachmentMeta)[] = []; 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")) && completeEvent.parentNodes.find((node) => node.startsWith("FencedCode")) &&
// either a render [[bla]] clause // either a render [[bla]] clause
/(render\s+|template\()\[\[/.test( /(render\s+|template\()\[\[/.test(
completeEvent.linePrefix, 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); allPages = await queryObjects<PageMeta>("template", {}, 5);
} else if ( } else if (
completeEvent.parentNodes.find((node) => completeEvent.parentNodes.find((node) =>
@ -44,10 +68,7 @@ export async function pageComplete(completeEvent: CompleteEvent) {
} else { } else {
// Otherwise, just complete non-meta pages // Otherwise, just complete non-meta pages
allPages = await queryObjects<PageMeta>("page", { allPages = await queryObjects<PageMeta>("page", {
filter: ["and", ["!=", ["attr", "tags"], ["string", "template"]], ["!=", [ filter: ["not", isMetaPageFilter],
"attr",
"tags",
], ["string", "meta"]]],
}, 5); }, 5);
// and attachments // and attachments
allPages = allPages.concat( allPages = allPages.concat(
@ -55,34 +76,29 @@ export async function pageComplete(completeEvent: CompleteEvent) {
); );
} }
const prefix = match[1];
if (prefix.startsWith("!")) { if (prefix.startsWith("!")) {
// Federation prefix, let's first see if we're matching anything from federation that is locally synced // Federation!
const prefixMatches = allPages.filter((pageMeta) => // Let's see if this URI is complete enough to try to fetch index.json
pageMeta.name.startsWith(prefix) if (prefix.includes("/")) {
); // Yep
if (prefixMatches.length === 0) { const domain = prefix.split("/")[0];
// Ok, nothing synced in via federation, let's see if this URI is complete enough to try to fetch index.json // Cached listing
if (prefix.includes("/")) { const federationPages = (await listFilesCached(domain)).filter((fm) =>
// Yep fm.name.endsWith(".md")
const domain = prefix.split("/")[0]; ).map(fileMetaToPageMeta);
// Cached listing if (federationPages.length > 0) {
const federationPages = (await listFilesCached(domain)).filter((fm) => allPages = allPages.concat(federationPages);
fm.name.endsWith(".md")
).map(fileMetaToPageMeta);
if (federationPages.length > 0) {
allPages = allPages.concat(federationPages);
}
} }
} }
} }
const folder = folderName(completeEvent.pageName); const folder = folderName(completeEvent.pageName);
// Decorate the pages
allPages = await decoration.applyDecorationsToPages(allPages as PageMeta[]); allPages = await decoration.applyDecorationsToPages(allPages as PageMeta[]);
return { return {
from: completeEvent.pos - match[1].length, from: completeEvent.pos - prefix.length,
options: allPages.map((pageMeta) => { options: allPages.map((pageMeta) => {
const completions: any[] = []; const completions: any[] = [];
let namePrefix = ""; let namePrefix = "";
@ -90,10 +106,11 @@ export async function pageComplete(completeEvent: CompleteEvent) {
namePrefix = pageMeta.pageDecoration?.prefix; namePrefix = pageMeta.pageDecoration?.prefix;
} }
if (isWikilink) { if (isWikilink) {
// A [[wikilink]]
if (pageMeta.displayName) { if (pageMeta.displayName) {
const decoratedName = namePrefix + pageMeta.displayName; const decoratedName = namePrefix + pageMeta.displayName;
completions.push({ completions.push({
label: `${pageMeta.displayName}`, label: pageMeta.displayName,
displayLabel: decoratedName, displayLabel: decoratedName,
boost: new Date(pageMeta.lastModified).getTime(), boost: new Date(pageMeta.lastModified).getTime(),
apply: pageMeta.tag === "template" apply: pageMeta.tag === "template"
@ -107,7 +124,7 @@ export async function pageComplete(completeEvent: CompleteEvent) {
for (const alias of pageMeta.aliases) { for (const alias of pageMeta.aliases) {
const decoratedName = namePrefix + alias; const decoratedName = namePrefix + alias;
completions.push({ completions.push({
label: `${alias}`, label: alias,
displayLabel: decoratedName, displayLabel: decoratedName,
boost: new Date(pageMeta.lastModified).getTime(), boost: new Date(pageMeta.lastModified).getTime(),
apply: pageMeta.tag === "template" apply: pageMeta.tag === "template"
@ -120,12 +137,13 @@ export async function pageComplete(completeEvent: CompleteEvent) {
} }
const decoratedName = namePrefix + pageMeta.name; const decoratedName = namePrefix + pageMeta.name;
completions.push({ completions.push({
label: `${pageMeta.name}`, label: pageMeta.name,
displayLabel: decoratedName, displayLabel: decoratedName,
boost: new Date(pageMeta.lastModified).getTime(), boost: new Date(pageMeta.lastModified).getTime(),
type: "page", type: "page",
}); });
} else { } else {
// A markdown link []()
let labelText = pageMeta.name; let labelText = pageMeta.name;
let boost = new Date(pageMeta.lastModified).getTime(); let boost = new Date(pageMeta.lastModified).getTime();
// Relative path if in the same folder or a subfolder // 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: requiredPermissions:
- fetch - fetch
functions: functions:
listFiles:
path: ./federation.ts:listFiles
env: server
pageNamespace:
pattern: "!.+"
operation: listFiles
readFile: readFile:
path: ./federation.ts:readFile path: ./federation.ts:readFile
pageNamespace: pageNamespace:

View File

@ -1,31 +1,20 @@
import "$sb/lib/native_fetch.ts"; import "$sb/lib/native_fetch.ts";
import { federatedPathToUrl } from "$sb/lib/resolve.ts"; import { federatedPathToUrl } from "$sb/lib/resolve.ts";
import { readFederationConfigs } from "./config.ts";
import { datastore } from "$sb/syscalls.ts"; import { datastore } from "$sb/syscalls.ts";
import type { FileMeta } from "../../plug-api/types.ts"; import type { FileMeta } from "../../plug-api/types.ts";
import { wildcardPathToRegex } from "./util.ts"; import { wildcardPathToRegex } from "./util.ts";
async function responseToFileMeta( function responseToFileMeta(
r: Response, r: Response,
name: string, name: string,
): Promise<FileMeta> { ): 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;
}
return { return {
name: name, name: name,
size: r.headers.get("Content-length") size: r.headers.get("Content-length")
? +r.headers.get("Content-length")! ? +r.headers.get("Content-length")!
: 0, : 0,
contentType: r.headers.get("Content-type")!, contentType: r.headers.get("Content-type")!,
perm, perm: "ro",
created: +(r.headers.get("X-Created") || "0"), created: +(r.headers.get("X-Created") || "0"),
lastModified: +(r.headers.get("X-Last-Modified") || "0"), lastModified: +(r.headers.get("X-Last-Modified") || "0"),
}; };
@ -40,23 +29,6 @@ type FileListingCacheEntry = {
lastUpdated: number; 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( export async function listFilesCached(
uri: string, uri: string,
supportWildcards = false, supportWildcards = false,
@ -145,7 +117,7 @@ export async function readFile(
if (r.status === 503) { if (r.status === 503) {
throw new Error("Offline"); throw new Error("Offline");
} }
const fileMeta = await responseToFileMeta(r, name); const fileMeta = responseToFileMeta(r, name);
if (r.status === 404) { if (r.status === 404) {
throw Error("Not found"); throw Error("Not found");
} }
@ -223,7 +195,7 @@ export async function getFileMeta(name: string): Promise<FileMeta> {
if (r.status === 503) { if (r.status === 503) {
throw new Error("Offline"); throw new Error("Offline");
} }
const fileMeta = await responseToFileMeta(r, name); const fileMeta = responseToFileMeta(r, name);
if (!r.ok) { if (!r.ok) {
throw new Error("Not found"); throw new Error("Not found");
} }

View File

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

View File

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

View File

@ -115,6 +115,12 @@ export class MainUI {
completer={client.miniEditorComplete.bind(client)} completer={client.miniEditorComplete.bind(client)}
vimMode={viewState.uiOptions.vimMode} vimMode={viewState.uiOptions.vimMode}
darkMode={viewState.uiOptions.darkMode} darkMode={viewState.uiOptions.darkMode}
onModeSwitch={(mode) => {
dispatch({ type: "stop-navigate" });
setTimeout(() => {
dispatch({ type: "start-navigate", mode });
});
}}
onNavigate={(page) => { onNavigate={(page) => {
dispatch({ type: "stop-navigate" }); dispatch({ type: "stop-navigate" });
setTimeout(() => { 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) * [[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` * New type of [[Shortcuts|shortcut]]: `slashCommand`
* Naming is hard. Renamed the `source` attribute of [[Libraries]] to `import`. egacy references to `source` will keep working. * 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:** 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:** 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 links using the `[[page name]]` syntax
# Internal link format # 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]]`: 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|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 [[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]] * `[[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. * `[[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? # 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]]. 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: There are a few rules regarding page names:
* Page names cannot be empty * Page names cannot be empty
* Page names cannot start with a `.` * Page names cannot start with a `.` nor `^`
* Page names cannot `@` or `$`, due to ambiguity with referencing specific positions or anchors inside a page * Page names cannot contain `@` or `$`, due to ambiguity with referencing specific positions or anchors inside a page
* Page names should not contain `!` * 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. * 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.