Change how attribute indexing and completion works (#1061)

* Creation of separate aspring-page objects for pages linked to, but not created
* Show "No results" instead of broken markdown table for no query results
* Show schema validation errors
* Deno upgrade
* Adds config support to plugs (see examples)
* Moves all builtin schemas to plug config
* Adds core plug just for builtin schemas
* Changes how attributes are indexed and completed, now attempts to derive a JSON schema for ad hoc attributes
pull/1062/head
Zef Hemel 2024-08-24 12:35:09 +02:00 committed by GitHub
parent 6f91b65457
commit 80f9c14b96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1061 additions and 407 deletions

View File

@ -33,7 +33,7 @@ jobs:
- name: Setup Deno - name: Setup Deno
uses: denoland/setup-deno@v1 uses: denoland/setup-deno@v1
with: with:
deno-version: v1.45 deno-version: v1.46
- name: Run bundle build - name: Run bundle build
run: | run: |

View File

@ -16,7 +16,7 @@ jobs:
- name: Setup Deno - name: Setup Deno
uses: denoland/setup-deno@v1 uses: denoland/setup-deno@v1
with: with:
deno-version: v1.45 deno-version: v1.46
- name: Run build - name: Run build
run: deno task build run: deno task build
- name: Bundle - name: Bundle

View File

@ -16,7 +16,7 @@ jobs:
- name: Setup Deno - name: Setup Deno
uses: denoland/setup-deno@v1 uses: denoland/setup-deno@v1
with: with:
deno-version: v1.45 deno-version: v1.46
- name: Build bundles - name: Build bundles
run: | run: |

View File

@ -20,7 +20,7 @@ jobs:
- name: Setup Deno - name: Setup Deno
uses: denoland/setup-deno@v1 uses: denoland/setup-deno@v1
with: with:
deno-version: v1.45 deno-version: v1.46
- name: Run build - name: Run build
run: deno task build run: deno task build

2
.gitpod.Dockerfile vendored
View File

@ -1,6 +1,6 @@
FROM gitpod/workspace-full:latest FROM gitpod/workspace-full:latest
RUN curl -fsSL https://deno.land/x/install/install.sh | sh -s v1.45.3 RUN curl -fsSL https://deno.land/x/install/install.sh | sh -s v1.46.1
RUN /home/gitpod/.deno/bin/deno completions bash > /home/gitpod/.bashrc.d/90-deno && \ RUN /home/gitpod/.deno/bin/deno completions bash > /home/gitpod/.bashrc.d/90-deno && \
echo 'export DENO_INSTALL="/home/gitpod/.deno"' >> /home/gitpod/.bashrc.d/90-deno && \ echo 'export DENO_INSTALL="/home/gitpod/.deno"' >> /home/gitpod/.bashrc.d/90-deno && \
echo 'export PATH="$DENO_INSTALL/bin:$PATH"' >> /home/gitpod/.bashrc.d/90-deno echo 'export PATH="$DENO_INSTALL/bin:$PATH"' >> /home/gitpod/.bashrc.d/90-deno

View File

@ -1,4 +1,4 @@
FROM denoland/deno:debian-1.45.4 FROM denoland/deno:debian-1.46.1
# The volume that will keep the space data # The volume that will keep the space data

View File

@ -43,6 +43,11 @@ export async function compileManifest(
); );
manifest.assets = assetsBundle.toJSON(); manifest.assets = assetsBundle.toJSON();
// Normalize the edge case of a plug with no functions
if (!manifest.functions) {
manifest.functions = {};
}
const jsFile = ` const jsFile = `
import { setupMessageListener } from "${ import { setupMessageListener } from "${
options.runtimeUrl || workerRuntimeUrl options.runtimeUrl || workerRuntimeUrl

View File

@ -54,12 +54,20 @@ async function loadConfigsFromSystem(
console.warn("Index plug not loaded yet, falling back to default config"); console.warn("Index plug not loaded yet, falling back to default config");
return defaultConfig; return defaultConfig;
} }
// We'll start witht the default config
let fullConfig: any = { ...defaultConfig };
// Then merge in all plug-based configs
for (const plugDef of system.loadedPlugs.values()) {
if (plugDef.manifest?.config) {
const plugConfig = cleanupJSON(plugDef.manifest.config);
fullConfig = deepObjectMerge(fullConfig, plugConfig);
}
}
// Query all space-configs // Query all space-configs
const allConfigs: ConfigObject[] = await system.invokeFunction( const allConfigs: ConfigObject[] = await system.invokeFunction(
"index.queryObjects", "index.queryObjects",
["space-config", {}], ["space-config", {}],
); );
let fullConfig: any = { ...defaultConfig };
// Now let's intelligently merge them // Now let's intelligently merge them
for (const config of allConfigs) { for (const config of allConfigs) {
let configObject = { [config.key]: config.value }; let configObject = { [config.key]: config.value };

View File

@ -25,6 +25,7 @@ export function jsonschemaSyscalls(): SysCallMapping {
schema: any, schema: any,
object: any, object: any,
): undefined | string => { ): undefined | string => {
try {
const validate = ajv.compile(schema); const validate = ajv.compile(schema);
if (validate(object)) { if (validate(object)) {
return; return;
@ -34,6 +35,9 @@ export function jsonschemaSyscalls(): SysCallMapping {
text = text.replace(/^data[\.\s]/, ""); text = text.replace(/^data[\.\s]/, "");
return text; return text;
} }
} catch (e) {
return e.message;
}
}, },
"jsonschema.validateSchema": ( "jsonschema.validateSchema": (
_ctx, _ctx,

View File

@ -29,6 +29,11 @@ export interface Manifest<HookT> {
* see: common/manifest.ts#SilverBulletHooks * see: common/manifest.ts#SilverBulletHooks
*/ */
functions: Record<string, FunctionDef<HookT>>; functions: Record<string, FunctionDef<HookT>>;
/**
* A map of configuration options for the plug (to be merged with the system configuration).
*/
config?: any;
} }
/** Associates hooks with a function. This is the generic base structure, that identifies the function. Hooks are defined by the type parameter. */ /** Associates hooks with a function. This is the generic base structure, that identifies the function. Hooks are defined by the type parameter. */

View File

@ -108,8 +108,8 @@ export async function extractFrontmatter(
) { ) {
return null; return null;
} }
} catch (e: any) { } catch {
console.warn("Could not parse frontmatter", e.message); // console.warn("Could not parse frontmatter", e.message);
} }
} }

View File

@ -158,3 +158,7 @@ export function writeFile(
export function deleteFile(name: string): Promise<void> { export function deleteFile(name: string): Promise<void> {
return syscall("space.deleteFile", name); return syscall("space.deleteFile", name);
} }
export function fileExists(name: string): Promise<boolean> {
return syscall("space.fileExists", name);
}

View File

@ -1,5 +1,6 @@
// TODO: Figure out how to keep this up-to-date automatically // TODO: Figure out how to keep this up-to-date automatically
export const builtinPlugNames = [ export const builtinPlugNames = [
"core",
"editor", "editor",
"index", "index",
"sync", "sync",

109
plugs/core/core.plug.yaml Normal file
View File

@ -0,0 +1,109 @@
name: core
config:
# Built-in schemas
schema.tag:
syscall:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
readOnly: true
name:
type: string
argCount:
type: number
requiredPermissions:
type: array
items:
type: string
command:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
readOnly: true
name:
type: string
priority:
type: number
key:
type: string
nullable: true
mac:
type: string
nullable: true
hide:
type: boolean
nullable: true
requireMode:
type: string
enum: ["ro", "rw"]
nullable: true
space-config:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
readOnly: true
key:
type: string
value:
type:
- number
- string
- boolean
- object
- array
- null
space-style:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
readOnly: true
style:
type: string
origin:
type: string
space-script:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
readOnly: true
script:
type: string
# Built-in configuration schemas
schema.config.properties:
indexPage:
type: string
format: page-ref
maximumAttachmentSize:
type: number
nullable: true
objectDecorators:
type: array
items:
type: object
required:
- where
- attributes
nullable: true
spaceIgnore:
type: string
nullable: true

View File

@ -8,7 +8,7 @@ import type {
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 "@silverbulletmd/silverbullet/lib/resolve"; import { folderName } from "@silverbulletmd/silverbullet/lib/resolve";
import type { LinkObject } from "../index/page_links.ts"; import type { AspiringPageObject } from "../index/page_links.ts";
import { localDateString } from "$lib/dates.ts"; import { localDateString } from "$lib/dates.ts";
// A meta page is a page tagged with either #template or #meta // A meta page is a page tagged with either #template or #meta
@ -79,19 +79,16 @@ export async function pageComplete(completeEvent: CompleteEvent) {
filter: ["!=~", ["attr", "name"], ["regexp", "^_", ""]], filter: ["!=~", ["attr", "name"], ["regexp", "^_", ""]],
}, 5), }, 5),
// And all links to non-existing pages (to augment the existing ones) // And all links to non-existing pages (to augment the existing ones)
queryObjects<LinkObject>("link", { queryObjects<AspiringPageObject>("aspiring-page", {
filter: ["and", ["attr", "toPage"], ["not", ["call", "pageExists", [[ distinct: true,
"attr", select: [{ name: "name" }],
"toPage", }, 5).then((aspiringPages) =>
]]]]],
select: [{ name: "toPage" }],
}, 5).then((brokenLinks) =>
// Rewrite them to PageMeta shaped objects // Rewrite them to PageMeta shaped objects
brokenLinks.map((link): PageMeta => ({ aspiringPages.map((aspiringPage): PageMeta => ({
ref: link.toPage!, ref: aspiringPage.name,
tag: "page", tag: "page",
tags: ["non-existing"], // Picked up later in completion tags: ["non-existing"], // Picked up later in completion
name: link.toPage!, name: aspiringPage.name,
created: "", created: "",
lastModified: "", lastModified: "",
perm: "rw", perm: "rw",

View File

@ -1,6 +1,68 @@
name: editor name: editor
requiredPermissions: requiredPermissions:
- fetch - fetch
config:
schema.config.properties:
shortcuts:
type: array
items:
type: object
properties:
command:
type: string
key:
type: string
nullable: true
mac:
type: string
nullable: true
slashCommand:
type: string
nullable: true
priority:
type: number
nullable: true
required:
- command
nullable: true
useSmartQuotes:
type: boolean
nullable: true
pwaOpenLastPage:
type: boolean
nullable: true
hideEditButton:
type: boolean
nullable: true
hideSyncButton:
type: boolean
nullable: true
actionButtons:
type: array
items:
type: object
properties:
icon:
type: string
description:
type: string
nullable: true
command:
type: string
args:
type: array
items:
type: object
nullable: true
mobile:
type: boolean
nullable: true
required:
- icon
- command
defaultLinkStyle:
type: string
nullable: true
functions: functions:
setEditorMode: setEditorMode:
path: "./editor.ts:setEditorMode" path: "./editor.ts:setEditorMode"

View File

@ -1,4 +1,16 @@
name: emoji name: emoji
config:
schema.config.properties:
emoji:
type: object
properties:
aliases:
type: object
additionalProperties:
type: string
required:
- aliases
nullable: true
functions: functions:
emojiCompleter: emojiCompleter:
path: "./emoji.ts:emojiCompleter" path: "./emoji.ts:emojiCompleter"

View File

@ -1,6 +1,25 @@
name: federation name: federation
requiredPermissions: requiredPermissions:
- fetch - fetch
config:
schema.config.properties:
libraries:
type: array
items:
type: object
properties:
import:
type: string
format: page-ref
exclude:
type: array
items:
type: string
format: page-ref
nullable: true
required:
- import
nullable: true
functions: functions:
readFile: readFile:
path: ./federation.ts:readFile path: ./federation.ts:readFile

View File

@ -1,4 +1,4 @@
import { datastore } from "@silverbulletmd/silverbullet/syscalls"; import { datastore, system } from "@silverbulletmd/silverbullet/syscalls";
import type { import type {
KV, KV,
KvKey, KvKey,
@ -7,8 +7,7 @@ import type {
ObjectValue, ObjectValue,
} from "../../plug-api/types.ts"; } from "../../plug-api/types.ts";
import type { QueryProviderEvent } from "../../plug-api/types.ts"; import type { QueryProviderEvent } from "../../plug-api/types.ts";
import { builtins } from "./builtins.ts"; import { determineType, type SimpleJSONType } from "./attributes.ts";
import { determineType } from "./attributes.ts";
import { ttlCache } from "$lib/memory_cache.ts"; import { ttlCache } from "$lib/memory_cache.ts";
const indexKey = "idx"; const indexKey = "idx";
@ -79,12 +78,13 @@ export async function clearIndex(): Promise<void> {
/** /**
* Indexes entities in the data store * Indexes entities in the data store
*/ */
export function indexObjects<T>( export async function indexObjects<T>(
page: string, page: string,
objects: ObjectValue<T>[], objects: ObjectValue<T>[],
): Promise<void> { ): Promise<void> {
const kvs: KV<T>[] = []; const kvs: KV<T>[] = [];
const allAttributes = new Map<string, string>(); // tag:name -> attributeType const schema = await system.getSpaceConfig("schema");
const allAttributes = new Map<string, SimpleJSONType>();
for (const obj of objects) { for (const obj of objects) {
if (!obj.tag) { if (!obj.tag) {
console.error("Object has no tag", obj, "this shouldn't happen"); console.error("Object has no tag", obj, "this shouldn't happen");
@ -92,6 +92,8 @@ export function indexObjects<T>(
} }
// Index as all the tag + any additional tags specified // Index as all the tag + any additional tags specified
const allTags = [obj.tag, ...obj.tags || []]; const allTags = [obj.tag, ...obj.tags || []];
const tagSchemaProperties =
schema.tag[obj.tag] && schema.tag[obj.tag].properties || {};
for (const tag of allTags) { for (const tag of allTags) {
// The object itself // The object itself
kvs.push({ kvs.push({
@ -99,41 +101,32 @@ export function indexObjects<T>(
value: obj, value: obj,
}); });
// Index attributes // Index attributes
const builtinAttributes = builtins[tag]; const schemaAttributes = schema.tag[tag] && schema.tag[tag].properties;
if (!builtinAttributes) { if (!schemaAttributes) {
// This is not a builtin tag, so we index all attributes (almost, see below) // There is no schema definition for this tag, so we index all attributes
attributeLabel: for (
const [attrName, attrValue] of Object.entries(
obj as Record<string, any>,
)
) {
if (attrName.startsWith("$")) {
continue;
}
// Check for all tags attached to this object if they're builtins
// If so: if `attrName` is defined in the builtin, use the attributeType from there (mostly to preserve readOnly aspects)
for (const otherTag of allTags) {
const builtinAttributes = builtins[otherTag];
if (builtinAttributes && builtinAttributes[attrName]) {
allAttributes.set(
`${tag}:${attrName}`,
builtinAttributes[attrName],
);
continue attributeLabel;
}
}
allAttributes.set(`${tag}:${attrName}`, determineType(attrValue));
}
} else if (tag !== "attribute") {
// For builtin tags, only index custom ones
for ( for (
const [attrName, attrValue] of Object.entries( const [attrName, attrValue] of Object.entries(
obj as Record<string, any>, obj as Record<string, any>,
) )
) { ) {
// console.log("Indexing", tag, attrName, attrValue); if (attrName.startsWith("$") || tagSchemaProperties[attrName]) {
// Skip builtins and internal attributes continue;
if (builtinAttributes[attrName] || attrName.startsWith("$")) { }
allAttributes.set(`${tag}:${attrName}`, determineType(attrValue));
}
} else {
// For tags with schemas, only index attributes that are not in the schema
for (
const [attrName, attrValue] of Object.entries(
obj as Record<string, any>,
)
) {
// Skip schema-defined and internal attributes
if (
schemaAttributes[attrName] || tagSchemaProperties[attrName] ||
attrName.startsWith("$")
) {
continue; continue;
} }
allAttributes.set(`${tag}:${attrName}`, determineType(attrValue)); allAttributes.set(`${tag}:${attrName}`, determineType(attrValue));
@ -144,17 +137,15 @@ export function indexObjects<T>(
if (allAttributes.size > 0) { if (allAttributes.size > 0) {
[...allAttributes].forEach(([key, value]) => { [...allAttributes].forEach(([key, value]) => {
const [tagName, name] = key.split(":"); const [tagName, name] = key.split(":");
const attributeType = value.startsWith("!") ? value.substring(1) : value;
kvs.push({ kvs.push({
key: ["attribute", cleanKey(key, page)], key: ["ah-attr", cleanKey(key, page)],
value: { value: {
ref: key, ref: key,
tag: "attribute", tag: "ah-attr",
tagName, tagName,
name, name,
attributeType,
readOnly: value.startsWith("!"),
page, page,
schema: value,
} as T, } as T,
}); });
}); });
@ -188,6 +179,16 @@ export function queryObjects<T>(
}, ttlSecs); }, ttlSecs);
} }
export function queryDeleteObjects<T>(
tag: string,
query: ObjectQuery,
): Promise<void> {
return datastore.queryDelete({
...query,
prefix: [indexKey, tag],
});
}
export async function query( export async function query(
query: KvQuery, query: KvQuery,
variables?: Record<string, any>, variables?: Record<string, any>,
@ -220,11 +221,15 @@ export async function objectSourceProvider({
} }
export async function discoverSources() { export async function discoverSources() {
const schema = await system.getSpaceConfig("schema");
// Query all tags we indexed
return (await datastore.query({ return (await datastore.query({
prefix: [indexKey, "tag"], prefix: [indexKey, "tag"],
select: [{ name: "name" }], select: [{ name: "name" }],
distinct: true, distinct: true,
})).map(( })).map((
{ value }, { value },
) => value.name); ) => value.name)
// And concatenate all the tags from the schema
.concat(Object.keys(schema.tag));
} }

View File

@ -0,0 +1,64 @@
import { assertEquals } from "@std/assert/equals";
import { determineType, jsonTypeToString } from "./attributes.ts";
Deno.test("JSON Determine type", () => {
// Determine type tests
assertEquals(determineType(null), { type: "null" });
assertEquals(determineType(undefined), { type: "null" });
assertEquals(determineType("hello"), { type: "string" });
assertEquals(determineType(10), { type: "number" });
assertEquals(determineType(true), { type: "boolean" });
assertEquals(determineType({}), { type: "object", properties: {} });
assertEquals(determineType([]), { type: "array" });
assertEquals(determineType([1]), {
type: "array",
items: { type: "number" },
});
assertEquals(
determineType({ name: "Pete", age: 10, siblings: ["Sarah"] }),
{
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
siblings: { type: "array", items: { type: "string" } },
},
},
);
});
Deno.test("Serialize JSON Type to string", () => {
assertEquals(jsonTypeToString({ type: "string" }), "string");
assertEquals(jsonTypeToString({ type: "null" }), "null");
assertEquals(jsonTypeToString({ type: "number" }), "number");
assertEquals(jsonTypeToString({ type: "boolean" }), "boolean");
assertEquals(jsonTypeToString({ type: "array" }), "any[]");
assertEquals(
jsonTypeToString({ type: "array", items: { type: "number" } }),
"number[]",
);
assertEquals(
jsonTypeToString({ type: "object", properties: {} }),
"{}",
);
assertEquals(
jsonTypeToString({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
}),
"{name: string; age: number;}",
);
assertEquals(
jsonTypeToString({
anyOf: [
{ type: "string" },
{ type: "number" },
{ type: "boolean" },
],
}),
"string | number | boolean",
);
});

View File

@ -3,16 +3,23 @@ import type {
ObjectValue, ObjectValue,
QueryExpression, QueryExpression,
} from "../../plug-api/types.ts"; } from "../../plug-api/types.ts";
import { events } from "@silverbulletmd/silverbullet/syscalls"; import { events, system } from "@silverbulletmd/silverbullet/syscalls";
import { queryObjects } from "./api.ts"; import { queryObjects } from "./api.ts";
import { determineTags } from "$lib/cheap_yaml.ts"; import { determineTags } from "$lib/cheap_yaml.ts";
import type { TagObject } from "./tags.ts";
export type AttributeObject = ObjectValue<{ export type SimpleJSONType = {
type?: "string" | "number" | "boolean" | "any" | "array" | "object" | "null";
items?: SimpleJSONType;
properties?: Record<string, SimpleJSONType>;
anyOf?: SimpleJSONType[];
};
export type AdhocAttributeObject = ObjectValue<{
name: string; name: string;
attributeType: string; schema: SimpleJSONType;
tagName: string; tagName: string;
page: string; page: string;
readOnly: boolean;
}>; }>;
export type AttributeCompleteEvent = { export type AttributeCompleteEvent = {
@ -23,20 +30,11 @@ export type AttributeCompleteEvent = {
export type AttributeCompletion = { export type AttributeCompletion = {
name: string; name: string;
source: string; source: string;
// String version of JSON schema
attributeType: string; attributeType: string;
readOnly: boolean; readOnly?: boolean;
}; };
export function determineType(v: any): string {
const t = typeof v;
if (t === "object") {
if (Array.isArray(v)) {
return "array";
}
}
return t;
}
/** /**
* Triggered by the `attribute:complete:*` event (that is: gimme all attribute completions) * Triggered by the `attribute:complete:*` event (that is: gimme all attribute completions)
* @param attributeCompleteEvent * @param attributeCompleteEvent
@ -49,21 +47,59 @@ export async function objectAttributeCompleter(
attributeCompleteEvent.source === "" attributeCompleteEvent.source === ""
? undefined ? undefined
: ["=", ["attr", "tagName"], ["string", attributeCompleteEvent.source]]; : ["=", ["attr", "tagName"], ["string", attributeCompleteEvent.source]];
const allAttributes = await queryObjects<AttributeObject>("attribute", { const schema = await system.getSpaceConfig("schema");
const allAttributes = (await queryObjects<AdhocAttributeObject>("ah-attr", {
filter: attributeFilter, filter: attributeFilter,
distinct: true, distinct: true,
select: [{ name: "name" }, { name: "attributeType" }, { name: "tag" }, { select: [{ name: "name" }, { name: "schema" }, { name: "tag" }, {
name: "readOnly", name: "tagName",
}, { name: "tagName" }], }],
}, 5); }, 5)).map((value) => {
return allAttributes.map((value) => {
return { return {
name: value.name, name: value.name,
source: value.tagName, source: value.tagName,
attributeType: value.attributeType, attributeType: jsonTypeToString(value.schema),
readOnly: value.readOnly,
} as AttributeCompletion; } as AttributeCompletion;
}); });
// Add attributes from the direct schema
addAttributeCompletionsForTag(
schema,
attributeCompleteEvent.source,
allAttributes,
);
// Look up the tag so we can check the parent as well
const sourceTags = await queryObjects<TagObject>("tag", {
filter: ["=", ["attr", "name"], ["string", attributeCompleteEvent.source]],
});
if (sourceTags.length > 0) {
addAttributeCompletionsForTag(schema, sourceTags[0].parent, allAttributes);
}
return allAttributes;
}
function addAttributeCompletionsForTag(
schema: any,
tag: string,
allAttributes: AttributeCompletion[],
) {
if (schema.tag[tag]) {
for (
const [name, value] of Object.entries(
schema.tag[tag].properties as Record<
string,
any
>,
)
) {
allAttributes.push({
name,
source: tag,
attributeType: jsonTypeToString(value),
readOnly: value.readOnly,
});
}
}
} }
/** /**
@ -151,3 +187,66 @@ export function attributeCompletionsToCMCompletion(
}), }),
); );
} }
/**
* Attempt some reasonable stringification of a JSON schema
* @param schema
* @returns
*/
export function jsonTypeToString(schema: SimpleJSONType): string {
if (schema.anyOf) {
return schema.anyOf.map(jsonTypeToString).join(" | ");
} else if (schema.type === "array") {
if (schema.items) {
return `${jsonTypeToString(schema.items)}[]`;
} else {
return "any[]";
}
} else if (schema.type === "object") {
if (schema.properties) {
return `{${
Object.entries(schema.properties).map(([k, v]) =>
`${k}: ${jsonTypeToString(v)};`
).join(" ")
}}`;
} else {
return "{}";
}
}
return schema.type!;
}
export function determineType(v: any): SimpleJSONType {
const t = typeof v;
if (t === "undefined" || v === null) {
return { type: "null" };
} else if (t === "object") {
if (Array.isArray(v)) {
if (v.length === 0) {
return {
type: "array",
};
} else {
return {
type: "array",
items: determineType(v[0]),
};
}
} else {
return {
type: "object",
properties: Object.fromEntries(
Object.entries(v).map(([k, v]) => [k, determineType(v)]),
),
};
}
} else if (t === "number") {
return { type: "number" };
} else if (t === "boolean") {
return { type: "boolean" };
} else if (t === "string") {
return { type: "string" };
} else {
return { type: "any" };
}
}

View File

@ -1,172 +1,8 @@
import { system } from "@silverbulletmd/silverbullet/syscalls"; import { system } from "@silverbulletmd/silverbullet/syscalls";
import { indexObjects } from "./api.ts"; import type { QueryProviderEvent } from "@silverbulletmd/silverbullet/types";
import type {
ObjectValue,
QueryProviderEvent,
} from "@silverbulletmd/silverbullet/types";
import { applyQuery } from "@silverbulletmd/silverbullet/lib/query"; import { applyQuery } from "@silverbulletmd/silverbullet/lib/query";
import { builtinFunctions } from "$lib/builtin_query_functions.ts"; import { builtinFunctions } from "$lib/builtin_query_functions.ts";
export const builtinPseudoPage = ":builtin:";
// Types marked with a ! are read-only, they cannot be set by the user
export const builtins: Record<string, Record<string, string>> = {
page: {
ref: "!string",
name: "!string",
displayName: "string",
aliases: "string[]",
created: "!date",
lastModified: "!date",
perm: "!rw|ro",
contentType: "!string",
size: "!number",
tags: "string[]",
},
attachment: {
ref: "!string",
name: "!string",
created: "!date",
lastModified: "!date",
perm: "!rw|ro",
contentType: "!string",
size: "!number",
},
task: {
ref: "!string",
name: "!string",
done: "!boolean",
page: "!string",
state: "!string",
deadline: "string",
pos: "!number",
tags: "string[]",
},
item: {
ref: "!string",
name: "!string",
page: "!string",
tags: "string[]",
},
taskstate: {
ref: "!string",
tags: "!string[]",
state: "!string",
count: "!number",
page: "!string",
},
tag: {
ref: "!string",
name: "!string",
page: "!string",
context: "!string",
},
attribute: {
ref: "!string",
name: "!string",
attributeType: "!string",
tagName: "!string",
page: "!string",
readOnly: "!boolean",
},
anchor: {
ref: "!string",
name: "!string",
page: "!string",
pos: "!number",
},
link: {
ref: "!string",
name: "!string",
page: "!string",
pos: "!number",
alias: "!string",
asTemplate: "!boolean",
},
header: {
ref: "!string",
name: "!string",
page: "!string",
level: "!number",
pos: "!number",
},
paragraph: {
text: "!string",
page: "!string",
pos: "!number",
},
template: {
ref: "!string",
page: "!string",
pageName: "string",
pos: "!number",
hooks: "hooksSpec",
},
table: {
ref: "!string",
page: "!string",
pos: "!number",
},
// System builtins
syscall: {
name: "!string",
requiredPermissions: "!string[]",
argCount: "!number",
},
command: {
name: "!string",
priority: "!number",
key: "!string",
mac: "!string",
hide: "!boolean",
requireMode: "!rw|ro",
},
"space-config": {
key: "!string",
value: "!any",
},
"space-style": {
style: "!string",
origin: "!string",
},
"space-script": {
script: "!string",
},
};
export async function loadBuiltinsIntoIndex() {
if (await system.getMode() === "ro") {
console.log("Running in read-only mode, not loading builtins");
return;
}
console.log("Loading builtins attributes into index");
const allObjects: ObjectValue<any>[] = [];
for (const [tagName, attributes] of Object.entries(builtins)) {
allObjects.push({
ref: tagName,
tag: "tag",
name: tagName,
page: builtinPseudoPage,
parent: "builtin",
});
allObjects.push(
...Object.entries(attributes).map(([name, attributeType]) => ({
ref: `${tagName}:${name}`,
tag: "attribute",
tagName,
name,
attributeType: attributeType.startsWith("!")
? attributeType.substring(1)
: attributeType,
readOnly: attributeType.startsWith("!"),
page: builtinPseudoPage,
})),
);
}
await indexObjects(builtinPseudoPage, allObjects);
}
export async function syscallSourceProvider({ export async function syscallSourceProvider({
query, query,
variables, variables,

View File

@ -28,8 +28,6 @@ export async function reindexSpace(noClear = false) {
// Executed this way to not have to embed the search plug code here // Executed this way to not have to embed the search plug code here
await system.invokeFunction("index.clearIndex"); await system.invokeFunction("index.clearIndex");
} }
// Load builtins
await system.invokeFunction("index.loadBuiltinsIntoIndex");
// Pre-index SETTINGS page to get useful settings // Pre-index SETTINGS page to get useful settings
console.log("Indexing SETTINGS page"); console.log("Indexing SETTINGS page");
await indexPage("SETTINGS"); await indexPage("SETTINGS");

View File

@ -1,11 +1,5 @@
name: index name: index
functions: functions:
loadBuiltinsIntoIndex:
path: builtins.ts:loadBuiltinsIntoIndex
env: server
events:
- system:ready
# Public API # Public API
batchSet: batchSet:
path: api.ts:batchSet path: api.ts:batchSet
@ -21,7 +15,6 @@ functions:
getObjectByRef: getObjectByRef:
path: api.ts:getObjectByRef path: api.ts:getObjectByRef
env: server env: server
objectSourceProvider: objectSourceProvider:
path: api.ts:objectSourceProvider path: api.ts:objectSourceProvider
events: events:
@ -239,3 +232,397 @@ functions:
path: builtins.ts:commandSourceProvider path: builtins.ts:commandSourceProvider
events: events:
- query:command - query:command
config:
# Schema for the built-in tags indexed by this plug
schema.tag:
page:
type: object
additionalProperties: true
properties:
ref:
type: string
readOnly: true
tag:
type: string
readOnly: true
enum:
- page
tags:
anyOf:
- type: array
items:
type: string
- type: string
itags:
type: array
readOnly: true
items:
type: string
nullable: true
name:
type: string
readOnly: true
pageDecoration:
type: object
properties:
prefix:
type: string
nullable: true
cssClasses:
type: array
items:
type: string
nullable: true
hide:
type: boolean
nullable: true
renderWidgets:
type: boolean
nullable: true
nullable: true
displayName:
type: string
nullable: true
aliases:
type: array
items:
type: string
nullable: true
created:
type: string
readOnly: true
contentType:
type: string
readOnly: true
size:
type: number
readOnly: true
lastModified:
type: string
readOnly: true
perm:
type: string
readOnly: true
enum:
- ro
- rw
lastOpened:
type: number
readOnly: true
nullable: true
aspiring-page:
type: object
additionalProperties: true
properties:
ref:
type: string
readOnly: true
tag:
type: string
readOnly: true
enum:
- aspiring-page
name:
type: string
readOnly: true
page:
type: string
readOnly: true
pos:
type: number
readOnly: true
attachment:
type: object
additionalProperties: true
properties:
ref:
type: string
readOnly: true
tag:
type: string
readOnly: true
enum:
- attachment
tags:
type: array
readOnly: true
items:
type: string
nullable: true
itags:
type: array
readOnly: true
items:
type: string
nullable: true
name:
type: string
readOnly: true
created:
readOnly: true
type: string
contentType:
type: string
readOnly: true
size:
type: number
readOnly: true
lastModified:
type: string
readOnly: true
perm:
type: string
readOnly: true
enum:
- ro
- rw
item:
type: object
additionalProperties: true
properties:
ref:
type: string
readOnly: true
tag:
type: string
readOnly: true
enum:
- item
tags:
type: array
items:
type: string
nullable: true
itags:
type: array
readOnly: true
items:
type: string
nullable: true
name:
type: string
readOnly: true
page:
type: string
readOnly: true
parent:
type: string
readOnly: true
pos:
type: number
readOnly: true
text:
type: string
readOnly: true
tag:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
readOnly: true
enum:
- tag
tags:
type: array
readOnly: true
items:
type: string
nullable: true
itags:
type: array
readOnly: true
items:
type: string
nullable: true
name:
type: string
readOnly: true
page:
type: string
readOnly: true
parent:
type: string
readOnly: true
context:
type: string
readOnly: true
anchor:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
enum:
- anchor
tags:
type: array
items:
type: string
nullable: true
itags:
type: array
items:
type: string
nullable: true
name:
type: string
page:
type: string
pos:
type: number
link:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
enum:
- link
tags:
type: array
items:
type: string
nullable: true
itags:
type: array
items:
type: string
nullable: true
name:
type: string
page:
type: string
toFile:
type: string
nullable: true
toPage:
type: string
nullable: true
snippet:
type: string
pos:
type: number
alias:
type: string
asTemplate:
type: boolean
header:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
enum:
- header
tags:
type: array
items:
type: string
nullable: true
itags:
type: array
items:
type: string
nullable: true
name:
type: string
page:
type: string
pos:
type: number
level:
type: string
paragraph:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
enum:
- paragraph
tags:
type: array
items:
type: string
nullable: true
itags:
type: array
items:
type: string
nullable: true
text:
type: string
page:
type: string
pos:
type: number
template:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
enum:
- template
tags:
anyOf:
- type: array
items:
type: string
- type: string
itags:
type: array
items:
type: string
nullable: true
page:
type: string
pageName:
type: string
description:
type: string
pos:
type: number
hooks:
type: object
frontmatter:
anyOf:
- type: object
- type: string
table:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
enum:
- table
tags:
type: array
items:
type: string
nullable: true
itags:
type: array
items:
type: string
nullable: true
page:
type: string
pos:
type: number

View File

@ -11,7 +11,7 @@ import {
} from "@silverbulletmd/silverbullet/lib/tree"; } from "@silverbulletmd/silverbullet/lib/tree";
import type { LintEvent } from "../../plug-api/types.ts"; import type { LintEvent } from "../../plug-api/types.ts";
import { queryObjects } from "./api.ts"; import { queryObjects } from "./api.ts";
import type { AttributeObject } from "./attributes.ts"; import type { AdhocAttributeObject } from "./attributes.ts";
import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter"; import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter";
import { import {
cleanupJSON, cleanupJSON,
@ -26,8 +26,8 @@ export async function lintYAML({ tree }: LintEvent): Promise<LintDiagnostic[]> {
await traverseTreeAsync(tree, async (node) => { await traverseTreeAsync(tree, async (node) => {
if (node.type === "FrontMatterCode") { if (node.type === "FrontMatterCode") {
// Query all readOnly attributes for pages with this tag set // Query all readOnly attributes for pages with this tag set
const readOnlyAttributes = await queryObjects<AttributeObject>( const readOnlyAttributes = await queryObjects<AdhocAttributeObject>(
"attribute", "ah-attr",
{ {
filter: ["and", ["=", ["attr", "tagName"], [ filter: ["and", ["=", ["attr", "tagName"], [
"array", "array",

View File

@ -1,4 +1,4 @@
import type { IndexTreeEvent } from "../../plug-api/types.ts"; import type { IndexTreeEvent } from "@silverbulletmd/silverbullet/types";
import { import {
editor, editor,
markdown, markdown,
@ -9,13 +9,14 @@ import {
import type { LintDiagnostic, PageMeta } from "../../plug-api/types.ts"; import type { LintDiagnostic, PageMeta } from "../../plug-api/types.ts";
import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter"; import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter";
import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute"; import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute";
import { indexObjects } from "./api.ts"; import { indexObjects, queryDeleteObjects } from "./api.ts";
import { import {
findNodeOfType, findNodeOfType,
renderToText, renderToText,
traverseTreeAsync, traverseTreeAsync,
} from "../../plug-api/lib/tree.ts"; } from "@silverbulletmd/silverbullet/lib/tree";
import { updateITags } from "@silverbulletmd/silverbullet/lib/tags"; import { updateITags } from "@silverbulletmd/silverbullet/lib/tags";
import type { AspiringPageObject } from "./page_links.ts";
export async function indexPage({ name, tree }: IndexTreeEvent) { export async function indexPage({ name, tree }: IndexTreeEvent) {
if (name.startsWith("_")) { if (name.startsWith("_")) {
@ -62,6 +63,11 @@ export async function indexPage({ name, tree }: IndexTreeEvent) {
// console.log("Page object", combinedPageMeta); // console.log("Page object", combinedPageMeta);
await indexObjects<PageMeta>(name, [combinedPageMeta]); await indexObjects<PageMeta>(name, [combinedPageMeta]);
// Make sure this page is no (longer) in the aspiring pages list
await queryDeleteObjects<AspiringPageObject>("aspiring-page", {
filter: ["=", ["attr", "name"], ["string", name]],
});
} }
export async function lintFrontmatter(): Promise<LintDiagnostic[]> { export async function lintFrontmatter(): Promise<LintDiagnostic[]> {

View File

@ -23,6 +23,7 @@ import {
mdLinkRegex, mdLinkRegex,
wikiLinkRegex, wikiLinkRegex,
} from "$common/markdown_parser/constants.ts"; } from "$common/markdown_parser/constants.ts";
import { space } from "@silverbulletmd/silverbullet/syscalls";
export type LinkObject = ObjectValue< export type LinkObject = ObjectValue<
{ {
@ -50,6 +51,19 @@ export type LinkObject = ObjectValue<
} }
>; >;
/**
* Represents a page that does not yet exist, but is being linked to. A page "aspiring" to be created.
*/
export type AspiringPageObject = ObjectValue<{
// ref: page@pos
// The page the link appears on
page: string;
// And the position
pos: number;
// The page the link points to
name: string;
}>;
export async function indexLinks({ name, tree }: IndexTreeEvent) { export async function indexLinks({ name, tree }: IndexTreeEvent) {
const links: ObjectValue<LinkObject>[] = []; const links: ObjectValue<LinkObject>[] = [];
const frontmatter = await extractFrontmatter(tree); const frontmatter = await extractFrontmatter(tree);
@ -194,8 +208,35 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) {
} }
return false; return false;
}); });
// console.log("Found", links, "page link(s)"); // console.log("Found", links, "page link(s)");
if (links.length > 0) {
await indexObjects(name, links); await indexObjects(name, links);
}
// Now let's check which are aspiring pages
const aspiringPages: ObjectValue<AspiringPageObject>[] = [];
for (const link of links) {
if (link.toPage) {
// No federated links, nothing with template directives
if (link.toPage.startsWith("!") || link.toPage.includes("{{")) {
continue;
}
if (!await space.fileExists(`${link.toPage}.md`)) {
aspiringPages.push({
ref: `${name}@${link.pos}`,
tag: "aspiring-page",
page: name,
pos: link.pos,
name: link.toPage,
} as AspiringPageObject);
}
}
}
if (aspiringPages.length > 0) {
await indexObjects(name, aspiringPages);
}
} }
export async function getBackLinks( export async function getBackLinks(

View File

@ -44,7 +44,11 @@ export async function widget(
}, },
); );
if (Array.isArray(results)) { if (Array.isArray(results)) {
if (results.length === 0) {
resultMarkdown = "No results";
} else {
resultMarkdown = jsonToMDTable(results); resultMarkdown = jsonToMDTable(results);
}
} else { } else {
resultMarkdown = results; resultMarkdown = results;
} }

View File

@ -41,3 +41,68 @@ functions:
command: command:
name: "Task: Remove Completed" name: "Task: Remove Completed"
requireMode: rw requireMode: rw
config:
schema.tag:
task:
type: object
additionalProperties: true
properties:
ref:
type: string
readOnly: true
tag:
type: string
enum:
- task
tags:
type: array
items:
type: string
nullable: true
itags:
type: array
items:
type: string
nullable: true
name:
type: string
page:
type: string
parent:
type: string
pos:
type: number
text:
type: string
state:
type: string
done:
type: boolean
deadline:
type: string
nullable: true
taskstate:
type: object
properties:
ref:
type: string
readOnly: true
tag:
type: string
enum:
- taskstate
tags:
type: array
items:
type: string
nullable: true
itags:
type: array
items:
type: string
nullable: true
state:
type: string
count:
type: number

View File

@ -120,7 +120,7 @@ export class ServerSystem extends CommonSystem {
this.system.registerSyscalls( this.system.registerSyscalls(
[], [],
eventSyscalls(this.eventHook), eventSyscalls(this.eventHook),
spaceReadSyscalls(space), spaceReadSyscalls(space, this.allKnownFiles),
assetSyscalls(this.system), assetSyscalls(this.system),
yamlSyscalls(), yamlSyscalls(),
systemSyscalls( systemSyscalls(
@ -169,6 +169,7 @@ export class ServerSystem extends CommonSystem {
this.eventHook.addLocalListener( this.eventHook.addLocalListener(
"file:changed", "file:changed",
async (path, localChange) => { async (path, localChange) => {
this.allKnownFiles.add(path);
if (!localChange) { if (!localChange) {
console.log("Outside file change: reindexing", path); console.log("Outside file change: reindexing", path);
// Change made outside of editor, trigger reindex // Change made outside of editor, trigger reindex
@ -190,6 +191,7 @@ export class ServerSystem extends CommonSystem {
}, },
); );
// Keep allKnownFiles up to date
this.eventHook.addLocalListener( this.eventHook.addLocalListener(
"file:listed", "file:listed",
(allFiles: FileMeta[]) => { (allFiles: FileMeta[]) => {
@ -202,6 +204,13 @@ export class ServerSystem extends CommonSystem {
}); });
}, },
); );
this.eventHook.addLocalListener(
"file:deleted",
(path: string) => {
// Update list of known pages and attachments
this.allKnownFiles.delete(path);
},
);
// All setup, enable eventing // All setup, enable eventing
eventedSpacePrimitives.enabled = true; eventedSpacePrimitives.enabled = true;

View File

@ -9,7 +9,10 @@ import type { Space } from "../../common/space.ts";
/** /**
* Almost the same as web/syscalls/space.ts except leaving out client-specific stuff * Almost the same as web/syscalls/space.ts except leaving out client-specific stuff
*/ */
export function spaceReadSyscalls(space: Space): SysCallMapping { export function spaceReadSyscalls(
space: Space,
allKnownFiles: Set<string>,
): SysCallMapping {
return { return {
"space.listPages": (): Promise<PageMeta[]> => { "space.listPages": (): Promise<PageMeta[]> => {
return space.fetchPageList(); return space.fetchPageList();
@ -46,6 +49,9 @@ export function spaceReadSyscalls(space: Space): SysCallMapping {
"space.readFile": async (_ctx, name: string): Promise<Uint8Array> => { "space.readFile": async (_ctx, name: string): Promise<Uint8Array> => {
return (await space.spacePrimitives.readFile(name)).data; return (await space.spacePrimitives.readFile(name)).data;
}, },
"space.fileExists": (_ctx, name: string): boolean => {
return allKnownFiles.has(name);
},
}; };
} }

View File

@ -58,102 +58,6 @@ type SchemaConfig = {
config: Record<string, any>; // any = JSONSchema config: Record<string, any>; // any = JSONSchema
}; };
const configSchema = {
type: "object",
properties: {
indexPage: { type: "string", format: "page-ref" },
shortcuts: {
type: "array",
items: {
type: "object",
properties: {
command: { type: "string" },
key: { type: "string", nullable: true },
mac: { type: "string", nullable: true },
slashCommand: { type: "string", nullable: true },
priority: { type: "number", nullable: true },
},
required: ["command"],
},
nullable: true,
},
useSmartQuotes: { type: "boolean", nullable: true },
maximumAttachmentSize: { type: "number", nullable: true },
pwaOpenLastPage: { type: "boolean", nullable: true },
hideEditButton: { type: "boolean", nullable: true },
hideSyncButton: { type: "boolean", nullable: true },
libraries: {
type: "array",
items: {
type: "object",
properties: {
import: { type: "string", format: "page-ref" },
exclude: {
type: "array",
items: { type: "string", format: "page-ref" },
nullable: true,
},
},
required: ["import"],
},
nullable: true,
},
actionButtons: {
type: "array",
items: {
type: "object",
properties: {
icon: { type: "string" },
description: { type: "string", nullable: true },
command: { type: "string" },
args: {
type: "array",
items: { type: "object" },
nullable: true,
},
mobile: { type: "boolean", nullable: true },
},
required: ["icon", "command"],
},
},
objectDecorators: {
type: "array",
items: {
type: "object",
required: ["where", "attributes"],
},
nullable: true,
},
spaceIgnore: { type: "string", nullable: true },
emoji: {
type: "object",
properties: {
aliases: {
type: "object",
additionalProperties: {
type: "string",
},
},
},
required: ["aliases"],
nullable: true,
},
customStyles: {
anyOf: [
{ type: "string" },
{
type: "array",
items: { type: "string" },
},
{ type: "null" },
],
},
defaultLinkStyle: { type: "string", nullable: true },
},
additionalProperties: true,
required: [],
};
export const defaultConfig: Config = { export const defaultConfig: Config = {
indexPage: "index", indexPage: "index",
hideSyncButton: false, hideSyncButton: false,
@ -162,7 +66,11 @@ export const defaultConfig: Config = {
actionButtons: [], // Actually defaults to defaultActionButtons actionButtons: [], // Actually defaults to defaultActionButtons
schema: { schema: {
config: configSchema, config: {
type: "object",
properties: {},
additionalProperties: true,
},
tag: {}, tag: {},
}, },
}; };

View File

@ -73,7 +73,7 @@ import { LimitedMap } from "$lib/limited_map.ts";
import { plugPrefix } from "$common/spaces/constants.ts"; import { plugPrefix } from "$common/spaces/constants.ts";
import { lezerToParseTree } from "$common/markdown_parser/parse_tree.ts"; import { lezerToParseTree } from "$common/markdown_parser/parse_tree.ts";
import { findNodeMatching } from "@silverbulletmd/silverbullet/lib/tree"; import { findNodeMatching } from "@silverbulletmd/silverbullet/lib/tree";
import type { LinkObject } from "../plugs/index/page_links.ts"; import type { AspiringPageObject } from "../plugs/index/page_links.ts";
import type { Config, ConfigContainer } from "../type/config.ts"; import type { Config, ConfigContainer } from "../type/config.ts";
import { editor } from "@silverbulletmd/silverbullet/syscalls"; import { editor } from "@silverbulletmd/silverbullet/syscalls";
@ -754,19 +754,15 @@ export class Client implements ConfigContainer {
return; return;
} }
const allPages = await this.clientSystem.queryObjects<PageMeta>("page", {}); const allPages = await this.clientSystem.queryObjects<PageMeta>("page", {});
const allBrokenLinkPages = (await this.clientSystem.queryObjects< const allAspiringPages = (await this.clientSystem.queryObjects<
LinkObject AspiringPageObject
>("link", { >("aspiring-page", {
filter: ["and", ["attr", "toPage"], ["not", ["call", "pageExists", [[ select: [{ name: "name" }],
"attr", })).map((aspiringPage): PageMeta => ({
"toPage", ref: aspiringPage.name,
]]]]],
select: [{ name: "toPage" }],
})).map((link): PageMeta => ({
ref: link.toPage!,
tag: "page", tag: "page",
_isBrokenLink: true, _isAspiring: true,
name: link.toPage!, name: aspiringPage.name,
created: "", created: "",
lastModified: "", lastModified: "",
perm: "rw", perm: "rw",
@ -774,7 +770,7 @@ export class Client implements ConfigContainer {
this.ui.viewDispatch({ this.ui.viewDispatch({
type: "update-page-list", type: "update-page-list",
allPages: allPages.concat(allBrokenLinkPages), allPages: allPages.concat(allAspiringPages),
}); });
} }

View File

@ -70,12 +70,12 @@ export function PageNavigator({
name: (pageMeta.pageDecoration?.prefix ?? "") + pageMeta.name, name: (pageMeta.pageDecoration?.prefix ?? "") + pageMeta.name,
description, description,
orderId: orderId, orderId: orderId,
hint: pageMeta._isBrokenLink ? "Create page" : undefined, hint: pageMeta._isAspiring ? "Create page" : undefined,
cssClass, cssClass,
}); });
} else if (mode === "meta") { } else if (mode === "meta") {
// Special behavior for #template and #meta pages // Special behavior for #template and #meta pages
if (pageMeta._isBrokenLink) { if (pageMeta._isAspiring) {
// Skip over broken links // Skip over broken links
continue; continue;
} }

View File

@ -42,6 +42,9 @@ export function spaceReadSyscalls(editor: Client): SysCallMapping {
"space.readFile": async (_ctx, name: string): Promise<Uint8Array> => { "space.readFile": async (_ctx, name: string): Promise<Uint8Array> => {
return (await editor.space.spacePrimitives.readFile(name)).data; return (await editor.space.spacePrimitives.readFile(name)).data;
}, },
"space.fileExists": (_ctx, name: string): boolean => {
return editor.clientSystem.allKnownFiles.has(name);
},
}; };
} }

View File

@ -0,0 +1,3 @@
An aspiring page is a [[Pages|page]] that does not yet exist, but is already linked to.
Aspiring pages appear in the [[Page Picker]] (with a `Create page` hint) as well as in auto complete when creating [[Links]].

View File

@ -208,7 +208,7 @@ So, whats the fuss all about?
* Ah yes, you can also still take notes with SilverBullet. That still works. I think. * Ah yes, you can also still take notes with SilverBullet. That still works. I think.
## 0.6.1 ## 0.6.1
* Tag pages: when you click on a #tag you will now be directed to a page that shows all pages, tasks, items and paragraphs tagged with that tag. * Tag pages: when you click on a `#tag` you will now be directed to a page that shows all pages, tasks, items and paragraphs tagged with that tag.
* Action buttons (top right buttons) can now be configured; see [[SETTINGS]] for how to do this. * Action buttons (top right buttons) can now be configured; see [[SETTINGS]] for how to do this.
* Headers are now indexed, meaning you can query them [[Objects#header]] and also reference them by name via page links using `#` that I just demonstrated 👈. See [[Links]] for more information on all the types of link formats that SilverBullet now supports. * Headers are now indexed, meaning you can query them [[Objects#header]] and also reference them by name via page links using `#` that I just demonstrated 👈. See [[Links]] for more information on all the types of link formats that SilverBullet now supports.
* New {[Task: Remove Completed]} command to remove all completed tasks from a page * New {[Task: Remove Completed]} command to remove all completed tasks from a page

View File

@ -2,19 +2,17 @@
We would like to keep our space clean, these are some tools that help you do that. We would like to keep our space clean, these are some tools that help you do that.
# Broken links # Aspiring pages
This shows all internal links that are broken. This shows all page links that link to a page that does not (yet) exist. These could be broken links or just pages _aspiring_ to be created.
```template ```template
{{#let @brokenLinks = { {{#let @brokenLinks = {aspiring-page}}}
link where toPage and not pageExists(toPage)
}}}
{{#if @brokenLinks}} {{#if @brokenLinks}}
{{#each @brokenLinks}} {{#each @brokenLinks}}
* [[{{ref}}]]: broken link to [[{{toPage}}]] * [[{{ref}}]]: broken link to [[{{name}}]]
{{/each}} {{/each}}
{{else}} {{else}}
No broken links, all good! No aspiring pages, all good!
{{/if}} {{/if}}
{{/let}} {{/let}}
``` ```

View File

@ -39,6 +39,14 @@ Note that you can also query this page using the `level/intermediate` directly:
level/intermediate level/intermediate
``` ```
## aspiring-page
[[Aspiring Pages]] are pages that are linked to, but not yet created.
```query
aspiring-page
```
## table ## table
Markdown table rows are indexed using the `table` tag, any additional tags can be added using [[Tags]] in any of its cells. Markdown table rows are indexed using the `table` tag, any additional tags can be added using [[Tags]] in any of its cells.
@ -48,7 +56,6 @@ Markdown table rows are indexed using the `table` tag, any additional tags can b
| Some Row | This is an example row in between two others | | Some Row | This is an example row in between two others |
| Another key | This time without a tag | | Another key | This time without a tag |
```query ```query
table table
``` ```
@ -153,10 +160,10 @@ In addition, the `snippet` attribute attempts to capture a little bit of context
_Note_: this is the data source used for the {[Mentions: Toggle]} feature as well page {[Page: Rename]}. _Note_: this is the data source used for the {[Mentions: Toggle]} feature as well page {[Page: Rename]}.
Here is a query that shows all links that appear in this particular page: Here is a query that shows some links that appear in this particular page:
```query ```query
link where page = @page.name link where page = @page.name limit 5
``` ```
## anchor ## anchor
@ -185,13 +192,6 @@ Here are the tags used/defined in this page:
tag where page = @page.name select name, parent tag where page = @page.name select name, parent
``` ```
## attribute
This is another meta tag, which is used to index all [[Attributes]] used in your space. This is used by e.g. attribute completion in various contexts. You likely dont need to use this tag directly, but its there.
```query
attribute where page = @page.name limit 1
```
## space-config ## space-config
This stores all configuration picked up as part of [[Space Config]] This stores all configuration picked up as part of [[Space Config]]