parent
fefda09c2b
commit
11967b82a6
|
@ -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" });
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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]].
|
||||
|
|
|
@ -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.
|
Loading…
Reference in New Issue