@@ -0,0 +1 @@
|
||||
build
|
||||
@@ -0,0 +1,49 @@
|
||||
import { collectNodesOfType } from "../../common/tree.ts";
|
||||
import {
|
||||
batchSet,
|
||||
queryPrefix,
|
||||
} from "../../syscall/silverbullet-syscall/index.ts";
|
||||
import {
|
||||
getCurrentPage,
|
||||
matchBefore,
|
||||
} from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import type { IndexTreeEvent } from "../../web/app_event.ts";
|
||||
import { removeQueries } from "../query/util.ts";
|
||||
|
||||
// Key space
|
||||
// a:pageName:anchorName => pos
|
||||
|
||||
export async function indexAnchors({ name: pageName, tree }: IndexTreeEvent) {
|
||||
removeQueries(tree);
|
||||
let anchors: { key: string; value: string }[] = [];
|
||||
|
||||
collectNodesOfType(tree, "NamedAnchor").forEach((n) => {
|
||||
let aName = n.children![0].text!;
|
||||
anchors.push({
|
||||
key: `a:${pageName}:${aName}`,
|
||||
value: "" + n.from,
|
||||
});
|
||||
});
|
||||
console.log("Found", anchors.length, "anchors(s)");
|
||||
await batchSet(pageName, anchors);
|
||||
}
|
||||
|
||||
export async function anchorComplete() {
|
||||
let prefix = await matchBefore("\\[\\[[^\\]@:]*@[\\w\\.\\-\\/]*");
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
const [pageRefPrefix, anchorRef] = prefix.text.split("@");
|
||||
let pageRef = pageRefPrefix.substring(2);
|
||||
if (!pageRef) {
|
||||
pageRef = await getCurrentPage();
|
||||
}
|
||||
let allAnchors = await queryPrefix(`a:${pageRef}:@${anchorRef}`);
|
||||
return {
|
||||
from: prefix.from + pageRefPrefix.length + 1,
|
||||
options: allAnchors.map((a) => ({
|
||||
label: a.key.split("@")[1],
|
||||
type: "anchor",
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
FileData,
|
||||
FileEncoding,
|
||||
} from "../../common/spaces/space_primitives.ts";
|
||||
import { renderToText, replaceNodesMatching } from "../../common/tree.ts";
|
||||
import type { FileMeta } from "../../common/types.ts";
|
||||
import { parseMarkdown } from "../../syscall/silverbullet-syscall/markdown.ts";
|
||||
|
||||
const pagePrefix = "💭 ";
|
||||
|
||||
export async function readFileCloud(
|
||||
name: string,
|
||||
encoding: FileEncoding,
|
||||
): Promise<{ data: FileData; meta: FileMeta } | undefined> {
|
||||
let originalUrl = name.substring(
|
||||
pagePrefix.length,
|
||||
name.length - ".md".length,
|
||||
);
|
||||
let url = originalUrl;
|
||||
if (!url.includes("/")) {
|
||||
url += "/index";
|
||||
}
|
||||
if (!url.startsWith("127.0.0.1")) {
|
||||
url = `https://${url}`;
|
||||
} else {
|
||||
url = `http://${url}`;
|
||||
}
|
||||
let text = "";
|
||||
try {
|
||||
let r = await fetch(`${url}.md`);
|
||||
text = await r.text();
|
||||
if (r.status !== 200) {
|
||||
text = `ERROR: ${text}`;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("ERROR", e.message);
|
||||
text = e.message;
|
||||
}
|
||||
return {
|
||||
data: await translateLinksWithPrefix(
|
||||
text,
|
||||
`${pagePrefix}${originalUrl.split("/")[0]}/`,
|
||||
),
|
||||
meta: {
|
||||
name,
|
||||
contentType: "text/markdown",
|
||||
lastModified: 0,
|
||||
size: text.length,
|
||||
perm: "ro",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function translateLinksWithPrefix(
|
||||
text: string,
|
||||
prefix: string,
|
||||
): Promise<string> {
|
||||
let tree = await parseMarkdown(text);
|
||||
replaceNodesMatching(tree, (tree) => {
|
||||
if (tree.type === "WikiLinkPage") {
|
||||
// Add the prefix in the link text
|
||||
tree.children![0].text = prefix + tree.children![0].text;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
text = renderToText(tree);
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function getFileMetaCloud(name: string): Promise<FileMeta> {
|
||||
return {
|
||||
name,
|
||||
size: 0,
|
||||
contentType: "text/markdown",
|
||||
lastModified: 0,
|
||||
perm: "ro",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { matchBefore } from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import { listCommands } from "../../syscall/silverbullet-syscall/system.ts";
|
||||
|
||||
export async function commandComplete() {
|
||||
let prefix = await matchBefore("\\{\\[[^\\]]*");
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
let allCommands = await listCommands();
|
||||
|
||||
return {
|
||||
from: prefix.from + 2,
|
||||
options: Object.keys(allCommands).map((commandName) => ({
|
||||
label: commandName,
|
||||
type: "command",
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
name: core
|
||||
syntax:
|
||||
Hashtag:
|
||||
firstCharacters:
|
||||
- "#"
|
||||
regex: "#[^#\\d\\s]+\\w+"
|
||||
className: sb-hashtag
|
||||
NakedURL:
|
||||
firstCharacters:
|
||||
- "h"
|
||||
regex: "https?:\\/\\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}([-a-zA-Z0-9()@:%_\\+.~#?&=\\/]*)"
|
||||
className: sb-naked-url
|
||||
CommandLink:
|
||||
firstCharacters:
|
||||
- "{"
|
||||
regex: "\\{\\[[^\\]]+\\]\\}"
|
||||
className: sb-command-link
|
||||
NamedAnchor:
|
||||
firstCharacters:
|
||||
- "@"
|
||||
regex: "@[a-zA-Z\\.\\-\\/]+[\\w\\.\\-\\/]*"
|
||||
className: sb-named-anchor
|
||||
functions:
|
||||
clearPageIndex:
|
||||
path: "./page.ts:clearPageIndex"
|
||||
env: server
|
||||
events:
|
||||
- page:saved
|
||||
- page:deleted
|
||||
pageQueryProvider:
|
||||
path: ./page.ts:pageQueryProvider
|
||||
events:
|
||||
- query:page
|
||||
parseIndexTextRepublish:
|
||||
path: "./page.ts:parseIndexTextRepublish"
|
||||
events:
|
||||
- page:index_text
|
||||
reindexSpaceCommand:
|
||||
path: "./page.ts:reindexCommand"
|
||||
command:
|
||||
name: "Space: Reindex"
|
||||
reindexSpace:
|
||||
path: "./page.ts:reindexSpace"
|
||||
env: server
|
||||
deletePage:
|
||||
path: "./page.ts:deletePage"
|
||||
command:
|
||||
name: "Page: Delete"
|
||||
|
||||
editorLoad:
|
||||
path: "./editor.ts:editorLoad"
|
||||
events:
|
||||
- plugs:loaded
|
||||
toggleReadOnlyode:
|
||||
path: "./editor.ts:toggleReadOnlyMode"
|
||||
command:
|
||||
name: "Editor: Toggle Read Only Mode"
|
||||
|
||||
# Backlinks
|
||||
indexLinks:
|
||||
path: "./page.ts:indexLinks"
|
||||
events:
|
||||
- page:index
|
||||
linkQueryProvider:
|
||||
path: ./page.ts:linkQueryProvider
|
||||
events:
|
||||
- query:link
|
||||
renamePage:
|
||||
path: "./page.ts:renamePage"
|
||||
command:
|
||||
name: "Page: Rename"
|
||||
mac: Cmd-Alt-r
|
||||
key: Ctrl-Alt-r
|
||||
|
||||
pageComplete:
|
||||
path: "./page.ts:pageComplete"
|
||||
events:
|
||||
- page:complete
|
||||
|
||||
# Commands
|
||||
commandComplete:
|
||||
path: "./command.ts:commandComplete"
|
||||
events:
|
||||
- page:complete
|
||||
|
||||
# Item indexing
|
||||
indexItem:
|
||||
path: "./item.ts:indexItems"
|
||||
events:
|
||||
- page:index
|
||||
itemQueryProvider:
|
||||
path: "./item.ts:queryProvider"
|
||||
events:
|
||||
- query:item
|
||||
|
||||
# Navigation
|
||||
linkNavigate:
|
||||
path: "./navigate.ts:linkNavigate"
|
||||
command:
|
||||
name: Navigate To page
|
||||
key: Ctrl-Enter
|
||||
mac: Cmd-Enter
|
||||
clickNavigate:
|
||||
path: "./navigate.ts:clickNavigate"
|
||||
events:
|
||||
- page:click
|
||||
navigateHome:
|
||||
path: "./navigate.ts:navigateCommand"
|
||||
command:
|
||||
name: "Navigate: Home"
|
||||
key: "Alt-h"
|
||||
page: ""
|
||||
|
||||
# Hashtags
|
||||
indexTags:
|
||||
path: "./tags.ts:indexTags"
|
||||
events:
|
||||
- page:index
|
||||
tagComplete:
|
||||
path: "./tags.ts:tagComplete"
|
||||
events:
|
||||
- page:complete
|
||||
tagProvider:
|
||||
path: "./tags.ts:tagProvider"
|
||||
events:
|
||||
- query:tag
|
||||
|
||||
# Anchors
|
||||
indexAnchors:
|
||||
path: "./anchor.ts:indexAnchors"
|
||||
events:
|
||||
- page:index
|
||||
anchorComplete:
|
||||
path: "./anchor.ts:anchorComplete"
|
||||
events:
|
||||
- page:complete
|
||||
|
||||
# Full text search
|
||||
# searchIndex:
|
||||
# path: ./search.ts:index
|
||||
# events:
|
||||
# - page:index
|
||||
# searchUnindex:
|
||||
# path: "./search.ts:unindex"
|
||||
# env: server
|
||||
# events:
|
||||
# - page:deleted
|
||||
searchQueryProvider:
|
||||
path: ./search.ts:queryProvider
|
||||
events:
|
||||
- query:full-text
|
||||
searchCommand:
|
||||
path: ./search.ts:searchCommand
|
||||
command:
|
||||
name: "Search Space"
|
||||
key: Ctrl-Shift-f
|
||||
mac: Cmd-Shift-f
|
||||
readPageSearch:
|
||||
path: ./search.ts:readPageSearch
|
||||
pageNamespace:
|
||||
pattern: "🔍 .+"
|
||||
operation: readFile
|
||||
getPageMetaSearch:
|
||||
path: ./search.ts:getPageMetaSearch
|
||||
pageNamespace:
|
||||
pattern: "🔍 .+"
|
||||
operation: getFileMeta
|
||||
|
||||
# Template commands
|
||||
insertPageMeta:
|
||||
path: "./template.ts:insertTemplateText"
|
||||
slashCommand:
|
||||
name: meta
|
||||
description: Insert a page metadata block
|
||||
value: |
|
||||
```meta
|
||||
|^|
|
||||
```
|
||||
insertTask:
|
||||
path: "./template.ts:insertTemplateText"
|
||||
slashCommand:
|
||||
name: task
|
||||
description: Insert a task
|
||||
value: "* [ ] |^|"
|
||||
insertQuery:
|
||||
path: "./template.ts:insertTemplateText"
|
||||
slashCommand:
|
||||
name: query
|
||||
description: Insert a query
|
||||
value: |
|
||||
<!-- #query |^| -->
|
||||
|
||||
<!-- /query -->
|
||||
insertInclude:
|
||||
path: "./template.ts:insertTemplateText"
|
||||
slashCommand:
|
||||
name: include
|
||||
description: Include another page
|
||||
value: |
|
||||
<!-- #include [[|^|]] -->
|
||||
|
||||
<!-- /include -->
|
||||
insertInjectTemplate:
|
||||
path: "./template.ts:insertTemplateText"
|
||||
slashCommand:
|
||||
name: use
|
||||
description: Use a template
|
||||
value: |
|
||||
<!-- #use [[|^|]] {} -->
|
||||
|
||||
<!-- /use -->
|
||||
insertInjectCleanTemplate:
|
||||
path: "./template.ts:insertTemplateText"
|
||||
slashCommand:
|
||||
name: use-verbose
|
||||
description: Use a template (verbose mode)
|
||||
value: |
|
||||
<!-- #use-verbose [[|^|]] {} -->
|
||||
|
||||
<!-- /use-verbose -->
|
||||
insertHRTemplate:
|
||||
path: "./template.ts:insertTemplateText"
|
||||
slashCommand:
|
||||
name: hr
|
||||
description: Insert a horizontal rule
|
||||
value: "---"
|
||||
quickNoteCommand:
|
||||
path: ./template.ts:quickNoteCommand
|
||||
command:
|
||||
name: "Quick Note"
|
||||
key: "Alt-Shift-n"
|
||||
priority: 1
|
||||
dailyNoteCommand:
|
||||
path: ./template.ts:dailyNoteCommand
|
||||
command:
|
||||
name: "Open Daily Note"
|
||||
key: "Alt-Shift-d"
|
||||
|
||||
instantiateTemplateCommand:
|
||||
path: ./template.ts:instantiateTemplateCommand
|
||||
command:
|
||||
name: "Template: Instantiate Page"
|
||||
insertSnippet:
|
||||
path: ./template.ts:insertSnippet
|
||||
command:
|
||||
name: "Template: Insert Snippet"
|
||||
slashCommand:
|
||||
name: snippet
|
||||
description: Insert a snippet
|
||||
insertTodayCommand:
|
||||
path: "./template.ts:insertTemplateText"
|
||||
slashCommand:
|
||||
name: today
|
||||
description: Insert today's date
|
||||
value: "{{today}}"
|
||||
insertTomorrowCommand:
|
||||
path: "./template.ts:insertTemplateText"
|
||||
slashCommand:
|
||||
name: tomorrow
|
||||
description: Insert tomorrow's date
|
||||
value: "{{tomorrow}}"
|
||||
|
||||
# Text editing commands
|
||||
quoteSelectionCommand:
|
||||
path: ./text.ts:quoteSelection
|
||||
command:
|
||||
name: "Text: Quote Selection"
|
||||
key: "Ctrl-Shift-."
|
||||
mac: "Cmd-Shift-."
|
||||
listifySelection:
|
||||
path: ./text.ts:listifySelection
|
||||
command:
|
||||
name: "Text: Listify Selection"
|
||||
numberListifySelection:
|
||||
path: ./text.ts:numberListifySelection
|
||||
command:
|
||||
name: "Text: Number Listify Selection"
|
||||
linkSelection:
|
||||
path: ./text.ts:linkSelection
|
||||
command:
|
||||
name: "Text: Link Selection"
|
||||
key: "Ctrl-Shift-k"
|
||||
mac: "Cmd-Shift-k"
|
||||
bold:
|
||||
path: ./text.ts:wrapSelection
|
||||
command:
|
||||
name: "Text: Bold"
|
||||
key: "Ctrl-b"
|
||||
mac: "Cmd-b"
|
||||
wrapper: "**"
|
||||
italic:
|
||||
path: ./text.ts:wrapSelection
|
||||
command:
|
||||
name: "Text: Italic"
|
||||
key: "Ctrl-i"
|
||||
mac: "Cmd-i"
|
||||
wrapper: "_"
|
||||
marker:
|
||||
path: ./text.ts:wrapSelection
|
||||
command:
|
||||
name: "Text: Marker"
|
||||
key: "Alt-m"
|
||||
wrapper: "=="
|
||||
|
||||
# Plug manager
|
||||
updatePlugsCommand:
|
||||
path: ./plugmanager.ts:updatePlugsCommand
|
||||
command:
|
||||
name: "Plugs: Update"
|
||||
key: "Ctrl-Shift-p"
|
||||
mac: "Cmd-Shift-p"
|
||||
updatePlugs:
|
||||
path: ./plugmanager.ts:updatePlugs
|
||||
env: server
|
||||
getPlugHTTPS:
|
||||
path: "./plugmanager.ts:getPlugHTTPS"
|
||||
events:
|
||||
- get-plug:https
|
||||
getPlugGithub:
|
||||
path: "./plugmanager.ts:getPlugGithub"
|
||||
events:
|
||||
- get-plug:github
|
||||
getPlugGithubRelease:
|
||||
path: "./plugmanager.ts:getPlugGithubRelease"
|
||||
events:
|
||||
- get-plug:ghr
|
||||
# Debug commands
|
||||
parseCommand:
|
||||
path: ./debug.ts:parsePageCommand
|
||||
command:
|
||||
name: "Debug: Parse Document"
|
||||
showLogsCommand:
|
||||
path: ./debug.ts:showLogsCommand
|
||||
command:
|
||||
name: "Debug: Show Logs"
|
||||
key: "Ctrl-Alt-l"
|
||||
mac: "Cmd-Alt-l"
|
||||
events:
|
||||
- log:reload
|
||||
hideBhsCommand:
|
||||
path: ./debug.ts:hideBhsCommand
|
||||
command:
|
||||
name: "UI: Hide BHS"
|
||||
key: "Ctrl-Alt-b"
|
||||
mac: "Cmd-Alt-b"
|
||||
|
||||
# Link unfurl infrastructure
|
||||
unfurlLink:
|
||||
path: ./link.ts:unfurlCommand
|
||||
command:
|
||||
name: "Link: Unfurl"
|
||||
key: "Ctrl-Shift-u"
|
||||
mac: "Cmd-Shift-u"
|
||||
contexts:
|
||||
- NakedURL
|
||||
unfurlExec:
|
||||
env: server
|
||||
path: ./link.ts:unfurlExec
|
||||
|
||||
# Title-based link unfurl
|
||||
titleUnfurlOptions:
|
||||
path: ./link.ts:titleUnfurlOptions
|
||||
events:
|
||||
- unfurl:options
|
||||
titleUnfurl:
|
||||
path: ./link.ts:titleUnfurl
|
||||
events:
|
||||
- unfurl:title-unfurl
|
||||
|
||||
# Random stuff
|
||||
statsCommand:
|
||||
path: ./stats.ts:statsCommand
|
||||
command:
|
||||
name: "Stats: Show"
|
||||
key: "Ctrl-s"
|
||||
mac: "Cmd-s"
|
||||
|
||||
# Cloud pages
|
||||
readPageCloud:
|
||||
path: ./cloud.ts:readFileCloud
|
||||
pageNamespace:
|
||||
pattern: "💭 .+"
|
||||
operation: readFile
|
||||
getPageMetaCloud:
|
||||
path: ./cloud.ts:getFileMetaCloud
|
||||
pageNamespace:
|
||||
pattern: "💭 .+"
|
||||
operation: getFileMeta
|
||||
@@ -0,0 +1,11 @@
|
||||
export function niceDate(d: Date): string {
|
||||
function pad(n: number) {
|
||||
let s = String(n);
|
||||
if (s.length === 1) {
|
||||
s = '0' + s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { getLogs } from "../../syscall/plugos-syscall/sandbox.ts";
|
||||
import {
|
||||
getText,
|
||||
hidePanel,
|
||||
showPanel,
|
||||
} from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import { parseMarkdown } from "../../syscall/silverbullet-syscall/markdown.ts";
|
||||
import { getServerLogs } from "../../syscall/silverbullet-syscall/sandbox.ts";
|
||||
|
||||
export async function parsePageCommand() {
|
||||
console.log(
|
||||
"AST",
|
||||
JSON.stringify(await parseMarkdown(await getText()), null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
export async function showLogsCommand() {
|
||||
let clientLogs = await getLogs();
|
||||
let serverLogs = await getServerLogs();
|
||||
|
||||
await showPanel(
|
||||
"bhs",
|
||||
1,
|
||||
`
|
||||
<style>
|
||||
#client-log-header {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
}
|
||||
#server-log-header {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 5px;
|
||||
width: 50%;
|
||||
}
|
||||
#client-log {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 30px;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
overflow: scroll;
|
||||
}
|
||||
#server-log {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
overflow: scroll;
|
||||
}
|
||||
</style>
|
||||
<div id="client-log-header">Client logs (max 100)</div>
|
||||
<div id="client-log">
|
||||
<pre>${clientLogs
|
||||
.map((le) => `[${le.level}] ${le.message}`)
|
||||
.join("\n")}</pre>
|
||||
</div>
|
||||
<div id="server-log-header">Server logs (max 100)</div>
|
||||
<div id="server-log">
|
||||
<pre>${serverLogs
|
||||
.map((le) => `[${le.level}] ${le.message}`)
|
||||
.join("\n")}</pre>
|
||||
</div>`,
|
||||
`
|
||||
var clientDiv = document.getElementById("client-log");
|
||||
clientDiv.scrollTop = clientDiv.scrollHeight;
|
||||
var serverDiv = document.getElementById("server-log");
|
||||
serverDiv.scrollTop = serverDiv.scrollHeight;
|
||||
if(window.reloadInterval) {
|
||||
clearInterval(window.reloadInterval);
|
||||
}
|
||||
window.reloadInterval = setInterval(() => {
|
||||
sendEvent("log:reload");
|
||||
}, 1000);
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
export async function hideBhsCommand() {
|
||||
await hidePanel("bhs");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as clientStore from "../../syscall/silverbullet-syscall/clientStore.ts";
|
||||
import { enableReadOnlyMode } from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
|
||||
export async function editorLoad() {
|
||||
let readOnlyMode = await clientStore.get("readOnlyMode");
|
||||
if (readOnlyMode) {
|
||||
await enableReadOnlyMode(true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleReadOnlyMode() {
|
||||
let readOnlyMode = await clientStore.get("readOnlyMode");
|
||||
readOnlyMode = !readOnlyMode;
|
||||
await enableReadOnlyMode(readOnlyMode);
|
||||
await clientStore.set("readOnlyMode", readOnlyMode);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { IndexTreeEvent } from "../../web/app_event.ts";
|
||||
|
||||
import {
|
||||
batchSet,
|
||||
queryPrefix,
|
||||
} from "../../syscall/silverbullet-syscall/index.ts";
|
||||
import {
|
||||
collectNodesOfType,
|
||||
ParseTree,
|
||||
renderToText,
|
||||
} from "../../common/tree.ts";
|
||||
import { removeQueries } from "../query/util.ts";
|
||||
import { applyQuery, QueryProviderEvent } from "../query/engine.ts";
|
||||
|
||||
export type Item = {
|
||||
name: string;
|
||||
nested?: string;
|
||||
tags?: string[];
|
||||
// Not stored in DB
|
||||
page?: string;
|
||||
pos?: number;
|
||||
};
|
||||
|
||||
export async function indexItems({ name, tree }: IndexTreeEvent) {
|
||||
let items: { key: string; value: Item }[] = [];
|
||||
removeQueries(tree);
|
||||
|
||||
console.log("Indexing items", name);
|
||||
|
||||
let coll = collectNodesOfType(tree, "ListItem");
|
||||
|
||||
coll.forEach((n) => {
|
||||
if (!n.children) {
|
||||
return;
|
||||
}
|
||||
if (collectNodesOfType(n, "Task").length > 0) {
|
||||
// This is a task item, skip it
|
||||
return;
|
||||
}
|
||||
|
||||
let textNodes: ParseTree[] = [];
|
||||
let nested: string | undefined;
|
||||
for (let child of n.children!.slice(1)) {
|
||||
if (child.type === "OrderedList" || child.type === "BulletList") {
|
||||
nested = renderToText(child);
|
||||
break;
|
||||
}
|
||||
textNodes.push(child);
|
||||
}
|
||||
|
||||
let itemText = textNodes.map(renderToText).join("").trim();
|
||||
let item: Item = {
|
||||
name: itemText,
|
||||
};
|
||||
if (nested) {
|
||||
item.nested = nested;
|
||||
}
|
||||
collectNodesOfType(n, "Hashtag").forEach((h) => {
|
||||
if (!item.tags) {
|
||||
item.tags = [];
|
||||
}
|
||||
item.tags.push(h.children![0].text!);
|
||||
});
|
||||
|
||||
items.push({
|
||||
key: `it:${n.from}`,
|
||||
value: item,
|
||||
});
|
||||
});
|
||||
console.log("Found", items.length, "item(s)");
|
||||
await batchSet(name, items);
|
||||
}
|
||||
|
||||
export async function queryProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
let allItems: Item[] = [];
|
||||
for (let { key, page, value } of await queryPrefix("it:")) {
|
||||
let [, pos] = key.split(":");
|
||||
allItems.push({
|
||||
...value,
|
||||
page: page,
|
||||
pos: +pos,
|
||||
});
|
||||
}
|
||||
return applyQuery(query, allItems);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { nodeAtPos } from "../../common/tree.ts";
|
||||
import {
|
||||
filterBox,
|
||||
flashNotification,
|
||||
getCursor,
|
||||
getText,
|
||||
replaceRange,
|
||||
} from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import { parseMarkdown } from "../../syscall/silverbullet-syscall/markdown.ts";
|
||||
import { dispatch as dispatchEvent } from "../../syscall/plugos-syscall/event.ts";
|
||||
import { invokeFunction } from "../../syscall/silverbullet-syscall/system.ts";
|
||||
|
||||
type UnfurlOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export async function unfurlCommand() {
|
||||
let mdTree = await parseMarkdown(await getText());
|
||||
let nakedUrlNode = nodeAtPos(mdTree, await getCursor());
|
||||
let url = nakedUrlNode!.children![0].text!;
|
||||
console.log("Got URL to unfurl", url);
|
||||
let optionResponses = await dispatchEvent("unfurl:options", url);
|
||||
let options: UnfurlOption[] = [];
|
||||
for (let resp of optionResponses) {
|
||||
options.push(...resp);
|
||||
}
|
||||
let selectedUnfurl: any = await filterBox(
|
||||
"Unfurl",
|
||||
options,
|
||||
"Select the unfurl strategy of your choice"
|
||||
);
|
||||
if (!selectedUnfurl) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let replacement = await invokeFunction(
|
||||
"server",
|
||||
"unfurlExec",
|
||||
selectedUnfurl.id,
|
||||
url
|
||||
);
|
||||
await replaceRange(nakedUrlNode?.from!, nakedUrlNode?.to!, replacement);
|
||||
} catch (e: any) {
|
||||
await flashNotification(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export async function titleUnfurlOptions(url: string): Promise<UnfurlOption[]> {
|
||||
return [
|
||||
{
|
||||
id: "title-unfurl",
|
||||
name: "Extract title",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Run on the server because plugs will likely rely on fetch for this
|
||||
export async function unfurlExec(id: string, url: string): Promise<string> {
|
||||
let replacement = await dispatchEvent(`unfurl:${id}`, url);
|
||||
if (replacement.length === 0) {
|
||||
throw new Error("Unfurl failed");
|
||||
} else {
|
||||
return replacement[0];
|
||||
}
|
||||
}
|
||||
|
||||
const titleRegex = /<title[^>]*>\s*([^<]+)\s*<\/title\s*>/i;
|
||||
|
||||
export async function titleUnfurl(url: string): Promise<string> {
|
||||
let response = await fetch(url);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
console.error("Unfurl failed", await response.text());
|
||||
throw new Error(`Failed to fetch: ${await response.statusText}`);
|
||||
}
|
||||
let body = await response.text();
|
||||
let match = titleRegex.exec(body);
|
||||
if (match) {
|
||||
return `[${match[1]}](${url})`;
|
||||
} else {
|
||||
throw new Error("No title found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ClickEvent } from "../../web/app_event.ts";
|
||||
import {
|
||||
flashNotification,
|
||||
getCurrentPage,
|
||||
getCursor,
|
||||
getText,
|
||||
navigate as navigateTo,
|
||||
openUrl,
|
||||
} from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import { parseMarkdown } from "../../syscall/silverbullet-syscall/markdown.ts";
|
||||
import { nodeAtPos, ParseTree } from "../../common/tree.ts";
|
||||
import { invokeCommand } from "../../syscall/silverbullet-syscall/system.ts";
|
||||
|
||||
// Checks if the URL contains a protocol, if so keeps it, otherwise assumes an attachment
|
||||
function patchUrl(url: string): string {
|
||||
if (url.indexOf("://") === -1) {
|
||||
return `fs/${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function actionClickOrActionEnter(mdTree: ParseTree | null) {
|
||||
if (!mdTree) {
|
||||
return;
|
||||
}
|
||||
// console.log("Attempting to navigate based on syntax node", mdTree);
|
||||
switch (mdTree.type) {
|
||||
case "WikiLinkPage": {
|
||||
let pageLink = mdTree.children![0].text!;
|
||||
let pos;
|
||||
if (pageLink.includes("@")) {
|
||||
[pageLink, pos] = pageLink.split("@");
|
||||
if (pos.match(/^\d+$/)) {
|
||||
pos = +pos;
|
||||
}
|
||||
}
|
||||
if (!pageLink) {
|
||||
pageLink = await getCurrentPage();
|
||||
}
|
||||
await navigateTo(pageLink, pos);
|
||||
break;
|
||||
}
|
||||
case "URL":
|
||||
case "NakedURL":
|
||||
await openUrl(patchUrl(mdTree.children![0].text!));
|
||||
break;
|
||||
case "Link": {
|
||||
const url = patchUrl(mdTree.children![4].children![0].text!);
|
||||
if (url.length <= 1) {
|
||||
return flashNotification("Empty link, ignoring", "error");
|
||||
}
|
||||
await openUrl(url);
|
||||
break;
|
||||
}
|
||||
case "CommandLink": {
|
||||
const command = mdTree
|
||||
.children![0].text!.substring(2, mdTree.children![0].text!.length - 2)
|
||||
.trim();
|
||||
console.log("Got command link", command);
|
||||
await invokeCommand(command);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function linkNavigate() {
|
||||
const mdTree = await parseMarkdown(await getText());
|
||||
const newNode = nodeAtPos(mdTree, await getCursor());
|
||||
await actionClickOrActionEnter(newNode);
|
||||
}
|
||||
|
||||
export async function clickNavigate(event: ClickEvent) {
|
||||
// Navigate by default, don't navigate when Ctrl or Cmd is held
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
const mdTree = await parseMarkdown(await getText());
|
||||
const newNode = nodeAtPos(mdTree, event.pos);
|
||||
await actionClickOrActionEnter(newNode);
|
||||
}
|
||||
|
||||
export async function navigateCommand(cmdDef: any) {
|
||||
await navigateTo(cmdDef.page);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { IndexEvent, IndexTreeEvent } from "../../web/app_event.ts";
|
||||
import {
|
||||
batchSet,
|
||||
clearPageIndex as clearPageIndexSyscall,
|
||||
clearPageIndexForPage,
|
||||
queryPrefix,
|
||||
set,
|
||||
} from "../../syscall/silverbullet-syscall/index.ts";
|
||||
|
||||
import {
|
||||
flashNotification,
|
||||
getCurrentPage,
|
||||
getCursor,
|
||||
getText,
|
||||
matchBefore,
|
||||
navigate,
|
||||
prompt,
|
||||
} from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
|
||||
import { dispatch } from "../../syscall/plugos-syscall/event.ts";
|
||||
import {
|
||||
deletePage as deletePageSyscall,
|
||||
listPages,
|
||||
readPage,
|
||||
writePage,
|
||||
} from "../../syscall/silverbullet-syscall/space.ts";
|
||||
import { invokeFunction } from "../../syscall/silverbullet-syscall/system.ts";
|
||||
import { parseMarkdown } from "../../syscall/silverbullet-syscall/markdown.ts";
|
||||
import {
|
||||
addParentPointers,
|
||||
collectNodesMatching,
|
||||
ParseTree,
|
||||
renderToText,
|
||||
replaceNodesMatching,
|
||||
} from "../../common/tree.ts";
|
||||
import { applyQuery, QueryProviderEvent } from "../query/engine.ts";
|
||||
import { extractMeta } from "../query/data.ts";
|
||||
|
||||
// Key space:
|
||||
// pl:toPage:pos => pageName
|
||||
// meta => metaJson
|
||||
|
||||
export async function indexLinks({ name, tree }: IndexTreeEvent) {
|
||||
let backLinks: { key: string; value: string }[] = [];
|
||||
// [[Style Links]]
|
||||
console.log("Now indexing", name);
|
||||
let pageMeta = extractMeta(tree);
|
||||
if (Object.keys(pageMeta).length > 0) {
|
||||
console.log("Extracted page meta data", pageMeta);
|
||||
// Don't index meta data starting with $
|
||||
for (let key in pageMeta) {
|
||||
if (key.startsWith("$")) {
|
||||
delete pageMeta[key];
|
||||
}
|
||||
}
|
||||
await set(name, "meta:", pageMeta);
|
||||
}
|
||||
|
||||
collectNodesMatching(tree, (n) => n.type === "WikiLinkPage").forEach((n) => {
|
||||
let toPage = n.children![0].text!;
|
||||
if (toPage.includes("@")) {
|
||||
toPage = toPage.split("@")[0];
|
||||
}
|
||||
backLinks.push({
|
||||
key: `pl:${toPage}:${n.from}`,
|
||||
value: name,
|
||||
});
|
||||
});
|
||||
console.log("Found", backLinks.length, "wiki link(s)");
|
||||
await batchSet(name, backLinks);
|
||||
}
|
||||
|
||||
export async function pageQueryProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
let allPages = await listPages();
|
||||
let allPageMap: Map<string, any> = new Map(
|
||||
allPages.map((pm) => [pm.name, pm])
|
||||
);
|
||||
for (let { page, value } of await queryPrefix("meta:")) {
|
||||
let p = allPageMap.get(page);
|
||||
if (p) {
|
||||
for (let [k, v] of Object.entries(value)) {
|
||||
p[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
allPages = [...allPageMap.values()];
|
||||
return applyQuery(query, allPages);
|
||||
}
|
||||
|
||||
export async function linkQueryProvider({
|
||||
query,
|
||||
pageName,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
let links: any[] = [];
|
||||
for (let { value: name, key } of await queryPrefix(`pl:${pageName}:`)) {
|
||||
const [, , pos] = key.split(":"); // Key: pl:page:pos
|
||||
links.push({ name, pos });
|
||||
}
|
||||
return applyQuery(query, links);
|
||||
}
|
||||
|
||||
export async function deletePage() {
|
||||
let pageName = await getCurrentPage();
|
||||
console.log("Navigating to index page");
|
||||
await navigate("");
|
||||
console.log("Deleting page from space");
|
||||
await deletePageSyscall(pageName);
|
||||
}
|
||||
|
||||
export async function renamePage() {
|
||||
const oldName = await getCurrentPage();
|
||||
const cursor = await getCursor();
|
||||
console.log("Old name is", oldName);
|
||||
const newName = await prompt(`Rename ${oldName} to:`, oldName);
|
||||
if (!newName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newName.trim() === oldName.trim()) {
|
||||
return;
|
||||
}
|
||||
console.log("New name", newName);
|
||||
|
||||
let pagesToUpdate = await getBackLinks(oldName);
|
||||
console.log("All pages containing backlinks", pagesToUpdate);
|
||||
|
||||
let text = await getText();
|
||||
console.log("Writing new page to space");
|
||||
await writePage(newName, text);
|
||||
console.log("Navigating to new page");
|
||||
await navigate(newName, cursor, true);
|
||||
console.log("Deleting page from space");
|
||||
await deletePageSyscall(oldName);
|
||||
|
||||
let pageToUpdateSet = new Set<string>();
|
||||
for (let pageToUpdate of pagesToUpdate) {
|
||||
pageToUpdateSet.add(pageToUpdate.page);
|
||||
}
|
||||
|
||||
for (let pageToUpdate of pageToUpdateSet) {
|
||||
if (pageToUpdate === oldName) {
|
||||
continue;
|
||||
}
|
||||
console.log("Now going to update links in", pageToUpdate);
|
||||
let { text } = await readPage(pageToUpdate);
|
||||
// console.log("Received text", text);
|
||||
if (!text) {
|
||||
// Page likely does not exist, but at least we can skip it
|
||||
continue;
|
||||
}
|
||||
let mdTree = await parseMarkdown(text);
|
||||
addParentPointers(mdTree);
|
||||
replaceNodesMatching(mdTree, (n): ParseTree | undefined | null => {
|
||||
if (n.type === "WikiLinkPage") {
|
||||
let pageName = n.children![0].text!;
|
||||
if (pageName === oldName) {
|
||||
n.children![0].text = newName;
|
||||
return n;
|
||||
}
|
||||
// page name with @pos position
|
||||
if (pageName.startsWith(`${oldName}@`)) {
|
||||
let [, pos] = pageName.split("@");
|
||||
n.children![0].text = `${newName}@${pos}`;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return;
|
||||
});
|
||||
// let newText = text.replaceAll(`[[${oldName}]]`, `[[${newName}]]`);
|
||||
let newText = renderToText(mdTree);
|
||||
if (text !== newText) {
|
||||
console.log("Changes made, saving...");
|
||||
await writePage(pageToUpdate, newText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type BackLink = {
|
||||
page: string;
|
||||
pos: number;
|
||||
};
|
||||
|
||||
async function getBackLinks(pageName: string): Promise<BackLink[]> {
|
||||
let allBackLinks = await queryPrefix(`pl:${pageName}:`);
|
||||
let pagesToUpdate: BackLink[] = [];
|
||||
for (let { key, value } of allBackLinks) {
|
||||
let keyParts = key.split(":");
|
||||
pagesToUpdate.push({
|
||||
page: value,
|
||||
pos: +keyParts[keyParts.length - 1],
|
||||
});
|
||||
}
|
||||
return pagesToUpdate;
|
||||
}
|
||||
|
||||
export async function reindexCommand() {
|
||||
await flashNotification("Reindexing...");
|
||||
await invokeFunction("server", "reindexSpace");
|
||||
await flashNotification("Reindexing done");
|
||||
}
|
||||
|
||||
// Completion
|
||||
export async function pageComplete() {
|
||||
let prefix = await matchBefore("\\[\\[[^\\]@:]*");
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
let allPages = await listPages();
|
||||
return {
|
||||
from: prefix.from + 2,
|
||||
options: allPages.map((pageMeta) => ({
|
||||
label: pageMeta.name,
|
||||
type: "page",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Server functions
|
||||
export async function reindexSpace() {
|
||||
console.log("Clearing page index...");
|
||||
await clearPageIndexSyscall();
|
||||
console.log("Listing all pages");
|
||||
let pages = await listPages();
|
||||
for (let { name } of pages) {
|
||||
console.log("Indexing", name);
|
||||
const { text } = await readPage(name);
|
||||
let parsed = await parseMarkdown(text);
|
||||
await dispatch("page:index", {
|
||||
name,
|
||||
tree: parsed,
|
||||
});
|
||||
}
|
||||
console.log("Indexing completed!");
|
||||
}
|
||||
|
||||
export async function clearPageIndex(page: string) {
|
||||
console.log("Clearing page index for page", page);
|
||||
await clearPageIndexForPage(page);
|
||||
}
|
||||
|
||||
export async function parseIndexTextRepublish({ name, text }: IndexEvent) {
|
||||
await dispatch("page:index", {
|
||||
name,
|
||||
tree: await parseMarkdown(text),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { dispatch } from "../../syscall/plugos-syscall/event.ts";
|
||||
import { Manifest } from "../../common/manifest.ts";
|
||||
import {
|
||||
flashNotification,
|
||||
save,
|
||||
} from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import {
|
||||
deleteAttachment,
|
||||
listPlugs,
|
||||
writeAttachment,
|
||||
} from "../../syscall/silverbullet-syscall/space.ts";
|
||||
import {
|
||||
invokeFunction,
|
||||
reloadPlugs,
|
||||
} from "../../syscall/silverbullet-syscall/system.ts";
|
||||
|
||||
import { readYamlPage } from "../lib/yaml_page.ts";
|
||||
|
||||
export async function updatePlugsCommand() {
|
||||
await save();
|
||||
flashNotification("Updating plugs...");
|
||||
try {
|
||||
await invokeFunction("server", "updatePlugs");
|
||||
flashNotification("And... done!");
|
||||
await reloadPlugs();
|
||||
} catch (e: any) {
|
||||
flashNotification("Error updating plugs: " + e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePlugs() {
|
||||
let plugList: string[] = [];
|
||||
try {
|
||||
const plugListRead: any[] = await readYamlPage("PLUGS");
|
||||
plugList = plugListRead.filter((plug) => typeof plug === "string");
|
||||
if (plugList.length !== plugListRead.length) {
|
||||
throw new Error(
|
||||
`Some of the plugs were not in a yaml list format, they were ignored`
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new Error(`Error processing PLUGS: ${e.message}`);
|
||||
}
|
||||
console.log("Plug YAML", plugList);
|
||||
let allPlugNames: string[] = [];
|
||||
for (let plugUri of plugList) {
|
||||
let [protocol, ...rest] = plugUri.split(":");
|
||||
let manifests = await dispatch(`get-plug:${protocol}`, rest.join(":"));
|
||||
if (manifests.length === 0) {
|
||||
console.error("Could not resolve plug", plugUri);
|
||||
}
|
||||
// console.log("Got manifests", plugUri, protocol, manifests);
|
||||
let manifest = manifests[0];
|
||||
allPlugNames.push(manifest.name);
|
||||
// console.log("Writing", `_plug/${manifest.name}`);
|
||||
await writeAttachment(
|
||||
`_plug/${manifest.name}.plug.json`,
|
||||
"string",
|
||||
JSON.stringify(manifest)
|
||||
);
|
||||
}
|
||||
|
||||
// And delete extra ones
|
||||
for (let existingPlug of await listPlugs()) {
|
||||
let plugName = existingPlug.substring(
|
||||
"_plug/".length,
|
||||
existingPlug.length - ".plug.json".length
|
||||
);
|
||||
console.log("Considering", plugName);
|
||||
if (!allPlugNames.includes(plugName)) {
|
||||
console.log("Removing plug", plugName);
|
||||
await deleteAttachment(existingPlug);
|
||||
}
|
||||
}
|
||||
await reloadPlugs();
|
||||
}
|
||||
|
||||
export async function getPlugHTTPS(url: string): Promise<Manifest> {
|
||||
let fullUrl = `https:${url}`;
|
||||
console.log("Now fetching plug manifest from", fullUrl);
|
||||
let req = await fetch(fullUrl);
|
||||
if (req.status !== 200) {
|
||||
throw new Error(`Could not fetch plug manifest from ${fullUrl}`);
|
||||
}
|
||||
let json = await req.json();
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function getPlugGithub(identifier: string): Promise<Manifest> {
|
||||
let [owner, repo, path] = identifier.split("/");
|
||||
let [repoClean, branch] = repo.split("@");
|
||||
if (!branch) {
|
||||
branch = "main"; // or "master"?
|
||||
}
|
||||
return getPlugHTTPS(
|
||||
`//raw.githubusercontent.com/${owner}/${repoClean}/${branch}/${path}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPlugGithubRelease(
|
||||
identifier: string
|
||||
): Promise<Manifest> {
|
||||
let [owner, repo, version] = identifier.split("/");
|
||||
if (!version || version === "latest") {
|
||||
console.log("fetching the latest version");
|
||||
const req = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases/latest`
|
||||
);
|
||||
if (req.status !== 200) {
|
||||
throw new Error(
|
||||
`Could not fetch latest relase manifest from ${identifier}}`
|
||||
);
|
||||
}
|
||||
const result = await req.json();
|
||||
version = result.name;
|
||||
}
|
||||
const finalUrl = `//github.com/${owner}/${repo}/releases/download/${version}/${repo}.plug.json`;
|
||||
return getPlugHTTPS(finalUrl);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
fullTextDelete,
|
||||
fullTextIndex,
|
||||
fullTextSearch,
|
||||
} from "../../syscall/plugos-syscall/fulltext.ts";
|
||||
import { renderToText } from "../../common/tree.ts";
|
||||
import { PageMeta } from "../../common/types.ts";
|
||||
import { queryPrefix } from "../../syscall/silverbullet-syscall/index.ts";
|
||||
import { navigate, prompt } from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import { IndexTreeEvent } from "../../web/app_event.ts";
|
||||
import { applyQuery, QueryProviderEvent } from "../query/engine.ts";
|
||||
import { removeQueries } from "../query/util.ts";
|
||||
|
||||
const searchPrefix = "🔍 ";
|
||||
|
||||
export async function index(data: IndexTreeEvent) {
|
||||
removeQueries(data.tree);
|
||||
let cleanText = renderToText(data.tree);
|
||||
await fullTextIndex(data.name, cleanText);
|
||||
}
|
||||
|
||||
export async function unindex(pageName: string) {
|
||||
await fullTextDelete(pageName);
|
||||
}
|
||||
|
||||
export async function queryProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
let phraseFilter = query.filter.find((f) => f.prop === "phrase");
|
||||
if (!phraseFilter) {
|
||||
throw Error("No 'phrase' filter specified, this is mandatory");
|
||||
}
|
||||
let results = await fullTextSearch(phraseFilter.value, 100);
|
||||
|
||||
let allPageMap: Map<string, any> = new Map(
|
||||
results.map((r: any) => [r.name, r])
|
||||
);
|
||||
for (let { page, value } of await queryPrefix("meta:")) {
|
||||
let p = allPageMap.get(page);
|
||||
if (p) {
|
||||
for (let [k, v] of Object.entries(value)) {
|
||||
p[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the "phrase" filter
|
||||
query.filter.splice(query.filter.indexOf(phraseFilter), 1);
|
||||
|
||||
results = applyQuery(query, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function searchCommand() {
|
||||
let phrase = await prompt("Search for: ");
|
||||
if (phrase) {
|
||||
await navigate(`${searchPrefix}${phrase}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPageSearch(
|
||||
name: string
|
||||
): Promise<{ text: string; meta: PageMeta }> {
|
||||
let phrase = name.substring(searchPrefix.length);
|
||||
let results = await fullTextSearch(phrase, 100);
|
||||
const text = `# Search results for "${phrase}"\n${results
|
||||
.map((r: any) => `* [[${r.name}]] (score: ${r.rank})`)
|
||||
.join("\n")}
|
||||
`;
|
||||
return {
|
||||
text: text,
|
||||
meta: {
|
||||
name,
|
||||
lastModified: 0,
|
||||
perm: "ro",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPageMetaSearch(name: string): Promise<PageMeta> {
|
||||
return {
|
||||
name,
|
||||
lastModified: 0,
|
||||
perm: "ro",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
flashNotification,
|
||||
getText,
|
||||
} from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import { listPages } from "../../syscall/silverbullet-syscall/space.ts";
|
||||
|
||||
function countWords(str: string): number {
|
||||
const matches = str.match(/[\w\d\'-]+/gi);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
function readingTime(wordCount: number): number {
|
||||
// 225 is average word reading speed for adults
|
||||
return Math.ceil(wordCount / 225);
|
||||
}
|
||||
|
||||
export async function statsCommand() {
|
||||
const text = await getText();
|
||||
const allPages = await listPages();
|
||||
const wordCount = countWords(text);
|
||||
const time = readingTime(wordCount);
|
||||
await flashNotification(
|
||||
`${wordCount} words; ${time} minutes read; ${allPages.length} total pages in space.`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { collectNodesOfType } from "../../common/tree.ts";
|
||||
import {
|
||||
batchSet,
|
||||
queryPrefix,
|
||||
} from "../../syscall/silverbullet-syscall/index.ts";
|
||||
import { matchBefore } from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import type { IndexTreeEvent } from "../../web/app_event.ts";
|
||||
import { applyQuery, QueryProviderEvent } from "../query/engine.ts";
|
||||
import { removeQueries } from "../query/util.ts";
|
||||
|
||||
// Key space
|
||||
// tag:TAG => true (for completion)
|
||||
|
||||
export async function indexTags({ name, tree }: IndexTreeEvent) {
|
||||
removeQueries(tree);
|
||||
let allTags = new Set<string>();
|
||||
collectNodesOfType(tree, "Hashtag").forEach((n) => {
|
||||
allTags.add(n.children![0].text!);
|
||||
});
|
||||
batchSet(
|
||||
name,
|
||||
[...allTags].map((t) => ({ key: `tag:${t}`, value: t }))
|
||||
);
|
||||
}
|
||||
|
||||
export async function tagComplete() {
|
||||
let prefix = await matchBefore("#[^#\\s]+");
|
||||
// console.log("Running tag complete", prefix);
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
let allTags = await queryPrefix(`tag:${prefix.text}`);
|
||||
return {
|
||||
from: prefix.from,
|
||||
options: allTags.map((tag) => ({
|
||||
label: tag.value,
|
||||
type: "tag",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
type Tag = {
|
||||
name: string;
|
||||
freq: number;
|
||||
};
|
||||
|
||||
export async function tagProvider({ query }: QueryProviderEvent) {
|
||||
let allTags = new Map<string, number>();
|
||||
for (let { value } of await queryPrefix("tag:")) {
|
||||
let currentFreq = allTags.get(value);
|
||||
if (!currentFreq) {
|
||||
currentFreq = 0;
|
||||
}
|
||||
allTags.set(value, currentFreq + 1);
|
||||
}
|
||||
return applyQuery(
|
||||
query,
|
||||
[...allTags.entries()].map(([name, freq]) => ({
|
||||
name,
|
||||
freq,
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
getPageMeta,
|
||||
listPages,
|
||||
readPage,
|
||||
writePage,
|
||||
} from "$sb/silverbullet-syscall/space.ts";
|
||||
import {
|
||||
filterBox,
|
||||
getCurrentPage,
|
||||
getCursor,
|
||||
insertAtCursor,
|
||||
moveCursor,
|
||||
navigate,
|
||||
prompt,
|
||||
} from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import { parseMarkdown } from "../../syscall/silverbullet-syscall/markdown.ts";
|
||||
import { extractMeta } from "../query/data.ts";
|
||||
import { renderToText } from "../../common/tree.ts";
|
||||
import { niceDate } from "./dates.ts";
|
||||
import { readSettings } from "../lib/settings_page.ts";
|
||||
|
||||
export async function instantiateTemplateCommand() {
|
||||
const allPages = await listPages();
|
||||
const { pageTemplatePrefix } = await readSettings({
|
||||
pageTemplatePrefix: "template/page/",
|
||||
});
|
||||
|
||||
const selectedTemplate = await filterBox(
|
||||
"Template",
|
||||
allPages
|
||||
.filter((pageMeta) => pageMeta.name.startsWith(pageTemplatePrefix))
|
||||
.map((pageMeta) => ({
|
||||
...pageMeta,
|
||||
name: pageMeta.name.slice(pageTemplatePrefix.length),
|
||||
})),
|
||||
`Select the template to create a new page from (listing any page starting with <tt>${pageTemplatePrefix}</tt>)`,
|
||||
);
|
||||
|
||||
if (!selectedTemplate) {
|
||||
return;
|
||||
}
|
||||
console.log("Selected template", selectedTemplate);
|
||||
|
||||
const { text } = await readPage(
|
||||
`${pageTemplatePrefix}${selectedTemplate.name}`,
|
||||
);
|
||||
|
||||
const parseTree = await parseMarkdown(text);
|
||||
const additionalPageMeta = extractMeta(parseTree, [
|
||||
"$name",
|
||||
"$disableDirectives",
|
||||
]);
|
||||
|
||||
const pageName = await prompt("Name of new page", additionalPageMeta.$name);
|
||||
if (!pageName) {
|
||||
return;
|
||||
}
|
||||
const pageText = replaceTemplateVars(renderToText(parseTree), pageName);
|
||||
await writePage(pageName, pageText);
|
||||
await navigate(pageName);
|
||||
}
|
||||
|
||||
export async function insertSnippet() {
|
||||
let allPages = await listPages();
|
||||
let { snippetPrefix } = await readSettings({
|
||||
snippetPrefix: "snippet/",
|
||||
});
|
||||
let cursorPos = await getCursor();
|
||||
let page = await getCurrentPage();
|
||||
let allSnippets = allPages
|
||||
.filter((pageMeta) => pageMeta.name.startsWith(snippetPrefix))
|
||||
.map((pageMeta) => ({
|
||||
...pageMeta,
|
||||
name: pageMeta.name.slice(snippetPrefix.length),
|
||||
}));
|
||||
|
||||
let selectedSnippet = await filterBox(
|
||||
"Snippet",
|
||||
allSnippets,
|
||||
`Select the snippet to insert (listing any page starting with <tt>${snippetPrefix}</tt>)`,
|
||||
);
|
||||
|
||||
if (!selectedSnippet) {
|
||||
return;
|
||||
}
|
||||
let { text } = await readPage(`${snippetPrefix}${selectedSnippet.name}`);
|
||||
|
||||
let templateText = replaceTemplateVars(text, page);
|
||||
let carretPos = templateText.indexOf("|^|");
|
||||
templateText = templateText.replace("|^|", "");
|
||||
templateText = replaceTemplateVars(templateText, page);
|
||||
await insertAtCursor(templateText);
|
||||
if (carretPos !== -1) {
|
||||
await moveCursor(cursorPos + carretPos);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This should probably be replaced with handlebards somehow?
|
||||
export function replaceTemplateVars(s: string, pageName: string): string {
|
||||
return s.replaceAll(/\{\{([^\}]+)\}\}/g, (match, v) => {
|
||||
switch (v) {
|
||||
case "today":
|
||||
return niceDate(new Date());
|
||||
case "tomorrow":
|
||||
let tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return niceDate(tomorrow);
|
||||
case "yesterday":
|
||||
let yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return niceDate(yesterday);
|
||||
case "lastWeek":
|
||||
let lastWeek = new Date();
|
||||
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||
return niceDate(lastWeek);
|
||||
case "page":
|
||||
return pageName;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
export async function quickNoteCommand() {
|
||||
let { quickNotePrefix } = await readSettings({
|
||||
quickNotePrefix: "📥 ",
|
||||
});
|
||||
let isoDate = new Date().toISOString();
|
||||
let [date, time] = isoDate.split("T");
|
||||
time = time.split(".")[0];
|
||||
let pageName = `${quickNotePrefix}${date} ${time}`;
|
||||
await navigate(pageName);
|
||||
}
|
||||
|
||||
export async function dailyNoteCommand() {
|
||||
let { dailyNoteTemplate, dailyNotePrefix } = await readSettings({
|
||||
dailyNoteTemplate: "template/page/Daily Note",
|
||||
dailyNotePrefix: "📅 ",
|
||||
});
|
||||
let dailyNoteTemplateText = "";
|
||||
try {
|
||||
let { text } = await readPage(dailyNoteTemplate);
|
||||
dailyNoteTemplateText = text;
|
||||
} catch {
|
||||
console.warn(`No daily note template found at ${dailyNoteTemplate}`);
|
||||
}
|
||||
let date = niceDate(new Date());
|
||||
let pageName = `${dailyNotePrefix}${date}`;
|
||||
if (dailyNoteTemplateText) {
|
||||
try {
|
||||
await getPageMeta(pageName);
|
||||
} catch {
|
||||
// Doesn't exist, let's create
|
||||
await writePage(
|
||||
pageName,
|
||||
replaceTemplateVars(dailyNoteTemplateText, pageName),
|
||||
);
|
||||
}
|
||||
await navigate(pageName);
|
||||
} else {
|
||||
await navigate(pageName);
|
||||
}
|
||||
}
|
||||
|
||||
export async function insertTemplateText(cmdDef: any) {
|
||||
let cursorPos = await getCursor();
|
||||
let page = await getCurrentPage();
|
||||
let templateText: string = cmdDef.value;
|
||||
let carretPos = templateText.indexOf("|^|");
|
||||
templateText = templateText.replace("|^|", "");
|
||||
templateText = replaceTemplateVars(templateText, page);
|
||||
await insertAtCursor(templateText);
|
||||
if (carretPos !== -1) {
|
||||
await moveCursor(cursorPos + carretPos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
getSelection,
|
||||
getText,
|
||||
insertAtCursor,
|
||||
moveCursor,
|
||||
replaceRange,
|
||||
setSelection,
|
||||
} from "$sb/silverbullet-syscall/editor.ts";
|
||||
|
||||
export async function quoteSelection() {
|
||||
let text = await getText();
|
||||
const selection = await getSelection();
|
||||
let from = selection.from;
|
||||
while (from >= 0 && text[from] !== "\n") {
|
||||
from--;
|
||||
}
|
||||
from++;
|
||||
if (text[from] === ">" && text[from + 1] === " ") {
|
||||
// Already quoted, we have to unquote
|
||||
text = text.slice(from + 2, selection.to);
|
||||
text = text.replaceAll("\n> ", "\n");
|
||||
} else {
|
||||
text = text.slice(from, selection.to);
|
||||
text = `> ${text.replaceAll("\n", "\n> ")}`;
|
||||
}
|
||||
await replaceRange(from, selection.to, text);
|
||||
}
|
||||
|
||||
export async function listifySelection() {
|
||||
let text = await getText();
|
||||
const selection = await getSelection();
|
||||
let from = selection.from;
|
||||
while (from >= 0 && text[from] !== "\n") {
|
||||
from--;
|
||||
}
|
||||
from++;
|
||||
text = text.slice(from, selection.to);
|
||||
text = `* ${text.replaceAll(/\n(?!\n)/g, "\n* ")}`;
|
||||
await replaceRange(from, selection.to, text);
|
||||
}
|
||||
|
||||
export async function numberListifySelection() {
|
||||
let text = await getText();
|
||||
const selection = await getSelection();
|
||||
let from = selection.from;
|
||||
while (from >= 0 && text[from] !== "\n") {
|
||||
from--;
|
||||
}
|
||||
from++;
|
||||
text = text.slice(from, selection.to);
|
||||
let counter = 1;
|
||||
text = `1. ${
|
||||
text.replaceAll(/\n(?!\n)/g, () => {
|
||||
counter++;
|
||||
return `\n${counter}. `;
|
||||
})
|
||||
}`;
|
||||
await replaceRange(from, selection.to, text);
|
||||
}
|
||||
|
||||
export async function linkSelection() {
|
||||
const text = await getText();
|
||||
const selection = await getSelection();
|
||||
const textSelection = text.slice(selection.from, selection.to);
|
||||
let linkedText = `[]()`;
|
||||
let pos = 1;
|
||||
if (textSelection.length > 0) {
|
||||
try {
|
||||
new URL(textSelection);
|
||||
linkedText = `[](${textSelection})`;
|
||||
} catch {
|
||||
linkedText = `[${textSelection}]()`;
|
||||
pos = linkedText.length - 1;
|
||||
}
|
||||
}
|
||||
await replaceRange(selection.from, selection.to, linkedText);
|
||||
await moveCursor(selection.from + pos);
|
||||
}
|
||||
|
||||
export function wrapSelection(cmdDef: any) {
|
||||
return insertMarker(cmdDef.wrapper);
|
||||
}
|
||||
|
||||
async function insertMarker(marker: string) {
|
||||
let text = await getText();
|
||||
const selection = await getSelection();
|
||||
if (selection.from === selection.to) {
|
||||
// empty selection
|
||||
if (markerAt(selection.from)) {
|
||||
// Already there, skipping ahead
|
||||
await moveCursor(selection.from + marker.length);
|
||||
} else {
|
||||
// Not there, inserting
|
||||
await insertAtCursor(marker + marker);
|
||||
await moveCursor(selection.from + marker.length);
|
||||
}
|
||||
} else {
|
||||
let from = selection.from;
|
||||
let to = selection.to;
|
||||
let hasMarker = markerAt(from);
|
||||
if (!markerAt(from)) {
|
||||
// Maybe just before the cursor? We'll accept that
|
||||
from = selection.from - marker.length;
|
||||
to = selection.to + marker.length;
|
||||
hasMarker = markerAt(from);
|
||||
}
|
||||
|
||||
if (!hasMarker) {
|
||||
// Adding
|
||||
await replaceRange(
|
||||
selection.from,
|
||||
selection.to,
|
||||
marker + text.slice(selection.from, selection.to) + marker,
|
||||
);
|
||||
await setSelection(
|
||||
selection.from + marker.length,
|
||||
selection.to + marker.length,
|
||||
);
|
||||
} else {
|
||||
// Removing
|
||||
await replaceRange(
|
||||
from,
|
||||
to,
|
||||
text.substring(from + marker.length, to - marker.length),
|
||||
);
|
||||
await setSelection(from, to - marker.length * 2);
|
||||
}
|
||||
}
|
||||
|
||||
function markerAt(pos: number) {
|
||||
for (var i = 0; i < marker.length; i++) {
|
||||
if (text[pos + i] !== marker[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
emoji-data.txt
|
||||
@@ -0,0 +1,4 @@
|
||||
build:
|
||||
curl https://unicode.org/Public/emoji/14.0/emoji-test.txt > emoji-data.txt
|
||||
node build.js
|
||||
rm emoji-data.txt
|
||||
@@ -0,0 +1,17 @@
|
||||
// Generates emoji.json from emoji-data.txt
|
||||
const { readFileSync, writeFileSync } = require("fs");
|
||||
|
||||
const emojiRe = /#\s([^\s]+)\s+E[^\s]+\s+(.+)$/;
|
||||
|
||||
let text = readFileSync("emoji-data.txt", "utf-8");
|
||||
const lines = text.split("\n").filter((line) => !line.startsWith("#"));
|
||||
|
||||
let emoji = [];
|
||||
for (const line of lines) {
|
||||
let match = emojiRe.exec(line);
|
||||
if (match) {
|
||||
emoji.push([match[1], match[2].toLowerCase().replaceAll(/\W+/g, "_")]);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync("emoji.json", JSON.stringify(emoji));
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
name: emoji
|
||||
functions:
|
||||
emojiCompleter:
|
||||
path: "./emoji.ts:emojiCompleter"
|
||||
events:
|
||||
- page:complete
|
||||
@@ -0,0 +1,23 @@
|
||||
import emojis from "./emoji.json" assert { type: "json" };
|
||||
import { matchBefore } from "$sb/silverbullet-syscall/editor.ts";
|
||||
|
||||
export async function emojiCompleter() {
|
||||
const prefix = await matchBefore(":[\\w]+");
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
const textPrefix = prefix.text.substring(1); // Cut off the initial :
|
||||
const filteredEmoji = emojis.filter(([_, shortcode]) =>
|
||||
shortcode.includes(textPrefix)
|
||||
);
|
||||
|
||||
return {
|
||||
from: prefix.from,
|
||||
filter: false,
|
||||
options: filteredEmoji.map(([emoji, shortcode]) => ({
|
||||
detail: shortcode,
|
||||
label: emoji,
|
||||
type: "emoji",
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
name: global
|
||||
dependencies:
|
||||
"https://esm.sh/handlebars": "https://esm.sh/handlebars@4.7.7"
|
||||
"https://deno.land/std/encoding/yaml.ts": "https://deno.land/std@0.158.0/encoding/yaml.ts"
|
||||
"https://esm.sh/@lezer/lr": "https://esm.sh/@lezer/lr@1.2.3"
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readYamlPage } from "./yaml_page.ts";
|
||||
|
||||
// Read SECRETS page and retrieve specific set of secret keys
|
||||
// Note: in this implementation there's no encryption employed at all so it's just a matter
|
||||
// of not decising this SECRETS page to other places
|
||||
export async function readSecrets(keys: string[]): Promise<any[]> {
|
||||
try {
|
||||
let allSecrets = await readYamlPage("SECRETS", ["yaml", "secrets"]);
|
||||
let collectedSecrets: any[] = [];
|
||||
for (let key of keys) {
|
||||
let secret = allSecrets[key];
|
||||
if (secret) {
|
||||
collectedSecrets.push(secret);
|
||||
} else {
|
||||
throw new Error(`No such secret: ${key}`);
|
||||
}
|
||||
}
|
||||
return collectedSecrets;
|
||||
} catch (e: any) {
|
||||
if (e.message === "Page not found") {
|
||||
throw new Error(`No such secret: ${keys[0]}`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readYamlPage } from "./yaml_page.ts";
|
||||
import { notifyUser } from "./util.ts";
|
||||
import * as YAML from "yaml";
|
||||
|
||||
import { writePage } from "../../syscall/silverbullet-syscall/space.ts";
|
||||
|
||||
/**
|
||||
* Convenience function to read a specific set of settings from the `SETTINGS` page as well as default values
|
||||
* in case they are not specified.
|
||||
* Example: `await readSettings({showPreview: false})` will return an object like `{showPreview: false}` (or `true`)
|
||||
* in case this setting is specifically set in the `SETTINGS` page.
|
||||
*
|
||||
* @param settings object with settings to fetch and their default values
|
||||
* @returns an object with the same shape as `settings` but with non-default values override based on `SETTINGS`
|
||||
*/
|
||||
|
||||
const SETTINGS_PAGE = "SETTINGS";
|
||||
|
||||
export async function readSettings<T extends object>(settings: T): Promise<T> {
|
||||
try {
|
||||
let allSettings = (await readYamlPage(SETTINGS_PAGE, ["yaml"])) || {};
|
||||
// TODO: I'm sure there's a better way to type this than "any"
|
||||
let collectedSettings: any = {};
|
||||
for (let [key, defaultVal] of Object.entries(settings)) {
|
||||
if (key in allSettings) {
|
||||
collectedSettings[key] = allSettings[key];
|
||||
} else {
|
||||
collectedSettings[key] = defaultVal;
|
||||
}
|
||||
}
|
||||
return collectedSettings as T;
|
||||
} catch (e: any) {
|
||||
if (e.message === "Page not found") {
|
||||
// No settings yet, return default values for all
|
||||
return settings;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to write a specific set of settings from the `SETTINGS` page.
|
||||
* If the SETTiNGS page doesn't exist it will create it.
|
||||
* @param settings
|
||||
*/
|
||||
export async function writeSettings<T extends object>(settings: T) {
|
||||
let readSettings = {};
|
||||
try {
|
||||
readSettings = (await readYamlPage(SETTINGS_PAGE, ["yaml"])) || {};
|
||||
} catch (e: any) {
|
||||
await notifyUser("Creating a new SETTINGS page...", "info");
|
||||
}
|
||||
const writeSettings = { ...readSettings, ...settings };
|
||||
// const doc = new YAML.Document();
|
||||
// doc.contents = writeSettings;
|
||||
const contents =
|
||||
`This page contains settings for configuring SilverBullet and its Plugs.\nAny changes outside of the yaml block will be overwritten.\n\`\`\`yaml\n${
|
||||
YAML.stringify(
|
||||
writeSettings,
|
||||
)
|
||||
}\n\`\`\``; // might need \r\n for windows?
|
||||
await writePage(SETTINGS_PAGE, contents);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { flashNotification } from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
|
||||
export async function replaceAsync(
|
||||
str: string,
|
||||
regex: RegExp,
|
||||
asyncFn: (match: string, ...args: any[]) => Promise<string>
|
||||
) {
|
||||
const promises: Promise<string>[] = [];
|
||||
str.replace(regex, (match: string, ...args: any[]): string => {
|
||||
const promise = asyncFn(match, ...args);
|
||||
promises.push(promise);
|
||||
return "";
|
||||
});
|
||||
const data = await Promise.all(promises);
|
||||
return str.replace(regex, () => data.shift()!);
|
||||
}
|
||||
|
||||
export function isServer() {
|
||||
return (
|
||||
typeof window === "undefined" || typeof window.document === "undefined"
|
||||
); // if something defines window the same way as the browser, this will fail.
|
||||
}
|
||||
|
||||
// this helps keep if's condition as positive
|
||||
export function isBrowser() {
|
||||
return !isServer();
|
||||
}
|
||||
|
||||
export async function notifyUser(message: string, type?: "info" | "error") {
|
||||
if (isBrowser()) {
|
||||
return flashNotification(message, type);
|
||||
}
|
||||
const log = type === "error" ? console.error : console.log;
|
||||
log(message); // we should end up sending the message to the user, users dont read logs.
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { findNodeOfType, traverseTree } from "../../common/tree.ts";
|
||||
import { parseMarkdown } from "../../syscall/silverbullet-syscall/markdown.ts";
|
||||
import {
|
||||
readPage,
|
||||
writePage,
|
||||
} from "../../syscall/silverbullet-syscall/space.ts";
|
||||
import * as YAML from "yaml";
|
||||
|
||||
export async function readYamlPage(
|
||||
pageName: string,
|
||||
allowedLanguages = ["yaml"],
|
||||
): Promise<any> {
|
||||
const { text } = await readPage(pageName);
|
||||
let tree = await parseMarkdown(text);
|
||||
let data: any = {};
|
||||
|
||||
traverseTree(tree, (t): boolean => {
|
||||
// Find a fenced code block
|
||||
if (t.type !== "FencedCode") {
|
||||
return false;
|
||||
}
|
||||
let codeInfoNode = findNodeOfType(t, "CodeInfo");
|
||||
if (!codeInfoNode) {
|
||||
return false;
|
||||
}
|
||||
if (!allowedLanguages.includes(codeInfoNode.children![0].text!)) {
|
||||
return false;
|
||||
}
|
||||
let codeTextNode = findNodeOfType(t, "CodeText");
|
||||
if (!codeTextNode) {
|
||||
// Honestly, this shouldn't happen
|
||||
return false;
|
||||
}
|
||||
let codeText = codeTextNode.children![0].text!;
|
||||
try {
|
||||
data = YAML.parse(codeText);
|
||||
} catch (e: any) {
|
||||
console.error("YAML Page parser error", e);
|
||||
throw new Error(`YAML Error: ${e.message}`);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function writeYamlPage(
|
||||
pageName: string,
|
||||
data: any,
|
||||
): Promise<void> {
|
||||
const text = YAML.stringify(data);
|
||||
await writePage(pageName, "```yaml\n" + text + "\n```");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
name: markdown
|
||||
functions:
|
||||
toggle:
|
||||
path: "./markdown.ts:togglePreview"
|
||||
command:
|
||||
name: "Markdown Preview: Toggle"
|
||||
key: Ctrl-p
|
||||
mac: Cmd-p
|
||||
preview:
|
||||
path: "./preview.ts:updateMarkdownPreview"
|
||||
env: client
|
||||
events:
|
||||
- plug:load
|
||||
- editor:updated
|
||||
- editor:pageLoaded
|
||||
- editor:pageReloaded
|
||||
@@ -0,0 +1,20 @@
|
||||
import { hideLhs, hideRhs } from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import { invokeFunction } from "../../syscall/silverbullet-syscall/system.ts";
|
||||
import * as clientStore from "../../syscall/silverbullet-syscall/clientStore.ts";
|
||||
import { readSettings } from "../lib/settings_page.ts";
|
||||
|
||||
export async function togglePreview() {
|
||||
let currentValue = !!(await clientStore.get("enableMarkdownPreview"));
|
||||
await clientStore.set("enableMarkdownPreview", !currentValue);
|
||||
if (!currentValue) {
|
||||
await invokeFunction("client", "preview");
|
||||
} else {
|
||||
await hideMarkdownPreview();
|
||||
}
|
||||
}
|
||||
|
||||
async function hideMarkdownPreview() {
|
||||
const setting = await readSettings({ previewOnRHS: true });
|
||||
const hide = setting.previewOnRHS ? hideRhs : hideLhs;
|
||||
await hide();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import MarkdownIt from "https://esm.sh/markdown-it@13.0.1";
|
||||
import {
|
||||
getText,
|
||||
showPanel,
|
||||
} from "../../syscall/silverbullet-syscall/editor.ts";
|
||||
import * as clientStore from "../../syscall/silverbullet-syscall/clientStore.ts";
|
||||
import { cleanMarkdown } from "./util.ts";
|
||||
|
||||
const css = `
|
||||
<style>
|
||||
body {
|
||||
font-family: georgia,times,serif;
|
||||
font-size: 14pt;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
thead tr {
|
||||
background-color: #333;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
tbody tr:nth-of-type(even) {
|
||||
background-color: #f3f3f3;
|
||||
}
|
||||
|
||||
a[href] {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 1px solid #333;
|
||||
margin-left: 2px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1em 0 1em 0;
|
||||
text-align: center;
|
||||
border-color: #777;
|
||||
border-width: 0;
|
||||
border-style: dotted;
|
||||
}
|
||||
|
||||
hr:after {
|
||||
content: "···";
|
||||
letter-spacing: 1em;
|
||||
}
|
||||
|
||||
</style>
|
||||
`;
|
||||
|
||||
import taskLists from "https://esm.sh/markdown-it-task-lists@2.1.1";
|
||||
|
||||
const md = new MarkdownIt({
|
||||
linkify: true,
|
||||
html: false,
|
||||
typographer: true,
|
||||
}).use(taskLists);
|
||||
|
||||
export async function updateMarkdownPreview() {
|
||||
if (!(await clientStore.get("enableMarkdownPreview"))) {
|
||||
return;
|
||||
}
|
||||
let text = await getText();
|
||||
let cleanMd = await cleanMarkdown(text);
|
||||
await showPanel(
|
||||
"rhs",
|
||||
2,
|
||||
`<html><head>${css}</head><body>${md.render(cleanMd)}</body></html>`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
findNodeOfType,
|
||||
renderToText,
|
||||
replaceNodesMatching,
|
||||
} from "../../common/tree.ts";
|
||||
import { parseMarkdown } from "../../syscall/silverbullet-syscall/markdown.ts";
|
||||
|
||||
export function encodePageUrl(name: string): string {
|
||||
return name.replaceAll(" ", "_");
|
||||
}
|
||||
|
||||
export async function cleanMarkdown(
|
||||
text: string,
|
||||
validPages?: string[],
|
||||
): Promise<string> {
|
||||
const mdTree = await parseMarkdown(text);
|
||||
replaceNodesMatching(mdTree, (n) => {
|
||||
if (n.type === "WikiLink") {
|
||||
const page = n.children![1].children![0].text!;
|
||||
if (validPages && !validPages.includes(page)) {
|
||||
return {
|
||||
// HACK
|
||||
text: `_${page}_`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
// HACK
|
||||
text: `[${page}](/${encodePageUrl(page)})`,
|
||||
};
|
||||
}
|
||||
// Simply get rid of these
|
||||
if (
|
||||
n.type === "CommentBlock" ||
|
||||
n.type === "Comment" ||
|
||||
n.type === "NamedAnchor"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (n.type === "Hashtag") {
|
||||
return {
|
||||
text: `__${n.children![0].text}__`,
|
||||
};
|
||||
}
|
||||
if (n.type === "URL") {
|
||||
const url = n.children![0].text!;
|
||||
if (url.indexOf("://") === -1) {
|
||||
n.children![0].text = `fs/${url}`;
|
||||
}
|
||||
console.log("Link", url);
|
||||
}
|
||||
if (n.type === "FencedCode") {
|
||||
const codeInfoNode = findNodeOfType(n, "CodeInfo");
|
||||
if (!codeInfoNode) {
|
||||
return;
|
||||
}
|
||||
if (codeInfoNode.children![0].text === "meta") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
return renderToText(mdTree);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
name: plugmd
|
||||
functions:
|
||||
check:
|
||||
path: "./plugmd.ts:checkCommand"
|
||||
command:
|
||||
name: "Plug: Check"
|
||||
mac: "Cmd-Alt-c"
|
||||
key: "Ctrl-Alt-c"
|
||||
compile:
|
||||
path: "./plugmd.ts:compileCommand"
|
||||
command:
|
||||
name: "Plug: Compile"
|
||||
mac: "Cmd-Shift-c"
|
||||
key: "Ctrl-Shift-c"
|
||||
compileJS:
|
||||
path: "./plugmd.ts:compileJS"
|
||||
env: server
|
||||
compileModule:
|
||||
path: "./plugmd.ts:compileModule"
|
||||
env: server
|
||||
getPlugPlugMd:
|
||||
path: "./plugmd.ts:getPlugPlugMd"
|
||||
events:
|
||||
- get-plug:plugmd
|
||||
@@ -0,0 +1,121 @@
|
||||
import { collectNodesOfType, findNodeOfType } from "../../common/tree.ts";
|
||||
import { getText, hideBhs, showBhs } from "$sb/silverbullet-syscall/editor.ts";
|
||||
import { parseMarkdown } from "$sb/silverbullet-syscall/markdown.ts";
|
||||
import { readPage, writePage } from "$sb/silverbullet-syscall/space.ts";
|
||||
import {
|
||||
invokeFunction,
|
||||
reloadPlugs,
|
||||
} from "$sb/silverbullet-syscall/system.ts";
|
||||
import * as YAML from "yaml";
|
||||
|
||||
import type { Manifest } from "../../common/manifest.ts";
|
||||
|
||||
export async function compileCommand() {
|
||||
let text = await getText();
|
||||
try {
|
||||
let manifest = await compileDefinition(text);
|
||||
await writePage(
|
||||
`_plug/${manifest.name}`,
|
||||
JSON.stringify(manifest, null, 2),
|
||||
);
|
||||
console.log("Wrote this plug", manifest);
|
||||
await hideBhs();
|
||||
|
||||
await reloadPlugs();
|
||||
} catch (e: any) {
|
||||
await showBhs(e.message);
|
||||
// console.error("Got this error from compiler", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkCommand() {
|
||||
let text = await getText();
|
||||
try {
|
||||
await compileDefinition(text);
|
||||
await hideBhs();
|
||||
reloadPlugs();
|
||||
} catch (e: any) {
|
||||
await showBhs(e.message);
|
||||
// console.error("Got this error from compiler", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function compileDefinition(text: string): Promise<Manifest> {
|
||||
let tree = await parseMarkdown(text);
|
||||
|
||||
let codeNodes = collectNodesOfType(tree, "FencedCode");
|
||||
let manifest: Manifest | undefined;
|
||||
let code: string | undefined;
|
||||
let language = "js";
|
||||
for (let codeNode of codeNodes) {
|
||||
let codeInfo = findNodeOfType(codeNode, "CodeInfo")!.children![0].text!;
|
||||
let codeText = findNodeOfType(codeNode, "CodeText")!.children![0].text!;
|
||||
if (codeInfo === "yaml") {
|
||||
manifest = YAML.parse(codeText);
|
||||
continue;
|
||||
}
|
||||
if (codeInfo === "typescript" || codeInfo === "ts") {
|
||||
language = "ts";
|
||||
}
|
||||
code = codeText;
|
||||
}
|
||||
|
||||
if (!manifest) {
|
||||
throw new Error("No meta found");
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error("No code found");
|
||||
}
|
||||
|
||||
manifest.dependencies = manifest.dependencies || {};
|
||||
|
||||
for (let [dep, depSpec] of Object.entries(manifest.dependencies)) {
|
||||
let compiled = await invokeFunction("server", "compileModule", depSpec);
|
||||
manifest.dependencies![dep] = compiled;
|
||||
}
|
||||
|
||||
manifest.functions = manifest.functions || {};
|
||||
|
||||
for (let [name, func] of Object.entries(manifest.functions)) {
|
||||
let compiled = await invokeFunction(
|
||||
"server",
|
||||
"compileJS",
|
||||
`file.${language}`,
|
||||
code,
|
||||
name,
|
||||
Object.keys(manifest.dependencies),
|
||||
);
|
||||
func.code = compiled;
|
||||
}
|
||||
|
||||
console.log("Doing the whole manifest thing");
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export async function compileJS(
|
||||
filename: string,
|
||||
code: string,
|
||||
functionName: string,
|
||||
excludeModules: string[],
|
||||
): Promise<string> {
|
||||
// console.log("Compiling JS", filename, excludeModules);
|
||||
return self.syscall(
|
||||
"esbuild.compile",
|
||||
filename,
|
||||
code,
|
||||
functionName,
|
||||
excludeModules,
|
||||
);
|
||||
}
|
||||
|
||||
export async function compileModule(moduleName: string): Promise<string> {
|
||||
return self.syscall("esbuild.compileModule", moduleName);
|
||||
}
|
||||
|
||||
export async function getPlugPlugMd(pageName: string): Promise<Manifest> {
|
||||
let { text } = await readPage(pageName);
|
||||
console.log("Compiling", pageName);
|
||||
return compileDefinition(text);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { listEvents } from "$sb/plugos-syscall/event.ts";
|
||||
import { matchBefore } from "$sb/silverbullet-syscall/editor.ts";
|
||||
|
||||
export async function queryComplete() {
|
||||
const prefix = await matchBefore("#query [\\w\\-_]*");
|
||||
|
||||
if (prefix) {
|
||||
const allEvents = await listEvents();
|
||||
// console.log("All events", allEvents);
|
||||
|
||||
return {
|
||||
from: prefix.from + "#query ".length,
|
||||
options: allEvents
|
||||
.filter((eventName) => eventName.startsWith("query:"))
|
||||
.map((source) => ({
|
||||
label: source.substring("query:".length),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Index key space:
|
||||
// data:page@pos
|
||||
|
||||
import type { IndexTreeEvent } from "../../web/app_event.ts";
|
||||
import {
|
||||
batchSet,
|
||||
queryPrefix,
|
||||
} from "../../syscall/silverbullet-syscall/index.ts";
|
||||
import {
|
||||
addParentPointers,
|
||||
collectNodesOfType,
|
||||
findNodeOfType,
|
||||
ParseTree,
|
||||
replaceNodesMatching,
|
||||
} from "../../common/tree.ts";
|
||||
import type { QueryProviderEvent } from "./engine.ts";
|
||||
import { applyQuery } from "./engine.ts";
|
||||
import { removeQueries } from "./util.ts";
|
||||
import * as YAML from "yaml";
|
||||
|
||||
export async function indexData({ name, tree }: IndexTreeEvent) {
|
||||
let dataObjects: { key: string; value: Object }[] = [];
|
||||
|
||||
removeQueries(tree);
|
||||
|
||||
collectNodesOfType(tree, "FencedCode").forEach((t) => {
|
||||
let codeInfoNode = findNodeOfType(t, "CodeInfo");
|
||||
if (!codeInfoNode) {
|
||||
return;
|
||||
}
|
||||
if (codeInfoNode.children![0].text !== "data") {
|
||||
return;
|
||||
}
|
||||
let codeTextNode = findNodeOfType(t, "CodeText");
|
||||
if (!codeTextNode) {
|
||||
// Honestly, this shouldn't happen
|
||||
return;
|
||||
}
|
||||
let codeText = codeTextNode.children![0].text!;
|
||||
try {
|
||||
// We support multiple YAML documents in one block
|
||||
for (let doc of parseAllDocuments(codeText)) {
|
||||
if (!doc.contents) {
|
||||
continue;
|
||||
}
|
||||
console.log(doc.contents.toJSON());
|
||||
dataObjects.push({
|
||||
key: `data:${name}@${t.from! + doc.range[0]}`,
|
||||
value: doc.contents.toJSON(),
|
||||
});
|
||||
}
|
||||
// console.log("Parsed data", parsedData);
|
||||
} catch (e) {
|
||||
console.error("Could not parse data", codeText, "error:", e);
|
||||
return;
|
||||
}
|
||||
});
|
||||
console.log("Found", dataObjects.length, "data objects");
|
||||
await batchSet(name, dataObjects);
|
||||
}
|
||||
|
||||
export function extractMeta(
|
||||
parseTree: ParseTree,
|
||||
removeKeys: string[] = [],
|
||||
): any {
|
||||
let data: any = {};
|
||||
addParentPointers(parseTree);
|
||||
replaceNodesMatching(parseTree, (t) => {
|
||||
if (t.type === "Hashtag") {
|
||||
// Check if if nested directly into a Paragraph
|
||||
if (t.parent && t.parent.type === "Paragraph") {
|
||||
let tagname = t.children![0].text;
|
||||
if (!data.tags) {
|
||||
data.tags = [];
|
||||
}
|
||||
if (!data.tags.includes(tagname)) {
|
||||
data.tags.push(tagname);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Find a fenced code block
|
||||
if (t.type !== "FencedCode") {
|
||||
return;
|
||||
}
|
||||
let codeInfoNode = findNodeOfType(t, "CodeInfo");
|
||||
if (!codeInfoNode) {
|
||||
return;
|
||||
}
|
||||
if (codeInfoNode.children![0].text !== "meta") {
|
||||
return;
|
||||
}
|
||||
let codeTextNode = findNodeOfType(t, "CodeText");
|
||||
if (!codeTextNode) {
|
||||
// Honestly, this shouldn't happen
|
||||
return;
|
||||
}
|
||||
let codeText = codeTextNode.children![0].text!;
|
||||
data = YAML.parse(codeText);
|
||||
if (removeKeys.length > 0) {
|
||||
let newData = { ...data };
|
||||
for (let key of removeKeys) {
|
||||
delete newData[key];
|
||||
}
|
||||
codeTextNode.children![0].text = YAML.stringify(newData).trim();
|
||||
// If nothing is left, let's just delete this thing
|
||||
if (Object.keys(newData).length === 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function queryProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<any[]> {
|
||||
let allData: any[] = [];
|
||||
for (let { key, page, value } of await queryPrefix("data:")) {
|
||||
let [, pos] = key.split("@");
|
||||
allData.push({
|
||||
...value,
|
||||
page: page,
|
||||
pos: +pos,
|
||||
});
|
||||
}
|
||||
return applyQuery(query, allData);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { assertEquals } from "../../test_deps.ts";
|
||||
import { applyQuery } from "./engine.ts";
|
||||
import { parseQuery } from "./parser.ts";
|
||||
|
||||
Deno.test("Test parser", () => {
|
||||
let parsedBasicQuery = parseQuery(`page`);
|
||||
assertEquals(parsedBasicQuery.table, "page");
|
||||
|
||||
let parsedQuery1 = parseQuery(
|
||||
`task where completed = false and dueDate <= "{{today}}" order by dueDate desc limit 5`,
|
||||
);
|
||||
assertEquals(parsedQuery1.table, "task");
|
||||
assertEquals(parsedQuery1.orderBy, "dueDate");
|
||||
assertEquals(parsedQuery1.orderDesc, true);
|
||||
assertEquals(parsedQuery1.limit, 5);
|
||||
assertEquals(parsedQuery1.filter.length, 2);
|
||||
assertEquals(parsedQuery1.filter[0], {
|
||||
op: "=",
|
||||
prop: "completed",
|
||||
value: false,
|
||||
});
|
||||
assertEquals(parsedQuery1.filter[1], {
|
||||
op: "<=",
|
||||
prop: "dueDate",
|
||||
value: "{{today}}",
|
||||
});
|
||||
|
||||
let parsedQuery2 = parseQuery(`page where name =~ /interview\\/.*/"`);
|
||||
assertEquals(parsedQuery2.table, "page");
|
||||
assertEquals(parsedQuery2.filter.length, 1);
|
||||
assertEquals(parsedQuery2.filter[0], {
|
||||
op: "=~",
|
||||
prop: "name",
|
||||
value: "interview\\/.*",
|
||||
});
|
||||
|
||||
let parsedQuery3 = parseQuery(`page where something != null`);
|
||||
assertEquals(parsedQuery3.table, "page");
|
||||
assertEquals(parsedQuery3.filter.length, 1);
|
||||
assertEquals(parsedQuery3.filter[0], {
|
||||
op: "!=",
|
||||
prop: "something",
|
||||
value: null,
|
||||
});
|
||||
|
||||
assertEquals(parseQuery(`page select name`).select, ["name"]);
|
||||
assertEquals(parseQuery(`page select name, age`).select, [
|
||||
"name",
|
||||
"age",
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
parseQuery(`gh-events where type in ["PushEvent", "somethingElse"]`),
|
||||
{
|
||||
table: "gh-events",
|
||||
filter: [
|
||||
{
|
||||
op: "in",
|
||||
prop: "type",
|
||||
value: ["PushEvent", "somethingElse"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assertEquals(parseQuery(`something render [[template/table]]`), {
|
||||
table: "something",
|
||||
filter: [],
|
||||
render: "template/table",
|
||||
});
|
||||
|
||||
assertEquals(parseQuery(`something render "template/table"`), {
|
||||
table: "something",
|
||||
filter: [],
|
||||
render: "template/table",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("Test applyQuery", () => {
|
||||
let data: any[] = [
|
||||
{ name: "interview/My Interview", lastModified: 1 },
|
||||
{ name: "interview/My Interview 2", lastModified: 2 },
|
||||
{ name: "Pete", age: 38 },
|
||||
{ name: "Angie", age: 28 },
|
||||
];
|
||||
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where name =~ /interview\\/.*/`), data),
|
||||
[
|
||||
{ name: "interview/My Interview", lastModified: 1 },
|
||||
{ name: "interview/My Interview 2", lastModified: 2 },
|
||||
],
|
||||
);
|
||||
assertEquals(
|
||||
applyQuery(
|
||||
parseQuery(`page where name =~ /interview\\/.*/ order by lastModified`),
|
||||
data,
|
||||
),
|
||||
[
|
||||
{ name: "interview/My Interview", lastModified: 1 },
|
||||
{ name: "interview/My Interview 2", lastModified: 2 },
|
||||
],
|
||||
);
|
||||
assertEquals(
|
||||
applyQuery(
|
||||
parseQuery(
|
||||
`page where name =~ /interview\\/.*/ order by lastModified desc`,
|
||||
),
|
||||
data,
|
||||
),
|
||||
[
|
||||
{ name: "interview/My Interview 2", lastModified: 2 },
|
||||
{ name: "interview/My Interview", lastModified: 1 },
|
||||
],
|
||||
);
|
||||
assertEquals(applyQuery(parseQuery(`page where age > 30`), data), [
|
||||
{ name: "Pete", age: 38 },
|
||||
]);
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where age > 28 and age < 38`), data),
|
||||
[],
|
||||
);
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where age > 30 select name`), data),
|
||||
[{ name: "Pete" }],
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where name in ["Pete"] select name`), data),
|
||||
[{ name: "Pete" }],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Test applyQuery with multi value", () => {
|
||||
let data: any[] = [
|
||||
{ name: "Pete", children: ["John", "Angie"] },
|
||||
{ name: "Angie", children: ["Angie"] },
|
||||
{ name: "Steve" },
|
||||
];
|
||||
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where children = "Angie"`), data),
|
||||
[
|
||||
{ name: "Pete", children: ["John", "Angie"] },
|
||||
{ name: "Angie", children: ["Angie"] },
|
||||
],
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
applyQuery(parseQuery(`page where children = ["Angie", "John"]`), data),
|
||||
[
|
||||
{ name: "Pete", children: ["John", "Angie"] },
|
||||
{ name: "Angie", children: ["Angie"] },
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { collectNodesOfType, ParseTree } from "../../common/tree.ts";
|
||||
import Handlebars from "handlebars";
|
||||
import * as YAML from "yaml";
|
||||
|
||||
import { readPage } from "../../syscall/silverbullet-syscall/space.ts";
|
||||
import { niceDate } from "../core/dates.ts";
|
||||
import { ParsedQuery } from "./parser.ts";
|
||||
|
||||
export type QueryProviderEvent = {
|
||||
query: ParsedQuery;
|
||||
pageName: string;
|
||||
};
|
||||
|
||||
export function valueNodeToVal(valNode: ParseTree): any {
|
||||
switch (valNode.type) {
|
||||
case "Number":
|
||||
return +valNode.children![0].text!;
|
||||
case "Bool":
|
||||
return valNode.children![0].text! === "true";
|
||||
case "Null":
|
||||
return null;
|
||||
case "Name":
|
||||
return valNode.children![0].text!;
|
||||
case "Regex":
|
||||
let val = valNode.children![0].text!;
|
||||
return val.substring(1, val.length - 1);
|
||||
case "String":
|
||||
let stringVal = valNode.children![0].text!;
|
||||
return stringVal.substring(1, stringVal.length - 1);
|
||||
case "PageRef":
|
||||
let pageRefVal = valNode.children![0].text!;
|
||||
return pageRefVal.substring(2, pageRefVal.length - 2);
|
||||
case "List":
|
||||
return collectNodesOfType(valNode, "Value").map((t) =>
|
||||
valueNodeToVal(t.children![0])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyQuery<T>(parsedQuery: ParsedQuery, records: T[]): T[] {
|
||||
let resultRecords: any[] = [];
|
||||
if (parsedQuery.filter.length === 0) {
|
||||
resultRecords = records.slice();
|
||||
} else {
|
||||
recordLoop:
|
||||
for (let record of records) {
|
||||
const recordAny: any = record;
|
||||
for (let { op, prop, value } of parsedQuery.filter) {
|
||||
switch (op) {
|
||||
case "=":
|
||||
const recordPropVal = recordAny[prop];
|
||||
if (Array.isArray(recordPropVal) && !Array.isArray(value)) {
|
||||
// Record property is an array, and value is a scalar: find the value in the array
|
||||
if (!recordPropVal.includes(value)) {
|
||||
continue recordLoop;
|
||||
}
|
||||
} else if (Array.isArray(recordPropVal) && Array.isArray(value)) {
|
||||
// Record property is an array, and value is an array: find the value in the array
|
||||
if (!recordPropVal.some((v) => value.includes(v))) {
|
||||
continue recordLoop;
|
||||
}
|
||||
} else if (!(recordPropVal == value)) {
|
||||
// Both are scalars: exact value
|
||||
continue recordLoop;
|
||||
}
|
||||
break;
|
||||
case "!=":
|
||||
if (!(recordAny[prop] != value)) {
|
||||
continue recordLoop;
|
||||
}
|
||||
break;
|
||||
case "<":
|
||||
if (!(recordAny[prop] < value)) {
|
||||
continue recordLoop;
|
||||
}
|
||||
break;
|
||||
case "<=":
|
||||
if (!(recordAny[prop] <= value)) {
|
||||
continue recordLoop;
|
||||
}
|
||||
break;
|
||||
case ">":
|
||||
if (!(recordAny[prop] > value)) {
|
||||
continue recordLoop;
|
||||
}
|
||||
break;
|
||||
case ">=":
|
||||
if (!(recordAny[prop] >= value)) {
|
||||
continue recordLoop;
|
||||
}
|
||||
break;
|
||||
case "=~":
|
||||
// TODO: Cache regexps somehow
|
||||
if (!new RegExp(value).exec(recordAny[prop])) {
|
||||
continue recordLoop;
|
||||
}
|
||||
break;
|
||||
case "!=~":
|
||||
if (new RegExp(value).exec(recordAny[prop])) {
|
||||
continue recordLoop;
|
||||
}
|
||||
break;
|
||||
case "in":
|
||||
if (!value.includes(recordAny[prop])) {
|
||||
continue recordLoop;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
resultRecords.push(recordAny);
|
||||
}
|
||||
}
|
||||
// Now the sorting
|
||||
if (parsedQuery.orderBy) {
|
||||
resultRecords = resultRecords.sort((a: any, b: any) => {
|
||||
const orderBy = parsedQuery.orderBy!;
|
||||
const orderDesc = parsedQuery.orderDesc!;
|
||||
if (a[orderBy] === b[orderBy]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (a[orderBy] < b[orderBy]) {
|
||||
return orderDesc ? 1 : -1;
|
||||
} else {
|
||||
return orderDesc ? -1 : 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (parsedQuery.limit) {
|
||||
resultRecords = resultRecords.slice(0, parsedQuery.limit);
|
||||
}
|
||||
if (parsedQuery.select) {
|
||||
resultRecords = resultRecords.map((rec) => {
|
||||
let newRec: any = {};
|
||||
for (let k of parsedQuery.select!) {
|
||||
newRec[k] = rec[k];
|
||||
}
|
||||
return newRec;
|
||||
});
|
||||
}
|
||||
return resultRecords;
|
||||
}
|
||||
|
||||
export async function renderQuery(
|
||||
parsedQuery: ParsedQuery,
|
||||
data: any[],
|
||||
): Promise<string> {
|
||||
if (parsedQuery.render) {
|
||||
Handlebars.registerHelper("json", (v: any) => JSON.stringify(v));
|
||||
Handlebars.registerHelper("niceDate", (ts: any) => niceDate(new Date(ts)));
|
||||
Handlebars.registerHelper("prefixLines", (v: string, prefix: string) =>
|
||||
v
|
||||
.split("\n")
|
||||
.map((l) => prefix + l)
|
||||
.join("\n"));
|
||||
|
||||
Handlebars.registerHelper(
|
||||
"substring",
|
||||
(s: string, from: number, to: number, elipsis = "") =>
|
||||
s.length > to - from ? s.substring(from, to) + elipsis : s,
|
||||
);
|
||||
|
||||
Handlebars.registerHelper("yaml", (v: any, prefix: string) => {
|
||||
if (typeof prefix === "string") {
|
||||
let yaml = YAML.stringify(v)
|
||||
.split("\n")
|
||||
.join("\n" + prefix)
|
||||
.trim();
|
||||
if (Array.isArray(v)) {
|
||||
return "\n" + prefix + yaml;
|
||||
} else {
|
||||
return yaml;
|
||||
}
|
||||
} else {
|
||||
return YAML.stringify(v).trim();
|
||||
}
|
||||
});
|
||||
let { text: templateText } = await readPage(parsedQuery.render);
|
||||
templateText = `{{#each .}}\n${templateText}\n{{/each}}`;
|
||||
let template = Handlebars.compile(templateText, { noEscape: true });
|
||||
return template(data);
|
||||
}
|
||||
|
||||
return "ERROR";
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
getCurrentPage,
|
||||
reloadPage,
|
||||
save,
|
||||
} from "$sb/silverbullet-syscall/editor.ts";
|
||||
|
||||
import Handlebars from "handlebars";
|
||||
|
||||
import { readPage, writePage } from "$sb/silverbullet-syscall/space.ts";
|
||||
import { invokeFunction } from "$sb/silverbullet-syscall/system.ts";
|
||||
import { renderQuery } from "./engine.ts";
|
||||
import { parseQuery } from "./parser.ts";
|
||||
import { replaceTemplateVars } from "../core/template.ts";
|
||||
import { jsonToMDTable, queryRegex } from "./util.ts";
|
||||
import { dispatch } from "$sb/plugos-syscall/event.ts";
|
||||
import { replaceAsync } from "../lib/util.ts";
|
||||
import { parseMarkdown } from "$sb/silverbullet-syscall/markdown.ts";
|
||||
import { nodeAtPos, renderToText } from "../../common/tree.ts";
|
||||
import { extractMeta } from "./data.ts";
|
||||
|
||||
export async function updateMaterializedQueriesCommand() {
|
||||
const currentPage = await getCurrentPage();
|
||||
await save();
|
||||
if (
|
||||
await invokeFunction(
|
||||
"server",
|
||||
"updateMaterializedQueriesOnPage",
|
||||
currentPage,
|
||||
)
|
||||
) {
|
||||
await reloadPage();
|
||||
}
|
||||
}
|
||||
|
||||
export const templateInstRegex =
|
||||
/(<!--\s*#(use|use-verbose|include)\s+\[\[([^\]]+)\]\](.*?)-->)(.+?)(<!--\s*\/\2\s*-->)/gs;
|
||||
|
||||
async function updateTemplateInstantiations(
|
||||
text: string,
|
||||
pageName: string,
|
||||
): Promise<string> {
|
||||
return replaceAsync(
|
||||
text,
|
||||
templateInstRegex,
|
||||
async (fullMatch, startInst, type, template, args, body, endInst) => {
|
||||
args = args.trim();
|
||||
let parsedArgs = {};
|
||||
if (args) {
|
||||
try {
|
||||
parsedArgs = JSON.parse(args);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse template instantiation args", args);
|
||||
return fullMatch;
|
||||
}
|
||||
}
|
||||
let templateText = "";
|
||||
if (template.startsWith("http://") || template.startsWith("https://")) {
|
||||
try {
|
||||
let req = await fetch(template);
|
||||
templateText = await req.text();
|
||||
} catch (e: any) {
|
||||
templateText = `ERROR: ${e.message}`;
|
||||
}
|
||||
} else {
|
||||
templateText = (await readPage(template)).text;
|
||||
}
|
||||
let newBody = templateText;
|
||||
// if it's a template injection (not a literal "include")
|
||||
if (type === "use" || type === "use-verbose") {
|
||||
let tree = await parseMarkdown(templateText);
|
||||
extractMeta(tree, ["$disableDirectives"]);
|
||||
templateText = renderToText(tree);
|
||||
let templateFn = Handlebars.compile(
|
||||
replaceTemplateVars(templateText, pageName),
|
||||
{ noEscape: true },
|
||||
);
|
||||
newBody = templateFn(parsedArgs);
|
||||
}
|
||||
return `${startInst}\n${newBody.trim()}\n${endInst}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanTemplateInstantiations(text: string): Promise<string> {
|
||||
return replaceAsync(
|
||||
text,
|
||||
templateInstRegex,
|
||||
async (fullMatch, startInst, type, template, args, body, endInst) => {
|
||||
if (type === "use") {
|
||||
body = body.replaceAll(
|
||||
queryRegex,
|
||||
(
|
||||
fullMatch: string,
|
||||
startQuery: string,
|
||||
query: string,
|
||||
body: string,
|
||||
) => {
|
||||
return body.trim();
|
||||
},
|
||||
);
|
||||
}
|
||||
return `${startInst}${body}${endInst}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Called from client, running on server
|
||||
export async function updateMaterializedQueriesOnPage(
|
||||
pageName: string,
|
||||
): Promise<boolean> {
|
||||
let text = "";
|
||||
try {
|
||||
text = (await readPage(pageName)).text;
|
||||
} catch {
|
||||
console.warn(
|
||||
"Could not read page",
|
||||
pageName,
|
||||
"perhaps it doesn't yet exist",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let newText = await updateTemplateInstantiations(text, pageName);
|
||||
let tree = await parseMarkdown(newText);
|
||||
let metaData = extractMeta(tree, ["$disableDirectives"]);
|
||||
if (metaData.$disableDirectives) {
|
||||
console.log("Directives disabled, skipping");
|
||||
return false;
|
||||
}
|
||||
newText = renderToText(tree);
|
||||
|
||||
newText = await replaceAsync(
|
||||
newText,
|
||||
queryRegex,
|
||||
async (fullMatch, startQuery, query, body, endQuery, index) => {
|
||||
let currentNode = nodeAtPos(tree, index + 1);
|
||||
if (currentNode?.type !== "CommentBlock") {
|
||||
// If not a comment block, it's likely a code block, ignore
|
||||
return fullMatch;
|
||||
}
|
||||
|
||||
let parsedQuery = parseQuery(replaceTemplateVars(query, pageName));
|
||||
|
||||
// console.log("Parsed query", parsedQuery);
|
||||
// Let's dispatch an event and see what happens
|
||||
let results = await dispatch(
|
||||
`query:${parsedQuery.table}`,
|
||||
{ query: parsedQuery, pageName: pageName },
|
||||
10 * 1000,
|
||||
);
|
||||
if (results.length === 0) {
|
||||
return `${startQuery}\n${endQuery}`;
|
||||
} else if (results.length === 1) {
|
||||
if (parsedQuery.render) {
|
||||
let rendered = await renderQuery(parsedQuery, results[0]);
|
||||
return `${startQuery}\n${rendered.trim()}\n${endQuery}`;
|
||||
} else {
|
||||
return `${startQuery}\n${jsonToMDTable(results[0])}\n${endQuery}`;
|
||||
}
|
||||
} else {
|
||||
console.error("Too many query results", results);
|
||||
return fullMatch;
|
||||
}
|
||||
},
|
||||
);
|
||||
newText = await cleanTemplateInstantiations(newText);
|
||||
if (text !== newText) {
|
||||
await writePage(pageName, newText);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "&fOVQPOOOmQQO'#C^QOQPOOOtQPO'#C`OyQQO'#CkO!OQPO'#CmO!TQPO'#CnO!YQPO'#CoOOQO'#Cq'#CqO!bQQO,58xO!iQQO'#CcO#WQQO'#CaOOQO'#Ca'#CaOOQO,58z,58zO#oQPO,59VOOQO,59X,59XO#tQQO'#DaOOQO,59Y,59YOOQO,59Z,59ZOOQO-E6o-E6oO$]QQO,58}OtQPO,58|O$tQQO1G.qO%`QPO'#CsO%eQQO,59{OOQO'#Cg'#CgOOQO'#Ci'#CiO$]QQO'#CjOOQO'#Cd'#CdOOQO1G.i1G.iOOQO1G.h1G.hOOQO'#Cl'#ClOOQO7+$]7+$]OOQO,59_,59_OOQO-E6q-E6qO%|QPO'#C}O&UQPO,59UO$]QQO'#CrO&ZQPO,59iOOQO1G.p1G.pOOQO,59^,59^OOQO-E6p-E6p",
|
||||
stateData: "&c~OjOS~ORPO~OkRO}SO!RTO!SUO!UVO~OhQX~P[ORYO~O!O^O~OX_O~OR`O~OYbOdbO~OhQa~P[OldOtdOudOvdOwdOxdOydOzdO{dO~O|eOhTXkTX}TX!RTX!STX!UTX~ORfO~OrgOh!TXk!TX}!TX!R!TX!S!TX!U!TX~OXlOYlO[lOmiOniOojOpkO~O!PoO!QoOh_ik_i}_i!R_i!S_i!U_i~ORqO~OrgOh!Tak!Ta}!Ta!R!Ta!S!Ta!U!Ta~OruOsqX~OswO~OruOsqa~O",
|
||||
goto: "#e!UPP!VP!Y!^!a!d!jPP!sP!s!s!Y!x!Y!Y!YP!{#R#XPPPPPPPPP#_PPPPPPPPPPPPPPPPP#bRQOTWPXR]RR[RQZRRneQmdQskRxuVldkuRpfQXPRcXQvsRyvQh`RrhRtkRaU",
|
||||
nodeNames: "⚠ Program Query Name WhereClause LogicalExpr AndExpr FilterExpr Value Number String Bool Regex Null List OrderClause Order LimitClause SelectClause RenderClause PageRef",
|
||||
maxTerm: 52,
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 3,
|
||||
tokenData: "B[~R}X^$Opq$Oqr$srs%W|}%r}!O%w!P!Q&Y!Q!['P!^!_'X!_!`'f!`!a's!c!}%w!}#O(Q#P#Q(q#R#S%w#T#U(v#U#V+]#V#W%w#W#X,X#X#Y%w#Y#Z.T#Z#]%w#]#^0e#^#`%w#`#a1a#a#b%w#b#c3t#c#d5p#d#f%w#f#g8T#g#h;P#h#i={#i#k%w#k#l?w#l#o%w#y#z$O$f$g$O#BY#BZ$O$IS$I_$O$Ip$Iq%W$Iq$Ir%W$I|$JO$O$JT$JU$O$KV$KW$O&FU&FV$O~$TYj~X^$Opq$O#y#z$O$f$g$O#BY#BZ$O$IS$I_$O$I|$JO$O$JT$JU$O$KV$KW$O&FU&FV$O~$vP!_!`$y~%OPv~#r#s%R~%WOz~~%ZUOr%Wrs%ms$Ip%W$Ip$Iq%m$Iq$Ir%m$Ir~%W~%rOY~~%wOr~P%|SRP}!O%w!c!}%w#R#S%w#T#o%w~&_V[~OY&YZ]&Y^!P&Y!P!Q&t!Q#O&Y#O#P&y#P~&Y~&yO[~~&|PO~&Y~'UPX~!Q!['P~'^Pl~!_!`'a~'fOt~~'kPu~#r#s'n~'sOy~~'xPx~!_!`'{~(QOw~R(VPpQ!}#O(YP(]RO#P(Y#P#Q(f#Q~(YP(iP#P#Q(lP(qOdP~(vOs~R({WRP}!O%w!c!}%w#R#S%w#T#b%w#b#c)e#c#g%w#g#h*a#h#o%wR)jURP}!O%w!c!}%w#R#S%w#T#W%w#W#X)|#X#o%wR*TS|QRP}!O%w!c!}%w#R#S%w#T#o%wR*fURP}!O%w!c!}%w#R#S%w#T#V%w#V#W*x#W#o%wR+PS!QQRP}!O%w!c!}%w#R#S%w#T#o%wR+bURP}!O%w!c!}%w#R#S%w#T#m%w#m#n+t#n#o%wR+{S!OQRP}!O%w!c!}%w#R#S%w#T#o%wR,^URP}!O%w!c!}%w#R#S%w#T#X%w#X#Y,p#Y#o%wR,uURP}!O%w!c!}%w#R#S%w#T#g%w#g#h-X#h#o%wR-^URP}!O%w!c!}%w#R#S%w#T#V%w#V#W-p#W#o%wR-wS!PQRP}!O%w!c!}%w#R#S%w#T#o%wR.YTRP}!O%w!c!}%w#R#S%w#T#U.i#U#o%wR.nURP}!O%w!c!}%w#R#S%w#T#`%w#`#a/Q#a#o%wR/VURP}!O%w!c!}%w#R#S%w#T#g%w#g#h/i#h#o%wR/nURP}!O%w!c!}%w#R#S%w#T#X%w#X#Y0Q#Y#o%wR0XSnQRP}!O%w!c!}%w#R#S%w#T#o%wR0jURP}!O%w!c!}%w#R#S%w#T#b%w#b#c0|#c#o%wR1TS{QRP}!O%w!c!}%w#R#S%w#T#o%wR1fURP}!O%w!c!}%w#R#S%w#T#]%w#]#^1x#^#o%wR1}URP}!O%w!c!}%w#R#S%w#T#a%w#a#b2a#b#o%wR2fURP}!O%w!c!}%w#R#S%w#T#]%w#]#^2x#^#o%wR2}URP}!O%w!c!}%w#R#S%w#T#h%w#h#i3a#i#o%wR3hS!RQRP}!O%w!c!}%w#R#S%w#T#o%wR3yURP}!O%w!c!}%w#R#S%w#T#i%w#i#j4]#j#o%wR4bURP}!O%w!c!}%w#R#S%w#T#`%w#`#a4t#a#o%wR4yURP}!O%w!c!}%w#R#S%w#T#`%w#`#a5]#a#o%wR5dSoQRP}!O%w!c!}%w#R#S%w#T#o%wR5uURP}!O%w!c!}%w#R#S%w#T#f%w#f#g6X#g#o%wR6^URP}!O%w!c!}%w#R#S%w#T#W%w#W#X6p#X#o%wR6uURP}!O%w!c!}%w#R#S%w#T#X%w#X#Y7X#Y#o%wR7^URP}!O%w!c!}%w#R#S%w#T#f%w#f#g7p#g#o%wR7wS}QRP}!O%w!c!}%w#R#S%w#T#o%wR8YURP}!O%w!c!}%w#R#S%w#T#X%w#X#Y8l#Y#o%wR8qURP}!O%w!c!}%w#R#S%w#T#b%w#b#c9T#c#o%wR9YURP}!O%w!c!}%w#R#S%w#T#W%w#W#X9l#X#o%wR9qURP}!O%w!c!}%w#R#S%w#T#X%w#X#Y:T#Y#o%wR:YURP}!O%w!c!}%w#R#S%w#T#f%w#f#g:l#g#o%wR:sS!UQRP}!O%w!c!}%w#R#S%w#T#o%wR;UURP}!O%w!c!}%w#R#S%w#T#X%w#X#Y;h#Y#o%wR;mURP}!O%w!c!}%w#R#S%w#T#`%w#`#a<P#a#o%wR<UURP}!O%w!c!}%w#R#S%w#T#X%w#X#Y<h#Y#o%wR<mURP}!O%w!c!}%w#R#S%w#T#V%w#V#W=P#W#o%wR=UURP}!O%w!c!}%w#R#S%w#T#h%w#h#i=h#i#o%wR=oS!SQRP}!O%w!c!}%w#R#S%w#T#o%wR>QURP}!O%w!c!}%w#R#S%w#T#f%w#f#g>d#g#o%wR>iURP}!O%w!c!}%w#R#S%w#T#i%w#i#j>{#j#o%wR?QURP}!O%w!c!}%w#R#S%w#T#X%w#X#Y?d#Y#o%wR?kSmQRP}!O%w!c!}%w#R#S%w#T#o%wR?|URP}!O%w!c!}%w#R#S%w#T#[%w#[#]@`#]#o%wR@eURP}!O%w!c!}%w#R#S%w#T#X%w#X#Y@w#Y#o%wR@|URP}!O%w!c!}%w#R#S%w#T#f%w#f#gA`#g#o%wRAeURP}!O%w!c!}%w#R#S%w#T#X%w#X#YAw#Y#o%wRBOSkQRP}!O%w!c!}%w#R#S%w#T#o%w",
|
||||
tokenizers: [0, 1],
|
||||
topRules: {"Program":[0,1]},
|
||||
tokenPrec: 0
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
Program = 1,
|
||||
Query = 2,
|
||||
Name = 3,
|
||||
WhereClause = 4,
|
||||
LogicalExpr = 5,
|
||||
AndExpr = 6,
|
||||
FilterExpr = 7,
|
||||
Value = 8,
|
||||
Number = 9,
|
||||
String = 10,
|
||||
Bool = 11,
|
||||
Regex = 12,
|
||||
Null = 13,
|
||||
List = 14,
|
||||
OrderClause = 15,
|
||||
Order = 16,
|
||||
LimitClause = 17,
|
||||
SelectClause = 18,
|
||||
RenderClause = 19,
|
||||
PageRef = 20
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
collectNodesOfType,
|
||||
findNodeOfType,
|
||||
replaceNodesMatching,
|
||||
} from "../../common/tree.ts";
|
||||
import { lezerToParseTree } from "../../common/parse_tree.ts";
|
||||
import { valueNodeToVal } from "./engine.ts";
|
||||
|
||||
// @ts-ignore auto generated
|
||||
import { parser } from "./parse-query.js";
|
||||
|
||||
export type Filter = {
|
||||
op: string;
|
||||
prop: string;
|
||||
value: any;
|
||||
};
|
||||
|
||||
export type ParsedQuery = {
|
||||
table: string;
|
||||
orderBy?: string;
|
||||
orderDesc?: boolean;
|
||||
limit?: number;
|
||||
filter: Filter[];
|
||||
select?: string[];
|
||||
render?: string;
|
||||
};
|
||||
|
||||
export function parseQuery(query: string): ParsedQuery {
|
||||
let n = lezerToParseTree(query, parser.parse(query).topNode);
|
||||
// Clean the tree a bit
|
||||
replaceNodesMatching(n, (n) => {
|
||||
if (!n.type) {
|
||||
let trimmed = n.text!.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
n.text = trimmed;
|
||||
}
|
||||
});
|
||||
|
||||
// console.log("Parsed", JSON.stringify(n, null, 2));
|
||||
let queryNode = n.children![0];
|
||||
let parsedQuery: ParsedQuery = {
|
||||
table: queryNode.children![0].children![0].text!,
|
||||
filter: [],
|
||||
};
|
||||
let orderByNode = findNodeOfType(queryNode, "OrderClause");
|
||||
if (orderByNode) {
|
||||
let nameNode = findNodeOfType(orderByNode, "Name");
|
||||
parsedQuery.orderBy = nameNode!.children![0].text!;
|
||||
let orderNode = findNodeOfType(orderByNode, "Order");
|
||||
parsedQuery.orderDesc = orderNode
|
||||
? orderNode.children![0].text! === "desc"
|
||||
: false;
|
||||
}
|
||||
let limitNode = findNodeOfType(queryNode, "LimitClause");
|
||||
if (limitNode) {
|
||||
let nameNode = findNodeOfType(limitNode, "Number");
|
||||
parsedQuery.limit = valueNodeToVal(nameNode!);
|
||||
}
|
||||
|
||||
let filterNodes = collectNodesOfType(queryNode, "FilterExpr");
|
||||
for (let filterNode of filterNodes) {
|
||||
let val: any = undefined;
|
||||
let valNode = filterNode.children![2].children![0];
|
||||
val = valueNodeToVal(valNode);
|
||||
let f: Filter = {
|
||||
prop: filterNode.children![0].children![0].text!,
|
||||
op: filterNode.children![1].text!,
|
||||
value: val,
|
||||
};
|
||||
parsedQuery.filter.push(f);
|
||||
}
|
||||
let selectNode = findNodeOfType(queryNode, "SelectClause");
|
||||
if (selectNode) {
|
||||
// console.log("Select node", JSON.stringify(selectNode));
|
||||
parsedQuery.select = [];
|
||||
collectNodesOfType(selectNode, "Name").forEach((t) => {
|
||||
parsedQuery.select!.push(t.children![0].text!);
|
||||
});
|
||||
// let nameNode = findNodeOfType(selectNode, "Number");
|
||||
// parsedQuery.limit = +nameNode!.children![0].text!;
|
||||
}
|
||||
|
||||
let renderNode = findNodeOfType(queryNode, "RenderClause");
|
||||
if (renderNode) {
|
||||
let renderNameNode = findNodeOfType(renderNode, "PageRef");
|
||||
if (!renderNameNode) {
|
||||
renderNameNode = findNodeOfType(renderNode, "String");
|
||||
}
|
||||
parsedQuery.render = valueNodeToVal(renderNameNode!);
|
||||
}
|
||||
|
||||
// console.log(JSON.stringify(queryNode, null, 2));
|
||||
return parsedQuery;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
@precedence { logic @left }
|
||||
|
||||
@top Program { Query }
|
||||
|
||||
Query {
|
||||
Name ( WhereClause | OrderClause | LimitClause | SelectClause | RenderClause )*
|
||||
}
|
||||
|
||||
commaSep<content> { content ("," content)* }
|
||||
|
||||
WhereClause { "where" LogicalExpr }
|
||||
OrderClause { "order" "by" Name Order? }
|
||||
LimitClause { "limit" Number }
|
||||
SelectClause { "select" commaSep<Name> }
|
||||
RenderClause { "render" (PageRef | String) }
|
||||
|
||||
Order {
|
||||
"desc" | "asc"
|
||||
}
|
||||
|
||||
Value { Number | String | Bool | Regex | Null | List }
|
||||
|
||||
LogicalExpr { AndExpr | FilterExpr }
|
||||
|
||||
AndExpr { FilterExpr !logic "and" FilterExpr }
|
||||
|
||||
FilterExpr {
|
||||
Name "<" Value
|
||||
| Name "<=" Value
|
||||
| Name "=" Value
|
||||
| Name "!=" Value
|
||||
| Name ">=" Value
|
||||
| Name ">" Value
|
||||
| Name "=~" Value
|
||||
| Name "!=~" Value
|
||||
| Name "in" Value
|
||||
}
|
||||
|
||||
List { "[" commaSep<Value> "]" }
|
||||
|
||||
@skip { space }
|
||||
|
||||
|
||||
|
||||
Bool {
|
||||
"true" | "false"
|
||||
}
|
||||
|
||||
Null {
|
||||
"null"
|
||||
}
|
||||
|
||||
@tokens {
|
||||
space { std.whitespace+ }
|
||||
Name { (std.asciiLetter | "-" | "_")+ }
|
||||
String {
|
||||
("\"" | "“" | "”") ![\"”“]* ("\"" | "“" | "”")
|
||||
}
|
||||
PageRef {
|
||||
"[" "[" ![\]]* "]" "]"
|
||||
}
|
||||
Regex { "/" ( ![/\\\n\r] | "\\" _ )* "/"? }
|
||||
|
||||
Number { std.digit+ }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: query
|
||||
functions:
|
||||
updateMaterializedQueriesOnPage:
|
||||
path: ./materialized_queries.ts:updateMaterializedQueriesOnPage
|
||||
updateMaterializedQueriesCommand:
|
||||
path: ./materialized_queries.ts:updateMaterializedQueriesCommand
|
||||
command:
|
||||
name: "Materialized Queries: Update"
|
||||
key: "Alt-q"
|
||||
events:
|
||||
- editor:pageLoaded
|
||||
indexData:
|
||||
path: ./data.ts:indexData
|
||||
events:
|
||||
- page:index
|
||||
dataQueryProvider:
|
||||
path: ./data.ts:queryProvider
|
||||
events:
|
||||
- query:data
|
||||
queryComplete:
|
||||
path: ./complete.ts:queryComplete
|
||||
events:
|
||||
- page:complete
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
addParentPointers,
|
||||
collectNodesMatching,
|
||||
ParseTree,
|
||||
renderToText,
|
||||
} from "../../common/tree.ts";
|
||||
|
||||
export const queryRegex =
|
||||
/(<!--\s*#query\s+(.+?)-->)(.+?)(<!--\s*\/query\s*-->)/gs;
|
||||
|
||||
export const directiveStartRegex = /<!--\s*#([\w\-]+)\s+(.+?)-->/s;
|
||||
|
||||
export const directiveEndRegex = /<!--\s*\/([\w\-]+)\s*-->/s;
|
||||
|
||||
export function removeQueries(pt: ParseTree) {
|
||||
addParentPointers(pt);
|
||||
collectNodesMatching(pt, (t) => {
|
||||
if (t.type !== "CommentBlock") {
|
||||
return false;
|
||||
}
|
||||
let text = t.children![0].text!;
|
||||
let match = directiveStartRegex.exec(text);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
let directiveType = match[1];
|
||||
let parentChildren = t.parent!.children!;
|
||||
let index = parentChildren.indexOf(t);
|
||||
let nodesToReplace: ParseTree[] = [];
|
||||
for (let i = index + 1; i < parentChildren.length; i++) {
|
||||
let n = parentChildren[i];
|
||||
if (n.type === "CommentBlock") {
|
||||
let text = n.children![0].text!;
|
||||
let match = directiveEndRegex.exec(text);
|
||||
if (match && match[1] === directiveType) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
nodesToReplace.push(n);
|
||||
}
|
||||
let renderedText = nodesToReplace.map(renderToText).join("");
|
||||
parentChildren.splice(index + 1, nodesToReplace.length, {
|
||||
text: new Array(renderedText.length + 1).join(" "),
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const maxWidth = 70;
|
||||
// Nicely format an array of JSON objects as a Markdown table
|
||||
export function jsonToMDTable(
|
||||
jsonArray: any[],
|
||||
valueTransformer: (k: string, v: any) => string = (k, v) => "" + v
|
||||
): string {
|
||||
let fieldWidths = new Map<string, number>();
|
||||
for (let entry of jsonArray) {
|
||||
for (let k of Object.keys(entry)) {
|
||||
let fieldWidth = fieldWidths.get(k);
|
||||
if (!fieldWidth) {
|
||||
fieldWidth = valueTransformer(k, entry[k]).length;
|
||||
} else {
|
||||
fieldWidth = Math.max(valueTransformer(k, entry[k]).length, fieldWidth);
|
||||
}
|
||||
fieldWidths.set(k, fieldWidth);
|
||||
}
|
||||
}
|
||||
|
||||
let fullWidth = 0;
|
||||
for (let v of fieldWidths.values()) {
|
||||
fullWidth += v + 1;
|
||||
}
|
||||
|
||||
let headerList = [...fieldWidths.keys()];
|
||||
let lines = [];
|
||||
lines.push(
|
||||
"|" +
|
||||
headerList
|
||||
.map(
|
||||
(headerName) =>
|
||||
headerName +
|
||||
charPad(" ", fieldWidths.get(headerName)! - headerName.length)
|
||||
)
|
||||
.join("|") +
|
||||
"|"
|
||||
);
|
||||
lines.push(
|
||||
"|" +
|
||||
headerList
|
||||
.map((title) => charPad("-", fieldWidths.get(title)!))
|
||||
.join("|") +
|
||||
"|"
|
||||
);
|
||||
for (const val of jsonArray) {
|
||||
let el = [];
|
||||
for (let prop of headerList) {
|
||||
let s = valueTransformer(prop, val[prop]);
|
||||
el.push(s + charPad(" ", fieldWidths.get(prop)! - s.length));
|
||||
}
|
||||
lines.push("|" + el.join("|") + "|");
|
||||
}
|
||||
return lines.join("\n");
|
||||
|
||||
function charPad(ch: string, length: number) {
|
||||
if (fullWidth > maxWidth && ch === "") {
|
||||
return "";
|
||||
} else if (fullWidth > maxWidth && ch === "-") {
|
||||
return "--";
|
||||
}
|
||||
if (length < 1) {
|
||||
return "";
|
||||
}
|
||||
return new Array(length + 1).join(ch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { ClickEvent, IndexTreeEvent } from "../../web/app_event.ts";
|
||||
|
||||
import { batchSet, queryPrefix } from "$sb/silverbullet-syscall/index.ts";
|
||||
import { readPage, writePage } from "$sb/silverbullet-syscall/space.ts";
|
||||
import { parseMarkdown } from "$sb/silverbullet-syscall/markdown.ts";
|
||||
import {
|
||||
dispatch,
|
||||
filterBox,
|
||||
getCursor,
|
||||
getText,
|
||||
} from "$sb/silverbullet-syscall/editor.ts";
|
||||
import {
|
||||
addParentPointers,
|
||||
collectNodesMatching,
|
||||
collectNodesOfType,
|
||||
findNodeOfType,
|
||||
nodeAtPos,
|
||||
ParseTree,
|
||||
renderToText,
|
||||
replaceNodesMatching,
|
||||
} from "../../common/tree.ts";
|
||||
import { removeQueries } from "../query/util.ts";
|
||||
import { applyQuery, QueryProviderEvent } from "../query/engine.ts";
|
||||
import { niceDate } from "../core/dates.ts";
|
||||
|
||||
export type Task = {
|
||||
name: string;
|
||||
done: boolean;
|
||||
deadline?: string;
|
||||
tags?: string[];
|
||||
nested?: string;
|
||||
// Not saved in DB, just added when pulled out (from key)
|
||||
pos?: number;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
function getDeadline(deadlineNode: ParseTree): string {
|
||||
return deadlineNode.children![0].text!.replace(/📅\s*/, "");
|
||||
}
|
||||
|
||||
export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
||||
// console.log("Indexing tasks");
|
||||
let tasks: { key: string; value: Task }[] = [];
|
||||
removeQueries(tree);
|
||||
collectNodesOfType(tree, "Task").forEach((n) => {
|
||||
let complete = n.children![0].children![0].text! !== "[ ]";
|
||||
let task: Task = {
|
||||
name: "",
|
||||
done: complete,
|
||||
};
|
||||
|
||||
replaceNodesMatching(n, (tree) => {
|
||||
if (tree.type === "DeadlineDate") {
|
||||
task.deadline = getDeadline(tree);
|
||||
// Remove this node from the tree
|
||||
return null;
|
||||
}
|
||||
if (tree.type === "Hashtag") {
|
||||
if (!task.tags) {
|
||||
task.tags = [];
|
||||
}
|
||||
task.tags.push(tree.children![0].text!);
|
||||
// Remove this node from the tree
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
task.name = n.children!.slice(1).map(renderToText).join("").trim();
|
||||
|
||||
let taskIndex = n.parent!.children!.indexOf(n);
|
||||
let nestedItems = n.parent!.children!.slice(taskIndex + 1);
|
||||
if (nestedItems.length > 0) {
|
||||
task.nested = nestedItems.map(renderToText).join("").trim();
|
||||
}
|
||||
tasks.push({
|
||||
key: `task:${n.from}`,
|
||||
value: task,
|
||||
});
|
||||
// console.log("Task", task);
|
||||
});
|
||||
|
||||
console.log("Found", tasks.length, "task(s)");
|
||||
await batchSet(name, tasks);
|
||||
}
|
||||
|
||||
export async function taskToggle(event: ClickEvent) {
|
||||
return taskToggleAtPos(event.pos);
|
||||
}
|
||||
|
||||
async function toggleTaskMarker(node: ParseTree, moveToPos: number) {
|
||||
let changeTo = "[x]";
|
||||
if (node.children![0].text === "[x]" || node.children![0].text === "[X]") {
|
||||
changeTo = "[ ]";
|
||||
}
|
||||
await dispatch({
|
||||
changes: {
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
insert: changeTo,
|
||||
},
|
||||
selection: {
|
||||
anchor: moveToPos,
|
||||
},
|
||||
});
|
||||
|
||||
let parentWikiLinks = collectNodesMatching(
|
||||
node.parent!,
|
||||
(n) => n.type === "WikiLinkPage",
|
||||
);
|
||||
for (let wikiLink of parentWikiLinks) {
|
||||
let ref = wikiLink.children![0].text!;
|
||||
if (ref.includes("@")) {
|
||||
let [page, pos] = ref.split("@");
|
||||
let text = (await readPage(page)).text;
|
||||
|
||||
let referenceMdTree = await parseMarkdown(text);
|
||||
// Adding +1 to immediately hit the task marker
|
||||
let taskMarkerNode = nodeAtPos(referenceMdTree, +pos + 1);
|
||||
|
||||
if (!taskMarkerNode || taskMarkerNode.type !== "TaskMarker") {
|
||||
console.error(
|
||||
"Reference not a task marker, out of date?",
|
||||
taskMarkerNode,
|
||||
);
|
||||
return;
|
||||
}
|
||||
taskMarkerNode.children![0].text = changeTo;
|
||||
text = renderToText(referenceMdTree);
|
||||
console.log("Updated reference paged text", text);
|
||||
await writePage(page, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function taskToggleAtPos(pos: number) {
|
||||
let text = await getText();
|
||||
let mdTree = await parseMarkdown(text);
|
||||
addParentPointers(mdTree);
|
||||
|
||||
let node = nodeAtPos(mdTree, pos);
|
||||
if (node && node.type === "TaskMarker") {
|
||||
await toggleTaskMarker(node, pos);
|
||||
}
|
||||
}
|
||||
|
||||
export async function taskToggleCommand() {
|
||||
let text = await getText();
|
||||
let pos = await getCursor();
|
||||
let tree = await parseMarkdown(text);
|
||||
addParentPointers(tree);
|
||||
|
||||
let node = nodeAtPos(tree, pos);
|
||||
// We kwow node.type === Task (due to the task context)
|
||||
let taskMarker = findNodeOfType(node!, "TaskMarker");
|
||||
await toggleTaskMarker(taskMarker!, pos);
|
||||
}
|
||||
|
||||
export async function postponeCommand() {
|
||||
let text = await getText();
|
||||
let pos = await getCursor();
|
||||
let tree = await parseMarkdown(text);
|
||||
addParentPointers(tree);
|
||||
|
||||
let node = nodeAtPos(tree, pos)!;
|
||||
// We kwow node.type === DeadlineDate (due to the task context)
|
||||
let date = getDeadline(node);
|
||||
let option = await filterBox(
|
||||
"Postpone for...",
|
||||
[
|
||||
{ name: "a day", orderId: 1 },
|
||||
{ name: "a week", orderId: 2 },
|
||||
{ name: "following Monday", orderId: 3 },
|
||||
],
|
||||
"Select the desired time span to delay this task",
|
||||
);
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
let d = new Date(date);
|
||||
switch (option.name) {
|
||||
case "a day":
|
||||
d.setDate(d.getDate() + 1);
|
||||
break;
|
||||
case "a week":
|
||||
d.setDate(d.getDate() + 7);
|
||||
break;
|
||||
case "following Monday":
|
||||
d.setDate(d.getDate() + ((7 - d.getDay() + 1) % 7 || 7));
|
||||
break;
|
||||
}
|
||||
await dispatch({
|
||||
changes: {
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
insert: `📅 ${niceDate(d)}`,
|
||||
},
|
||||
selection: {
|
||||
anchor: pos,
|
||||
},
|
||||
});
|
||||
// await toggleTaskMarker(taskMarker!, pos);
|
||||
}
|
||||
|
||||
export async function queryProvider({
|
||||
query,
|
||||
}: QueryProviderEvent): Promise<Task[]> {
|
||||
let allTasks: Task[] = [];
|
||||
for (let { key, page, value } of await queryPrefix("task:")) {
|
||||
let [, pos] = key.split(":");
|
||||
allTasks.push({
|
||||
...value,
|
||||
page: page,
|
||||
pos: pos,
|
||||
});
|
||||
}
|
||||
return applyQuery(query, allTasks);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
name: tasks
|
||||
syntax:
|
||||
DeadlineDate:
|
||||
firstCharacters:
|
||||
- "📅"
|
||||
regex: "📅\\s*\\d{4}\\-\\d{2}\\-\\d{2}"
|
||||
styles:
|
||||
backgroundColor: "rgba(22,22,22,0.07)"
|
||||
CompletedDate:
|
||||
firstCharacters:
|
||||
- "✅"
|
||||
regex: "✅\\s*\\d{4}\\-\\d{2}\\-\\d{2}"
|
||||
styles:
|
||||
backgroundColor: "rgba(22,22,22,0.07)"
|
||||
RepeatInterval:
|
||||
firstCharacters:
|
||||
- "🔁"
|
||||
regex: "🔁\\s*every\\s+\\w+"
|
||||
styles:
|
||||
backgroundColor: "rgba(22,22,22,0.07)"
|
||||
functions:
|
||||
indexTasks:
|
||||
path: "./task.ts:indexTasks"
|
||||
events:
|
||||
- page:index
|
||||
taskToggle:
|
||||
path: "./task.ts:taskToggle"
|
||||
events:
|
||||
- page:click
|
||||
itemQueryProvider:
|
||||
path: ./task.ts:queryProvider
|
||||
events:
|
||||
- query:task
|
||||
taskToggleCommand:
|
||||
path: ./task.ts:taskToggleCommand
|
||||
command:
|
||||
name: "Task: Toggle"
|
||||
key: Alt-t
|
||||
contexts:
|
||||
- Task
|
||||
taskPostponeCommand:
|
||||
path: ./task.ts:postponeCommand
|
||||
command:
|
||||
name: "Task: Postpone"
|
||||
key: Alt-+
|
||||
contexts:
|
||||
- DeadlineDate
|
||||
Reference in New Issue
Block a user