From 80f9c14b96ccb0b9ae016c76d92e51834968464f Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Sat, 24 Aug 2024 12:35:09 +0200 Subject: [PATCH] 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 --- .github/workflows/docker.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/server.yml | 2 +- .github/workflows/test.yml | 2 +- .gitpod.Dockerfile | 2 +- Dockerfile | 2 +- cmd/compile.ts | 5 + common/config.ts | 10 +- common/syscalls/jsonschema.ts | 20 +- lib/plugos/types.ts | 5 + plug-api/lib/frontmatter.ts | 4 +- plug-api/syscalls/space.ts | 4 + plugs/builtin_plugs.ts | 1 + plugs/core/core.plug.yaml | 109 ++++++ plugs/editor/complete.ts | 19 +- plugs/editor/editor.plug.yaml | 62 ++++ plugs/emoji/emoji.plug.yaml | 12 + plugs/federation/federation.plug.yaml | 19 ++ plugs/index/api.ts | 87 ++--- plugs/index/attributes.test.ts | 64 ++++ plugs/index/attributes.ts | 145 ++++++-- plugs/index/builtins.ts | 166 +--------- plugs/index/command.ts | 2 - plugs/index/index.plug.yaml | 401 ++++++++++++++++++++++- plugs/index/lint.ts | 6 +- plugs/index/page.ts | 12 +- plugs/index/page_links.ts | 43 ++- plugs/query/widget.ts | 6 +- plugs/tasks/tasks.plug.yaml | 65 ++++ server/server_system.ts | 11 +- server/syscalls/space.ts | 8 +- type/config.ts | 102 +----- web/client.ts | 24 +- web/components/page_navigator.tsx | 4 +- web/syscalls/space.ts | 3 + website/Aspiring Pages.md | 3 + website/CHANGELOG.md | 2 +- website/Library/Core/Page/Maintenance.md | 12 +- website/Objects.md | 20 +- 39 files changed, 1061 insertions(+), 407 deletions(-) create mode 100644 plugs/core/core.plug.yaml create mode 100644 plugs/index/attributes.test.ts create mode 100644 website/Aspiring Pages.md diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b26e20c9..e367b09d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -33,7 +33,7 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: v1.45 + deno-version: v1.46 - name: Run bundle build run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9501253c..5fc4db6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: v1.45 + deno-version: v1.46 - name: Run build run: deno task build - name: Bundle diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 83b9d09c..25bd7aa4 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: v1.45 + deno-version: v1.46 - name: Build bundles run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b80cf18a..b2add49e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: v1.45 + deno-version: v1.46 - name: Run build run: deno task build diff --git a/.gitpod.Dockerfile b/.gitpod.Dockerfile index 406ea014..0601e274 100644 --- a/.gitpod.Dockerfile +++ b/.gitpod.Dockerfile @@ -1,6 +1,6 @@ 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 && \ 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 diff --git a/Dockerfile b/Dockerfile index 948acc2a..ea73547a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/cmd/compile.ts b/cmd/compile.ts index f86f4af6..fee7a696 100644 --- a/cmd/compile.ts +++ b/cmd/compile.ts @@ -43,6 +43,11 @@ export async function compileManifest( ); manifest.assets = assetsBundle.toJSON(); + // Normalize the edge case of a plug with no functions + if (!manifest.functions) { + manifest.functions = {}; + } + const jsFile = ` import { setupMessageListener } from "${ options.runtimeUrl || workerRuntimeUrl diff --git a/common/config.ts b/common/config.ts index 5d265c0c..9bfa38d3 100644 --- a/common/config.ts +++ b/common/config.ts @@ -54,12 +54,20 @@ async function loadConfigsFromSystem( console.warn("Index plug not loaded yet, falling back to default config"); 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 const allConfigs: ConfigObject[] = await system.invokeFunction( "index.queryObjects", ["space-config", {}], ); - let fullConfig: any = { ...defaultConfig }; // Now let's intelligently merge them for (const config of allConfigs) { let configObject = { [config.key]: config.value }; diff --git a/common/syscalls/jsonschema.ts b/common/syscalls/jsonschema.ts index 23e46994..ff8e1a71 100644 --- a/common/syscalls/jsonschema.ts +++ b/common/syscalls/jsonschema.ts @@ -25,14 +25,18 @@ export function jsonschemaSyscalls(): SysCallMapping { schema: any, object: any, ): undefined | string => { - const validate = ajv.compile(schema); - if (validate(object)) { - return; - } else { - let text = ajv.errorsText(validate.errors); - text = text.replaceAll("/", "."); - text = text.replace(/^data[\.\s]/, ""); - return text; + try { + const validate = ajv.compile(schema); + if (validate(object)) { + return; + } else { + let text = ajv.errorsText(validate.errors); + text = text.replaceAll("/", "."); + text = text.replace(/^data[\.\s]/, ""); + return text; + } + } catch (e) { + return e.message; } }, "jsonschema.validateSchema": ( diff --git a/lib/plugos/types.ts b/lib/plugos/types.ts index 013f4135..c2fa355d 100644 --- a/lib/plugos/types.ts +++ b/lib/plugos/types.ts @@ -29,6 +29,11 @@ export interface Manifest { * see: common/manifest.ts#SilverBulletHooks */ functions: Record>; + + /** + * 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. */ diff --git a/plug-api/lib/frontmatter.ts b/plug-api/lib/frontmatter.ts index 4dd6a6c6..e571d798 100644 --- a/plug-api/lib/frontmatter.ts +++ b/plug-api/lib/frontmatter.ts @@ -108,8 +108,8 @@ export async function extractFrontmatter( ) { return null; } - } catch (e: any) { - console.warn("Could not parse frontmatter", e.message); + } catch { + // console.warn("Could not parse frontmatter", e.message); } } diff --git a/plug-api/syscalls/space.ts b/plug-api/syscalls/space.ts index 6637abe7..5a22f130 100644 --- a/plug-api/syscalls/space.ts +++ b/plug-api/syscalls/space.ts @@ -158,3 +158,7 @@ export function writeFile( export function deleteFile(name: string): Promise { return syscall("space.deleteFile", name); } + +export function fileExists(name: string): Promise { + return syscall("space.fileExists", name); +} diff --git a/plugs/builtin_plugs.ts b/plugs/builtin_plugs.ts index 21e679aa..1e3a37b1 100644 --- a/plugs/builtin_plugs.ts +++ b/plugs/builtin_plugs.ts @@ -1,5 +1,6 @@ // TODO: Figure out how to keep this up-to-date automatically export const builtinPlugNames = [ + "core", "editor", "index", "sync", diff --git a/plugs/core/core.plug.yaml b/plugs/core/core.plug.yaml new file mode 100644 index 00000000..dcf9c76a --- /dev/null +++ b/plugs/core/core.plug.yaml @@ -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 \ No newline at end of file diff --git a/plugs/editor/complete.ts b/plugs/editor/complete.ts index 31286a2c..baa9ccbd 100644 --- a/plugs/editor/complete.ts +++ b/plugs/editor/complete.ts @@ -8,7 +8,7 @@ import type { import { listFilesCached } from "../federation/federation.ts"; import { queryObjects } from "../index/plug_api.ts"; 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"; // 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", "^_", ""]], }, 5), // And all links to non-existing pages (to augment the existing ones) - queryObjects("link", { - filter: ["and", ["attr", "toPage"], ["not", ["call", "pageExists", [[ - "attr", - "toPage", - ]]]]], - select: [{ name: "toPage" }], - }, 5).then((brokenLinks) => + queryObjects("aspiring-page", { + distinct: true, + select: [{ name: "name" }], + }, 5).then((aspiringPages) => // Rewrite them to PageMeta shaped objects - brokenLinks.map((link): PageMeta => ({ - ref: link.toPage!, + aspiringPages.map((aspiringPage): PageMeta => ({ + ref: aspiringPage.name, tag: "page", tags: ["non-existing"], // Picked up later in completion - name: link.toPage!, + name: aspiringPage.name, created: "", lastModified: "", perm: "rw", diff --git a/plugs/editor/editor.plug.yaml b/plugs/editor/editor.plug.yaml index 89f220f2..95f344b8 100644 --- a/plugs/editor/editor.plug.yaml +++ b/plugs/editor/editor.plug.yaml @@ -1,6 +1,68 @@ name: editor requiredPermissions: - 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: setEditorMode: path: "./editor.ts:setEditorMode" diff --git a/plugs/emoji/emoji.plug.yaml b/plugs/emoji/emoji.plug.yaml index df06fdd0..03639c4d 100644 --- a/plugs/emoji/emoji.plug.yaml +++ b/plugs/emoji/emoji.plug.yaml @@ -1,4 +1,16 @@ name: emoji +config: + schema.config.properties: + emoji: + type: object + properties: + aliases: + type: object + additionalProperties: + type: string + required: + - aliases + nullable: true functions: emojiCompleter: path: "./emoji.ts:emojiCompleter" diff --git a/plugs/federation/federation.plug.yaml b/plugs/federation/federation.plug.yaml index 5cb4da33..dc493c49 100644 --- a/plugs/federation/federation.plug.yaml +++ b/plugs/federation/federation.plug.yaml @@ -1,6 +1,25 @@ name: federation requiredPermissions: - 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: readFile: path: ./federation.ts:readFile diff --git a/plugs/index/api.ts b/plugs/index/api.ts index d496631f..1dd2f922 100644 --- a/plugs/index/api.ts +++ b/plugs/index/api.ts @@ -1,4 +1,4 @@ -import { datastore } from "@silverbulletmd/silverbullet/syscalls"; +import { datastore, system } from "@silverbulletmd/silverbullet/syscalls"; import type { KV, KvKey, @@ -7,8 +7,7 @@ import type { ObjectValue, } from "../../plug-api/types.ts"; import type { QueryProviderEvent } from "../../plug-api/types.ts"; -import { builtins } from "./builtins.ts"; -import { determineType } from "./attributes.ts"; +import { determineType, type SimpleJSONType } from "./attributes.ts"; import { ttlCache } from "$lib/memory_cache.ts"; const indexKey = "idx"; @@ -79,12 +78,13 @@ export async function clearIndex(): Promise { /** * Indexes entities in the data store */ -export function indexObjects( +export async function indexObjects( page: string, objects: ObjectValue[], ): Promise { const kvs: KV[] = []; - const allAttributes = new Map(); // tag:name -> attributeType + const schema = await system.getSpaceConfig("schema"); + const allAttributes = new Map(); for (const obj of objects) { if (!obj.tag) { console.error("Object has no tag", obj, "this shouldn't happen"); @@ -92,6 +92,8 @@ export function indexObjects( } // Index as all the tag + any additional tags specified const allTags = [obj.tag, ...obj.tags || []]; + const tagSchemaProperties = + schema.tag[obj.tag] && schema.tag[obj.tag].properties || {}; for (const tag of allTags) { // The object itself kvs.push({ @@ -99,41 +101,32 @@ export function indexObjects( value: obj, }); // Index attributes - const builtinAttributes = builtins[tag]; - if (!builtinAttributes) { - // This is not a builtin tag, so we index all attributes (almost, see below) - attributeLabel: for ( - const [attrName, attrValue] of Object.entries( - obj as Record, - ) - ) { - 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 + const schemaAttributes = schema.tag[tag] && schema.tag[tag].properties; + if (!schemaAttributes) { + // There is no schema definition for this tag, so we index all attributes for ( const [attrName, attrValue] of Object.entries( obj as Record, ) ) { - // console.log("Indexing", tag, attrName, attrValue); - // Skip builtins and internal attributes - if (builtinAttributes[attrName] || attrName.startsWith("$")) { + if (attrName.startsWith("$") || tagSchemaProperties[attrName]) { + continue; + } + + 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, + ) + ) { + // Skip schema-defined and internal attributes + if ( + schemaAttributes[attrName] || tagSchemaProperties[attrName] || + attrName.startsWith("$") + ) { continue; } allAttributes.set(`${tag}:${attrName}`, determineType(attrValue)); @@ -144,17 +137,15 @@ export function indexObjects( if (allAttributes.size > 0) { [...allAttributes].forEach(([key, value]) => { const [tagName, name] = key.split(":"); - const attributeType = value.startsWith("!") ? value.substring(1) : value; kvs.push({ - key: ["attribute", cleanKey(key, page)], + key: ["ah-attr", cleanKey(key, page)], value: { ref: key, - tag: "attribute", + tag: "ah-attr", tagName, name, - attributeType, - readOnly: value.startsWith("!"), page, + schema: value, } as T, }); }); @@ -188,6 +179,16 @@ export function queryObjects( }, ttlSecs); } +export function queryDeleteObjects( + tag: string, + query: ObjectQuery, +): Promise { + return datastore.queryDelete({ + ...query, + prefix: [indexKey, tag], + }); +} + export async function query( query: KvQuery, variables?: Record, @@ -220,11 +221,15 @@ export async function objectSourceProvider({ } export async function discoverSources() { + const schema = await system.getSpaceConfig("schema"); + // Query all tags we indexed return (await datastore.query({ prefix: [indexKey, "tag"], select: [{ name: "name" }], distinct: true, })).map(( { value }, - ) => value.name); + ) => value.name) + // And concatenate all the tags from the schema + .concat(Object.keys(schema.tag)); } diff --git a/plugs/index/attributes.test.ts b/plugs/index/attributes.test.ts new file mode 100644 index 00000000..7614b968 --- /dev/null +++ b/plugs/index/attributes.test.ts @@ -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", + ); +}); diff --git a/plugs/index/attributes.ts b/plugs/index/attributes.ts index 5137f449..b09e910a 100644 --- a/plugs/index/attributes.ts +++ b/plugs/index/attributes.ts @@ -3,16 +3,23 @@ import type { ObjectValue, QueryExpression, } from "../../plug-api/types.ts"; -import { events } from "@silverbulletmd/silverbullet/syscalls"; +import { events, system } from "@silverbulletmd/silverbullet/syscalls"; import { queryObjects } from "./api.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; + anyOf?: SimpleJSONType[]; +}; + +export type AdhocAttributeObject = ObjectValue<{ name: string; - attributeType: string; + schema: SimpleJSONType; tagName: string; page: string; - readOnly: boolean; }>; export type AttributeCompleteEvent = { @@ -23,20 +30,11 @@ export type AttributeCompleteEvent = { export type AttributeCompletion = { name: string; source: string; + // String version of JSON schema 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) * @param attributeCompleteEvent @@ -49,21 +47,59 @@ export async function objectAttributeCompleter( attributeCompleteEvent.source === "" ? undefined : ["=", ["attr", "tagName"], ["string", attributeCompleteEvent.source]]; - const allAttributes = await queryObjects("attribute", { + const schema = await system.getSpaceConfig("schema"); + const allAttributes = (await queryObjects("ah-attr", { filter: attributeFilter, distinct: true, - select: [{ name: "name" }, { name: "attributeType" }, { name: "tag" }, { - name: "readOnly", - }, { name: "tagName" }], - }, 5); - return allAttributes.map((value) => { + select: [{ name: "name" }, { name: "schema" }, { name: "tag" }, { + name: "tagName", + }], + }, 5)).map((value) => { return { name: value.name, source: value.tagName, - attributeType: value.attributeType, - readOnly: value.readOnly, + attributeType: jsonTypeToString(value.schema), } 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("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" }; + } +} diff --git a/plugs/index/builtins.ts b/plugs/index/builtins.ts index d625aa27..2bb079ca 100644 --- a/plugs/index/builtins.ts +++ b/plugs/index/builtins.ts @@ -1,172 +1,8 @@ import { system } from "@silverbulletmd/silverbullet/syscalls"; -import { indexObjects } from "./api.ts"; -import type { - ObjectValue, - QueryProviderEvent, -} from "@silverbulletmd/silverbullet/types"; +import type { QueryProviderEvent } from "@silverbulletmd/silverbullet/types"; import { applyQuery } from "@silverbulletmd/silverbullet/lib/query"; 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> = { - 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[] = []; - 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({ query, variables, diff --git a/plugs/index/command.ts b/plugs/index/command.ts index 52af35f5..f616659a 100644 --- a/plugs/index/command.ts +++ b/plugs/index/command.ts @@ -28,8 +28,6 @@ export async function reindexSpace(noClear = false) { // Executed this way to not have to embed the search plug code here await system.invokeFunction("index.clearIndex"); } - // Load builtins - await system.invokeFunction("index.loadBuiltinsIntoIndex"); // Pre-index SETTINGS page to get useful settings console.log("Indexing SETTINGS page"); await indexPage("SETTINGS"); diff --git a/plugs/index/index.plug.yaml b/plugs/index/index.plug.yaml index 797ee824..a1f8311b 100644 --- a/plugs/index/index.plug.yaml +++ b/plugs/index/index.plug.yaml @@ -1,11 +1,5 @@ name: index functions: - loadBuiltinsIntoIndex: - path: builtins.ts:loadBuiltinsIntoIndex - env: server - events: - - system:ready - # Public API batchSet: path: api.ts:batchSet @@ -21,7 +15,6 @@ functions: getObjectByRef: path: api.ts:getObjectByRef env: server - objectSourceProvider: path: api.ts:objectSourceProvider events: @@ -239,3 +232,397 @@ functions: path: builtins.ts:commandSourceProvider events: - 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 diff --git a/plugs/index/lint.ts b/plugs/index/lint.ts index b1a2dd45..b821fcff 100644 --- a/plugs/index/lint.ts +++ b/plugs/index/lint.ts @@ -11,7 +11,7 @@ import { } from "@silverbulletmd/silverbullet/lib/tree"; import type { LintEvent } from "../../plug-api/types.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 { cleanupJSON, @@ -26,8 +26,8 @@ export async function lintYAML({ tree }: LintEvent): Promise { await traverseTreeAsync(tree, async (node) => { if (node.type === "FrontMatterCode") { // Query all readOnly attributes for pages with this tag set - const readOnlyAttributes = await queryObjects( - "attribute", + const readOnlyAttributes = await queryObjects( + "ah-attr", { filter: ["and", ["=", ["attr", "tagName"], [ "array", diff --git a/plugs/index/page.ts b/plugs/index/page.ts index be1e1b81..37c6c88b 100644 --- a/plugs/index/page.ts +++ b/plugs/index/page.ts @@ -1,4 +1,4 @@ -import type { IndexTreeEvent } from "../../plug-api/types.ts"; +import type { IndexTreeEvent } from "@silverbulletmd/silverbullet/types"; import { editor, markdown, @@ -9,13 +9,14 @@ import { import type { LintDiagnostic, PageMeta } from "../../plug-api/types.ts"; import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter"; import { extractAttributes } from "@silverbulletmd/silverbullet/lib/attribute"; -import { indexObjects } from "./api.ts"; +import { indexObjects, queryDeleteObjects } from "./api.ts"; import { findNodeOfType, renderToText, traverseTreeAsync, -} from "../../plug-api/lib/tree.ts"; +} from "@silverbulletmd/silverbullet/lib/tree"; import { updateITags } from "@silverbulletmd/silverbullet/lib/tags"; +import type { AspiringPageObject } from "./page_links.ts"; export async function indexPage({ name, tree }: IndexTreeEvent) { if (name.startsWith("_")) { @@ -62,6 +63,11 @@ export async function indexPage({ name, tree }: IndexTreeEvent) { // console.log("Page object", combinedPageMeta); await indexObjects(name, [combinedPageMeta]); + + // Make sure this page is no (longer) in the aspiring pages list + await queryDeleteObjects("aspiring-page", { + filter: ["=", ["attr", "name"], ["string", name]], + }); } export async function lintFrontmatter(): Promise { diff --git a/plugs/index/page_links.ts b/plugs/index/page_links.ts index 16151896..bba0f6f4 100644 --- a/plugs/index/page_links.ts +++ b/plugs/index/page_links.ts @@ -23,6 +23,7 @@ import { mdLinkRegex, wikiLinkRegex, } from "$common/markdown_parser/constants.ts"; +import { space } from "@silverbulletmd/silverbullet/syscalls"; 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) { const links: ObjectValue[] = []; const frontmatter = await extractFrontmatter(tree); @@ -194,8 +208,35 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) { } return false; }); + // console.log("Found", links, "page link(s)"); - await indexObjects(name, links); + if (links.length > 0) { + await indexObjects(name, links); + } + + // Now let's check which are aspiring pages + const aspiringPages: ObjectValue[] = []; + 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( diff --git a/plugs/query/widget.ts b/plugs/query/widget.ts index f06bb9aa..d37b7a27 100644 --- a/plugs/query/widget.ts +++ b/plugs/query/widget.ts @@ -44,7 +44,11 @@ export async function widget( }, ); if (Array.isArray(results)) { - resultMarkdown = jsonToMDTable(results); + if (results.length === 0) { + resultMarkdown = "No results"; + } else { + resultMarkdown = jsonToMDTable(results); + } } else { resultMarkdown = results; } diff --git a/plugs/tasks/tasks.plug.yaml b/plugs/tasks/tasks.plug.yaml index 049289af..afc9f9c0 100644 --- a/plugs/tasks/tasks.plug.yaml +++ b/plugs/tasks/tasks.plug.yaml @@ -41,3 +41,68 @@ functions: command: name: "Task: Remove Completed" 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 \ No newline at end of file diff --git a/server/server_system.ts b/server/server_system.ts index be172e21..5e480b8a 100644 --- a/server/server_system.ts +++ b/server/server_system.ts @@ -120,7 +120,7 @@ export class ServerSystem extends CommonSystem { this.system.registerSyscalls( [], eventSyscalls(this.eventHook), - spaceReadSyscalls(space), + spaceReadSyscalls(space, this.allKnownFiles), assetSyscalls(this.system), yamlSyscalls(), systemSyscalls( @@ -169,6 +169,7 @@ export class ServerSystem extends CommonSystem { this.eventHook.addLocalListener( "file:changed", async (path, localChange) => { + this.allKnownFiles.add(path); if (!localChange) { console.log("Outside file change: reindexing", path); // Change made outside of editor, trigger reindex @@ -190,6 +191,7 @@ export class ServerSystem extends CommonSystem { }, ); + // Keep allKnownFiles up to date this.eventHook.addLocalListener( "file:listed", (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 eventedSpacePrimitives.enabled = true; diff --git a/server/syscalls/space.ts b/server/syscalls/space.ts index e41965a3..17e77941 100644 --- a/server/syscalls/space.ts +++ b/server/syscalls/space.ts @@ -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 */ -export function spaceReadSyscalls(space: Space): SysCallMapping { +export function spaceReadSyscalls( + space: Space, + allKnownFiles: Set, +): SysCallMapping { return { "space.listPages": (): Promise => { return space.fetchPageList(); @@ -46,6 +49,9 @@ export function spaceReadSyscalls(space: Space): SysCallMapping { "space.readFile": async (_ctx, name: string): Promise => { return (await space.spacePrimitives.readFile(name)).data; }, + "space.fileExists": (_ctx, name: string): boolean => { + return allKnownFiles.has(name); + }, }; } diff --git a/type/config.ts b/type/config.ts index 7fec3a29..584a86b5 100644 --- a/type/config.ts +++ b/type/config.ts @@ -58,102 +58,6 @@ type SchemaConfig = { config: Record; // 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 = { indexPage: "index", hideSyncButton: false, @@ -162,7 +66,11 @@ export const defaultConfig: Config = { actionButtons: [], // Actually defaults to defaultActionButtons schema: { - config: configSchema, + config: { + type: "object", + properties: {}, + additionalProperties: true, + }, tag: {}, }, }; diff --git a/web/client.ts b/web/client.ts index 3da1e70a..3c83ec1a 100644 --- a/web/client.ts +++ b/web/client.ts @@ -73,7 +73,7 @@ import { LimitedMap } from "$lib/limited_map.ts"; import { plugPrefix } from "$common/spaces/constants.ts"; import { lezerToParseTree } from "$common/markdown_parser/parse_tree.ts"; 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 { editor } from "@silverbulletmd/silverbullet/syscalls"; @@ -754,19 +754,15 @@ export class Client implements ConfigContainer { return; } const allPages = await this.clientSystem.queryObjects("page", {}); - const allBrokenLinkPages = (await this.clientSystem.queryObjects< - LinkObject - >("link", { - filter: ["and", ["attr", "toPage"], ["not", ["call", "pageExists", [[ - "attr", - "toPage", - ]]]]], - select: [{ name: "toPage" }], - })).map((link): PageMeta => ({ - ref: link.toPage!, + const allAspiringPages = (await this.clientSystem.queryObjects< + AspiringPageObject + >("aspiring-page", { + select: [{ name: "name" }], + })).map((aspiringPage): PageMeta => ({ + ref: aspiringPage.name, tag: "page", - _isBrokenLink: true, - name: link.toPage!, + _isAspiring: true, + name: aspiringPage.name, created: "", lastModified: "", perm: "rw", @@ -774,7 +770,7 @@ export class Client implements ConfigContainer { this.ui.viewDispatch({ type: "update-page-list", - allPages: allPages.concat(allBrokenLinkPages), + allPages: allPages.concat(allAspiringPages), }); } diff --git a/web/components/page_navigator.tsx b/web/components/page_navigator.tsx index 045683fd..472a7b25 100644 --- a/web/components/page_navigator.tsx +++ b/web/components/page_navigator.tsx @@ -70,12 +70,12 @@ export function PageNavigator({ name: (pageMeta.pageDecoration?.prefix ?? "") + pageMeta.name, description, orderId: orderId, - hint: pageMeta._isBrokenLink ? "Create page" : undefined, + hint: pageMeta._isAspiring ? "Create page" : undefined, cssClass, }); } else if (mode === "meta") { // Special behavior for #template and #meta pages - if (pageMeta._isBrokenLink) { + if (pageMeta._isAspiring) { // Skip over broken links continue; } diff --git a/web/syscalls/space.ts b/web/syscalls/space.ts index 30f6e54d..263cda53 100644 --- a/web/syscalls/space.ts +++ b/web/syscalls/space.ts @@ -42,6 +42,9 @@ export function spaceReadSyscalls(editor: Client): SysCallMapping { "space.readFile": async (_ctx, name: string): Promise => { return (await editor.space.spacePrimitives.readFile(name)).data; }, + "space.fileExists": (_ctx, name: string): boolean => { + return editor.clientSystem.allKnownFiles.has(name); + }, }; } diff --git a/website/Aspiring Pages.md b/website/Aspiring Pages.md new file mode 100644 index 00000000..6ebe96dd --- /dev/null +++ b/website/Aspiring Pages.md @@ -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]]. \ No newline at end of file diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 9b393c9a..ecb42dc6 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -208,7 +208,7 @@ So, what’s the fuss all about? * Ah yes, you can also still take notes with SilverBullet. That still works. I think. ## 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. * 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 diff --git a/website/Library/Core/Page/Maintenance.md b/website/Library/Core/Page/Maintenance.md index 549e848b..0e4f5b4f 100644 --- a/website/Library/Core/Page/Maintenance.md +++ b/website/Library/Core/Page/Maintenance.md @@ -2,19 +2,17 @@ We would like to keep our space clean, these are some tools that help you do that. -# Broken links -This shows all internal links that are broken. +# Aspiring pages +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 -{{#let @brokenLinks = { - link where toPage and not pageExists(toPage) -}}} +{{#let @brokenLinks = {aspiring-page}}} {{#if @brokenLinks}} {{#each @brokenLinks}} -* [[{{ref}}]]: broken link to [[{{toPage}}]] +* [[{{ref}}]]: broken link to [[{{name}}]] {{/each}} {{else}} -No broken links, all good! +No aspiring pages, all good! {{/if}} {{/let}} ``` diff --git a/website/Objects.md b/website/Objects.md index 2b5c935c..eead518c 100644 --- a/website/Objects.md +++ b/website/Objects.md @@ -39,6 +39,14 @@ Note that you can also query this page using the `level/intermediate` directly: level/intermediate ``` +## aspiring-page +[[Aspiring Pages]] are pages that are linked to, but not yet created. + +```query +aspiring-page +``` + + ## table 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 | | Another key | This time without a tag | - ```query 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]}. -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 -link where page = @page.name +link where page = @page.name limit 5 ``` ## anchor @@ -185,13 +192,6 @@ Here are the tags used/defined in this page: 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 don’t need to use this tag directly, but it’s there. - -```query -attribute where page = @page.name limit 1 -``` - ## space-config This stores all configuration picked up as part of [[Space Config]]