Refactor all the things

This commit is contained in:
Zef Hemel
2023-08-28 17:12:15 +02:00
parent 54d2deea15
commit 5ff1a8bae3
87 changed files with 930 additions and 896 deletions
+5
View File
@@ -0,0 +1,5 @@
import { editor } from "$sb/syscalls.ts";
export async function accountLogoutCommand() {
await editor.openUrl("/.client/logout.html", true);
}
+62
View File
@@ -0,0 +1,62 @@
import { traverseTree } from "../../plug-api/lib/tree.ts";
import { editor, markdown, space } from "$sb/syscalls.ts";
export async function brokenLinksCommand() {
const pageName = "BROKEN LINKS";
await editor.flashNotification("Scanning your space...");
const allPages = await space.listPages();
const allPagesMap = new Map(allPages.map((p) => [p.name, true]));
const brokenLinks: { page: string; link: string; pos: number }[] = [];
for (const pageMeta of allPages) {
const text = await space.readPage(pageMeta.name);
const tree = await markdown.parseMarkdown(text);
traverseTree(tree, (tree) => {
if (tree.type === "WikiLinkPage") {
// Add the prefix in the link text
const [pageName] = tree.children![0].text!.split("@");
if (pageName.startsWith("💭 ")) {
return true;
}
if (
pageName && !pageName.startsWith("{{")
) {
if (!allPagesMap.has(pageName)) {
brokenLinks.push({
page: pageMeta.name,
link: pageName,
pos: tree.from!,
});
}
}
}
if (tree.type === "PageRef") {
const pageName = tree.children![0].text!.slice(2, -2);
if (pageName.startsWith("💭 ")) {
return true;
}
if (!allPagesMap.has(pageName)) {
brokenLinks.push({
page: pageMeta.name,
link: pageName,
pos: tree.from!,
});
}
}
if (tree.type === "DirectiveBody") {
// Don't look inside directive bodies
return true;
}
return false;
});
}
const lines: string[] = [];
for (const brokenLink of brokenLinks) {
lines.push(
`* [[${brokenLink.page}@${brokenLink.pos}]]: ${brokenLink.link}`,
);
}
await space.writePage(pageName, lines.join("\n"));
await editor.navigate(pageName);
}
+7
View File
@@ -0,0 +1,7 @@
import { editor } from "$sb/syscalls.ts";
export async function setThinClient(def: any) {
console.log("Setting thin client to", def.value);
await editor.setUiOption("thinClientMode", def.value);
await editor.reloadUI();
}
+19
View File
@@ -0,0 +1,19 @@
import { system } from "$sb/syscalls.ts";
import { CompleteEvent } from "$sb/app_event.ts";
export async function commandComplete(completeEvent: CompleteEvent) {
const match = /\{\[([^\]]*)$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
const allCommands = await system.listCommands();
return {
from: completeEvent.pos - match[1].length,
options: Object.keys(allCommands).map((commandName) => ({
label: commandName,
type: "command",
})),
};
}
+20
View File
@@ -0,0 +1,20 @@
import { debug, editor, markdown } from "$sb/syscalls.ts";
export async function parsePageCommand() {
console.log(
"AST",
JSON.stringify(
await markdown.parseMarkdown(await editor.getText()),
null,
2,
),
);
}
export async function resetClientCommand() {
await debug.resetClient();
}
export async function reloadUICommand() {
await editor.reloadUI();
}
+240
View File
@@ -0,0 +1,240 @@
name: editor
requiredPermissions:
- fetch
syntax:
NakedURL:
firstCharacters:
- "h"
regex: "https?:\\/\\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}([-a-zA-Z0-9()@:%_\\+.~#?&=\\/]*)"
className: sb-naked-url
functions:
setEditorMode:
path: "./editor.ts:setEditorMode"
events:
- editor:init
toggleDarkMode:
path: "./editor.ts:toggleDarkMode"
command:
name: "Editor: Toggle Dark Mode"
# Page operations
deletePage:
path: "./page.ts:deletePage"
command:
name: "Page: Delete"
copyPage:
path: "./page.ts:copyPage"
command:
name: "Page: Copy"
newPage:
path: ./page.ts:newPageCommand
command:
name: "Page: New"
key: "Alt-Shift-n"
# Completion
pageComplete:
path: "./page.ts:pageComplete"
events:
- editor:complete
commandComplete:
path: "./command.ts:commandComplete"
events:
- editor:complete
# 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: ""
# 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"
key: "Ctrl-Shift-8"
mac: "Cmd-Shift-8"
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: "_"
strikethrough:
path: ./text.ts:wrapSelection
command:
name: "Text: Strikethrough"
key: "Ctrl-Shift-s"
mac: "Cmd-Shift-s"
wrapper: "~~"
marker:
path: ./text.ts:wrapSelection
command:
name: "Text: Marker"
key: "Alt-m"
wrapper: "=="
centerCursor:
path: "./editor.ts:centerCursorCommand"
command:
name: "Editor: Center Cursor"
key: "Ctrl-Alt-l"
moveToPos:
path: "./editor.ts:moveToPosCommand"
command:
name: "Editor: Move Cursor to Position"
# Debug commands
parseCommand:
path: ./debug.ts:parsePageCommand
command:
name: "Debug: Parse Document"
# Link unfurl infrastructure
unfurlLink:
path: ./link.ts:unfurlCommand
command:
name: "Link: Unfurl"
key: "Ctrl-Shift-u"
mac: "Cmd-Shift-u"
contexts:
- NakedURL
# Title-based link unfurl
titleUnfurlOptions:
path: ./link.ts:titleUnfurlOptions
events:
- unfurl:options
titleUnfurl:
path: ./link.ts:titleUnfurl
events:
- unfurl:title-unfurl
embedWidget:
path: ./embed.ts:embedWidget
codeWidget: embed
# Folding commands
foldCommand:
path: ./editor.ts:foldCommand
command:
name: "Fold: Fold"
mac: "Cmd-Alt-["
key: "Ctrl-Shift-["
unfoldCommand:
path: ./editor.ts:unfoldCommand
command:
name: "Fold: Unfold"
mac: "Cmd-Alt-]"
key: "Ctrl-Shift-]"
toggleFoldCommand:
path: ./editor.ts:toggleFoldCommand
command:
name: "Fold: Toggle Fold"
mac: "Cmd-Alt-f"
key: "Ctrl-Alt-f"
foldAllCommand:
path: ./editor.ts:foldAllCommand
command:
name: "Fold: Fold All"
key: "Ctrl-Alt-["
unfoldAllCommand:
path: ./editor.ts:unfoldAllCommand
command:
name: "Fold: Unfold All"
key: "Ctrl-Alt-]"
# Vim
toggleVimMode:
path: "./vim.ts:toggleVimMode"
command:
name: "Editor: Toggle Vim Mode"
loadVimRc:
path: "./vim.ts:loadVimRc"
command:
name: "Editor: Vim: Load VIMRC"
events:
- editor:modeswitch
brokenLinksCommand:
path: ./broken_links.ts:brokenLinksCommand
command:
name: "Broken Links: Show"
# Client mode
enableThinClient:
path: ./client.ts:setThinClient
command:
name: "Client: Enable Thin Client"
value: true
disableThinClient:
path: ./client.ts:setThinClient
command:
name: "Client: Disable Thin Client"
value: false
# Random stuff
statsCommand:
path: ./stats.ts:statsCommand
command:
name: "Stats: Show"
reloadUICommand:
path: ./debug.ts:reloadUICommand
command:
name: "Debug: Reload UI"
resetClientCommand:
path: ./debug.ts:resetClientCommand
command:
name: "Debug: Reset Client"
versionCommand:
path: ./help.ts:versionCommand
command:
name: "Help: Version"
gettingStartedCommand:
path: ./help.ts:gettingStartedCommand
command:
name: "Help: Getting Started"
accountLogoutCommand:
path: ./account.ts:accountLogoutCommand
command:
name: "Account: Logout"
+52
View File
@@ -0,0 +1,52 @@
import { clientStore, editor } from "$sb/syscalls.ts";
// Run on "editor:init"
export async function setEditorMode() {
if (await clientStore.get("vimMode")) {
await editor.setUiOption("vimMode", true);
}
if (await clientStore.get("darkMode")) {
await editor.setUiOption("darkMode", true);
}
}
export async function toggleDarkMode() {
let darkMode = await clientStore.get("darkMode");
darkMode = !darkMode;
await editor.setUiOption("darkMode", darkMode);
await clientStore.set("darkMode", darkMode);
}
export async function foldCommand() {
await editor.fold();
}
export async function unfoldCommand() {
await editor.unfold();
}
export async function toggleFoldCommand() {
await editor.toggleFold();
}
export async function foldAllCommand() {
await editor.foldAll();
}
export async function unfoldAllCommand() {
await editor.unfoldAll();
}
export async function centerCursorCommand() {
const pos = await editor.getCursor();
await editor.moveCursor(pos, true);
}
export async function moveToPosCommand() {
const posString = await editor.prompt("Move to position:");
if (!posString) {
return;
}
const pos = +posString;
await editor.moveCursor(pos);
}
+47
View File
@@ -0,0 +1,47 @@
import { YAML } from "$sb/syscalls.ts";
import type { WidgetContent } from "$sb/app_event.ts";
type EmbedConfig = {
url: string;
height?: number;
width?: number;
};
function extractYoutubeVideoId(url: string) {
let match = url.match(/youtube\.com\/watch\?v=([^&]+)/);
if (match) {
return match[1];
}
match = url.match(/youtu.be\/([^&]+)/);
if (match) {
return match[1];
}
return null;
}
export async function embedWidget(
bodyText: string,
): Promise<WidgetContent> {
try {
const data: EmbedConfig = await YAML.parse(bodyText) as any;
let url = data.url;
const youtubeVideoId = extractYoutubeVideoId(url);
if (youtubeVideoId) {
url = `https://www.youtube.com/embed/${youtubeVideoId}`;
// Sensible video defaults
data.width = data.width || 560;
data.height = data.height || 315;
}
return {
url,
height: data.height,
width: data.width,
};
} catch (e: any) {
return {
html: `ERROR: Could not parse body as YAML: ${e.message}`,
script: "",
};
}
}
+12
View File
@@ -0,0 +1,12 @@
import { editor } from "$sb/syscalls.ts";
import { version } from "../../version.ts";
export async function versionCommand() {
await editor.flashNotification(
`You are currently running SilverBullet ${version}`,
);
}
export async function gettingStartedCommand() {
await editor.openUrl("https://silverbullet.md/Getting%20Started");
}
+69
View File
@@ -0,0 +1,69 @@
import { nodeAtPos } from "$sb/lib/tree.ts";
import { editor, events, markdown } from "$sb/syscalls.ts";
type UnfurlOption = {
id: string;
name: string;
};
export async function unfurlCommand() {
const mdTree = await markdown.parseMarkdown(await editor.getText());
const nakedUrlNode = nodeAtPos(mdTree, await editor.getCursor());
const url = nakedUrlNode!.children![0].text!;
console.log("Got URL to unfurl", url);
const optionResponses = await events.dispatchEvent("unfurl:options", url);
const options: UnfurlOption[] = [];
for (const resp of optionResponses) {
options.push(...resp);
}
const selectedUnfurl: any = await editor.filterBox(
"Unfurl",
options,
"Select the unfurl strategy of your choice",
);
if (!selectedUnfurl) {
return;
}
try {
const replacement = await events.dispatchEvent(
`unfurl:${selectedUnfurl.id}`,
url,
);
if (replacement.length === 0) {
throw new Error("Unfurl failed");
}
await editor.replaceRange(
nakedUrlNode?.from!,
nakedUrlNode?.to!,
replacement[0],
);
} catch (e: any) {
await editor.flashNotification(e.message, "error");
}
}
export function titleUnfurlOptions(): UnfurlOption[] {
return [
{
id: "title-unfurl",
name: "Extract title",
},
];
}
const titleRegex = /<title[^>]*>\s*([^<]+)\s*<\/title\s*>/i;
export async function titleUnfurl(url: string): Promise<string> {
const 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}`);
}
const body = await response.text();
const match = titleRegex.exec(body);
if (match) {
return `[${match[1]}](${url})`;
} else {
throw new Error("No title found");
}
}
+116
View File
@@ -0,0 +1,116 @@
import type { ClickEvent } from "$sb/app_event.ts";
import { editor, markdown, system } from "$sb/syscalls.ts";
import {
addParentPointers,
findNodeOfType,
findParentMatching,
nodeAtPos,
ParseTree,
} from "$sb/lib/tree.ts";
import { resolvePath } from "$sb/lib/resolve.ts";
async function actionClickOrActionEnter(
mdTree: ParseTree | null,
inNewWindow = false,
) {
if (!mdTree) {
return;
}
const navigationNodeFinder = (t: ParseTree) =>
[
"WikiLink",
"Link",
"Image",
"URL",
"NakedURL",
"Link",
"CommandLink",
"PageRef",
]
.includes(
t.type!,
);
if (!navigationNodeFinder(mdTree)) {
mdTree = findParentMatching(mdTree, navigationNodeFinder);
if (!mdTree) {
return;
}
}
const currentPage = await editor.getCurrentPage();
switch (mdTree.type) {
case "WikiLink": {
let pageLink = mdTree.children![1]!.children![0].text!;
let pos;
if (pageLink.includes("@")) {
[pageLink, pos] = pageLink.split("@");
if (pos.match(/^\d+$/)) {
pos = +pos;
}
}
pageLink = resolvePath(currentPage, pageLink);
if (!pageLink) {
pageLink = currentPage;
}
await editor.navigate(pageLink, pos, false, inNewWindow);
break;
}
case "PageRef": {
const bracketedPageRef = mdTree.children![0].text!;
// Slicing off the initial [[ and final ]]
const pageName = bracketedPageRef.substring(
2,
bracketedPageRef.length - 2,
);
await editor.navigate(pageName, 0, false, inNewWindow);
break;
}
case "NakedURL":
await editor.openUrl(mdTree.children![0].text!);
break;
case "Image":
case "Link": {
const urlNode = findNodeOfType(mdTree, "URL");
if (!urlNode) {
return;
}
const url = urlNode.children![0].text!;
if (url.length <= 1) {
return editor.flashNotification("Empty link, ignoring", "error");
}
if (url.indexOf("://") === -1 && !url.startsWith("mailto:")) {
return editor.openUrl(resolvePath(currentPage, decodeURI(url)));
} else {
await editor.openUrl(url);
}
break;
}
case "CommandLink": {
const commandName = mdTree.children![1]!.children![0].text!;
await system.invokeCommand(commandName);
break;
}
}
}
export async function linkNavigate() {
const mdTree = await markdown.parseMarkdown(await editor.getText());
const newNode = nodeAtPos(mdTree, await editor.getCursor());
addParentPointers(mdTree);
await actionClickOrActionEnter(newNode);
}
export async function clickNavigate(event: ClickEvent) {
// Navigate by default, don't navigate when Alt is held
if (event.altKey) {
return;
}
const mdTree = await markdown.parseMarkdown(await editor.getText());
addParentPointers(mdTree);
const newNode = nodeAtPos(mdTree, event.pos);
await actionClickOrActionEnter(newNode, event.ctrlKey || event.metaKey);
}
export async function navigateCommand(cmdDef: any) {
await editor.navigate(cmdDef.page);
}
+102
View File
@@ -0,0 +1,102 @@
import type { CompleteEvent } from "$sb/app_event.ts";
import { editor, space } from "$sb/syscalls.ts";
import { cacheFileListing } from "../federation/federation.ts";
import type { PageMeta } from "../../web/types.ts";
export async function deletePage() {
const pageName = await editor.getCurrentPage();
if (
!await editor.confirm(`Are you sure you would like to delete ${pageName}?`)
) {
return;
}
console.log("Navigating to index page");
await editor.navigate("");
console.log("Deleting page from space");
await space.deletePage(pageName);
}
export async function copyPage() {
const oldName = await editor.getCurrentPage();
const newName = await editor.prompt(`New page title:`, `${oldName} (copy)`);
if (!newName) {
return;
}
try {
// This throws an error if the page does not exist, which we expect to be the case
await space.getPageMeta(newName);
// So when we get to this point, we error out
throw new Error(
`Page ${newName} already exists, cannot rename to existing page.`,
);
} catch (e: any) {
if (e.message === "Not found") {
// Expected not found error, so we can continue
} else {
await editor.flashNotification(e.message, "error");
throw e;
}
}
const text = await editor.getText();
console.log("Writing new page to space");
await space.writePage(newName, text);
console.log("Navigating to new page");
await editor.navigate(newName);
}
export async function newPageCommand() {
const allPages = await space.listPages();
let pageName = `Untitled`;
let i = 1;
while (allPages.find((p) => p.name === pageName)) {
pageName = `Untitled ${i}`;
i++;
}
await editor.navigate(pageName);
}
// Completion
export async function pageComplete(completeEvent: CompleteEvent) {
const match = /\[\[([^\]@:\{}]*)$/.exec(completeEvent.linePrefix);
if (!match) {
return null;
}
let allPages: PageMeta[] = await space.listPages();
const prefix = match[1];
if (prefix.startsWith("!")) {
// Federation prefix, let's first see if we're matching anything from federation that is locally synced
const prefixMatches = allPages.filter((pageMeta) =>
pageMeta.name.startsWith(prefix)
);
if (prefixMatches.length === 0) {
// Ok, nothing synced in via federation, let's see if this URI is complete enough to try to fetch index.json
if (prefix.includes("/")) {
// Yep
const domain = prefix.split("/")[0];
// Cached listing
allPages = (await cacheFileListing(domain)).filter((fm) =>
fm.name.endsWith(".md")
).map((fm) => ({
...fm,
name: fm.name.slice(0, -3),
}));
}
}
}
return {
from: completeEvent.pos - match[1].length,
options: allPages.map((pageMeta) => {
return {
label: pageMeta.name,
boost: pageMeta.lastModified,
type: "page",
};
}),
};
}
+21
View File
@@ -0,0 +1,21 @@
import { editor, space } from "$sb/syscalls.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 editor.getText();
const allPages = await space.listPages();
const wordCount = countWords(text);
const time = readingTime(wordCount);
await editor.flashNotification(
`${text.length} characters; ${wordCount} words; ${time} minutes read; ${allPages.length} total pages in space.`,
);
}
+142
View File
@@ -0,0 +1,142 @@
import { editor } from "$sb/syscalls.ts";
export async function quoteSelection() {
let text = await editor.getText();
const selection = await editor.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 editor.replaceRange(from, selection.to, text);
}
export async function listifySelection() {
let text = await editor.getText();
const selection = await editor.getSelection();
//if very first of doc, just add a bullet and end
if (selection.to == 0 && selection.from == 0) {
await editor.insertAtCursor("* ");
return;
}
let from = selection.from;
if (text[from] == "\n") {
//end of line, need to find previous line break
from--;
}
while (from >= 0 && text[from] !== "\n") {
from--;
}
from++;
text = text.slice(from, selection.to);
text = `* ${text.replaceAll(/\n(?!\n)/g, "\n* ")}`;
await editor.replaceRange(from, selection.to, text);
}
export async function numberListifySelection() {
let text = await editor.getText();
const selection = await editor.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 editor.replaceRange(from, selection.to, text);
}
export async function linkSelection() {
const text = await editor.getText();
const selection = await editor.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 editor.replaceRange(selection.from, selection.to, linkedText);
await editor.moveCursor(selection.from + pos);
}
export function wrapSelection(cmdDef: any) {
return insertMarker(cmdDef.wrapper);
}
async function insertMarker(marker: string) {
const text = await editor.getText();
const selection = await editor.getSelection();
if (selection.from === selection.to) {
// empty selection
if (markerAt(selection.from)) {
// Already there, skipping ahead
await editor.moveCursor(selection.from + marker.length);
} else {
// Not there, inserting
await editor.insertAtCursor(marker + marker);
await editor.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 editor.replaceRange(
selection.from,
selection.to,
marker + text.slice(selection.from, selection.to) + marker,
);
await editor.setSelection(
selection.from + marker.length,
selection.to + marker.length,
);
} else {
// Removing
await editor.replaceRange(
from,
to,
text.substring(from + marker.length, to - marker.length),
);
await editor.setSelection(from, to - marker.length * 2);
}
}
function markerAt(pos: number) {
for (let i = 0; i < marker.length; i++) {
if (text[pos + i] !== marker[i]) {
return false;
}
}
return true;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { readCodeBlockPage } from "$sb/lib/yaml_page.ts";
import { editor, store } from "$sb/syscalls.ts";
export async function toggleVimMode() {
let vimMode = await store.get("vimMode");
vimMode = !vimMode;
await editor.setUiOption("vimMode", vimMode);
await store.set("vimMode", vimMode);
}
export async function loadVimRc() {
const vimMode = await editor.getUiOption("vimMode");
if (!vimMode) {
console.log("Not in vim mode");
return;
}
try {
const vimRc = await readCodeBlockPage("VIMRC");
if (vimRc) {
console.log("Now running vim ex commands from VIMRC");
const lines = vimRc.split("\n");
for (const line of lines) {
try {
console.log("Running vim ex command", line);
await editor.vimEx(line);
} catch (e: any) {
await editor.flashNotification(e.message, "error");
}
}
}
} catch (e: any) {
// No VIMRC page found
}
}