From 11967b82a66da4893b6bf4093db593ef56e3c307 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Wed, 17 Jul 2024 17:03:25 +0200 Subject: [PATCH] Meta links (#954) Add carret page link support (for meta pages) --- plug-api/lib/page_ref.test.ts | 3 ++ plug-api/lib/page_ref.ts | 6 +++ plugs/editor/complete.ts | 72 +++++++++++++++++---------- plugs/federation/config.ts | 36 -------------- plugs/federation/federation.plug.yaml | 6 --- plugs/federation/federation.ts | 38 ++------------ web/cm_plugins/wiki_link.ts | 8 ++- web/components/page_navigator.tsx | 12 +++-- web/editor_ui.tsx | 6 +++ website/CHANGELOG.md | 2 + website/Links.md | 9 ++-- website/Meta Pages.md | 3 +- website/Page Name Rules.md | 4 +- 13 files changed, 90 insertions(+), 115 deletions(-) delete mode 100644 plugs/federation/config.ts diff --git a/plug-api/lib/page_ref.test.ts b/plug-api/lib/page_ref.test.ts index 2c4483f6..d58393f9 100644 --- a/plug-api/lib/page_ref.test.ts +++ b/plug-api/lib/page_ref.test.ts @@ -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" }); diff --git a/plug-api/lib/page_ref.ts b/plug-api/lib/page_ref.ts index 75f4ffcb..bbec93c7 100644 --- a/plug-api/lib/page_ref.ts +++ b/plug-api/lib/page_ref.ts @@ -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]); diff --git a/plugs/editor/complete.ts b/plugs/editor/complete.ts index d4ecc572..60f51939 100644 --- a/plugs/editor/complete.ts +++ b/plugs/editor/complete.ts @@ -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("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("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("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 diff --git a/plugs/federation/config.ts b/plugs/federation/config.ts deleted file mode 100644 index 02bd3c5a..00000000 --- a/plugs/federation/config.ts +++ /dev/null @@ -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 { - // 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; -} diff --git a/plugs/federation/federation.plug.yaml b/plugs/federation/federation.plug.yaml index 58583631..66bfdcf8 100644 --- a/plugs/federation/federation.plug.yaml +++ b/plugs/federation/federation.plug.yaml @@ -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: diff --git a/plugs/federation/federation.ts b/plugs/federation/federation.ts index fe0bccd6..7c157e07 100644 --- a/plugs/federation/federation.ts +++ b/plugs/federation/federation.ts @@ -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 { - 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 { - 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 { 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"); } diff --git a/web/cm_plugins/wiki_link.ts b/web/cm_plugins/wiki_link.ts index 985783dd..15506172 100644 --- a/web/cm_plugins/wiki_link.ts +++ b/web/cm_plugins/wiki_link.ts @@ -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( diff --git a/web/components/page_navigator.tsx b/web/components/page_navigator.tsx index 382bdfd2..1cdde69c 100644 --- a/web/components/page_navigator.tsx +++ b/web/components/page_navigator.tsx @@ -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; 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); diff --git a/web/editor_ui.tsx b/web/editor_ui.tsx index 5df30ef1..6ba18bc6 100644 --- a/web/editor_ui.tsx +++ b/web/editor_ui.tsx @@ -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(() => { diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 80e12588..4a344dd8 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -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) --- diff --git a/website/Links.md b/website/Links.md index bf0353d0..0d757ab9 100644 --- a/website/Links.md +++ b/website/Links.md @@ -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. diff --git a/website/Meta Pages.md b/website/Meta Pages.md index efb1f93d..09bc15f3 100644 --- a/website/Meta Pages.md +++ b/website/Meta Pages.md @@ -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]]. -That’s 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]]. diff --git a/website/Page Name Rules.md b/website/Page Name Rules.md index 57d4a886..acc93d1a 100644 --- a/website/Page Name Rules.md +++ b/website/Page Name Rules.md @@ -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. \ No newline at end of file