1
0

Refactoring of offline handling

This commit is contained in:
Zef Hemel 2023-07-27 11:41:44 +02:00
parent 7b8d8af2c1
commit 4d0f36d475
13 changed files with 251 additions and 206 deletions

View File

@ -17,14 +17,19 @@ export class FallbackSpacePrimitives implements SpacePrimitives {
fetchFileList(): Promise<FileMeta[]> {
return this.primary.fetchFileList();
}
async readFile(name: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
try {
return await this.primary.readFile(name);
} catch (e) {
console.info(
`Could not read file ${name} from primary, trying fallback`,
e,
);
try {
return this.fallback.readFile(name);
} catch (fallbackError) {
console.error("Error during reaFile fallback", fallbackError);
return await this.fallback.readFile(name);
} catch (fallbackError: any) {
console.error("Error during readFile fallback", fallbackError);
// Fallback failed, so let's throw the original error
throw e;
}
@ -34,8 +39,12 @@ export class FallbackSpacePrimitives implements SpacePrimitives {
try {
return await this.primary.getFileMeta(name);
} catch (e) {
console.info(
`Could not fetch file ${name} metadata from primary, trying fallback`,
e,
);
try {
return this.fallback.getFileMeta(name);
return await this.fallback.getFileMeta(name);
} catch (fallbackError) {
console.error("Error during getFileMeta fallback", fallbackError);
// Fallback failed, so let's throw the original error

View File

@ -21,17 +21,35 @@ export class HttpSpacePrimitives implements SpacePrimitives {
options.headers = { ...options.headers, ...{ "X-Sync-Mode": "true" } };
}
const result = await fetch(url, options);
if (result.redirected) {
// Got a redirect, we'll assume this is due to invalid credentials and redirecting to an auth page
console.log(
"Got a redirect via the API so will redirect to URL",
result.url,
);
location.href = result.url;
throw new Error("Invalid credentials");
try {
const result = await fetch(url, options);
if (result.status === 503) {
throw new Error("Offline");
}
if (result.redirected) {
// Got a redirect, we'll assume this is due to invalid credentials and redirecting to an auth page
console.log(
"Got a redirect via the API so will redirect to URL",
result.url,
);
location.href = result.url;
throw new Error("Invalid credentials");
}
return result;
} catch (e: any) {
// Firefox: NetworkError when attempting to fetch resource (with SW and without)
// Safari: FetchEvent.respondWith received an error: TypeError: Load failed (service worker)
// Safari: Load failed (no service worker)
// Chrome: Failed to fetch (with service worker and without)
// Common substrings: "fetch" "load failed"
const errorMessage = e.message.toLowerCase();
if (
errorMessage.includes("fetch") || errorMessage.includes("load failed")
) {
throw new Error("Offline");
}
throw e;
}
return result;
}
async fetchFileList(): Promise<FileMeta[]> {

View File

@ -49,29 +49,31 @@ declare global {
// TODO: Oh my god, need to refactor this
export class Client {
editorView?: EditorView;
pageNavigator?: PathPageNavigator;
system: ClientSystem;
space: Space;
editorView: EditorView;
remoteSpacePrimitives: HttpSpacePrimitives;
plugSpaceRemotePrimitives: PlugSpacePrimitives;
private pageNavigator!: PathPageNavigator;
private dbPrefix: string;
plugSpaceRemotePrimitives!: PlugSpacePrimitives;
localSpacePrimitives!: FilteredSpacePrimitives;
remoteSpacePrimitives!: HttpSpacePrimitives;
space!: Space;
saveTimeout?: number;
debouncedUpdateEvent = throttle(() => {
this.eventHook
.dispatchEvent("editor:updated")
.catch((e) => console.error("Error dispatching editor:updated event", e));
}, 1000);
system: ClientSystem;
// Track if plugs have been updated since sync cycle
fullSyncCompleted = false;
// Runtime state (that doesn't make sense in viewState)
syncService: SyncService;
settings?: BuiltinSettings;
settings!: BuiltinSettings;
kvStore: DexieKVStore;
// Event bus used to communicate between components
@ -83,13 +85,11 @@ export class Client {
constructor(
parent: Element,
) {
const runtimeConfig = window.silverBulletConfig;
// Generate a semi-unique prefix for the database so not to reuse databases for different space paths
const dbPrefix = "" + simpleHash(window.silverBulletConfig.spaceFolderPath);
this.dbPrefix = "" + simpleHash(window.silverBulletConfig.spaceFolderPath);
this.kvStore = new DexieKVStore(
`${dbPrefix}_store`,
`${this.dbPrefix}_store`,
"data",
globalThis.indexedDB,
);
@ -101,55 +101,14 @@ export class Client {
this.system = new ClientSystem(
this,
this.kvStore,
dbPrefix,
this.dbPrefix,
this.eventHook,
);
// Setup space
this.remoteSpacePrimitives = new HttpSpacePrimitives(
location.origin,
runtimeConfig.spaceFolderPath,
true,
);
this.plugSpaceRemotePrimitives = new PlugSpacePrimitives(
this.remoteSpacePrimitives,
this.system.namespaceHook,
);
let fileFilterFn: (s: string) => boolean = () => true;
const localSpacePrimitives = new FilteredSpacePrimitives(
new FileMetaSpacePrimitives(
new EventedSpacePrimitives(
// Using fallback space primitives here to allow (by default) local reads to "fall through" to HTTP when files aren't synced yet
new FallbackSpacePrimitives(
new IndexedDBSpacePrimitives(
`${dbPrefix}_space`,
globalThis.indexedDB,
),
this.plugSpaceRemotePrimitives,
),
this.eventHook,
),
this.system.indexSyscalls,
),
(meta) => fileFilterFn(meta.name),
async () => {
await this.loadSettings();
if (typeof this.settings?.spaceIgnore === "string") {
fileFilterFn = gitIgnoreCompiler(this.settings.spaceIgnore).accepts;
} else {
fileFilterFn = () => true;
}
},
);
this.space = new Space(localSpacePrimitives, this.kvStore);
this.space.watch();
this.initSpace();
this.syncService = new SyncService(
localSpacePrimitives,
this.localSpacePrimitives,
this.plugSpaceRemotePrimitives,
this.kvStore,
this.eventHook,
@ -172,105 +131,33 @@ export class Client {
});
this.openPages = new OpenPages(this.editorView);
}
get currentPage(): string | undefined {
return this.ui.viewState.currentPage;
}
async init() {
this.focus();
this.space.on({
pageChanged: (meta) => {
// Only reload when watching the current page (to avoid reloading when switching pages)
if (this.space.watchInterval && this.currentPage === meta.name) {
console.log("Page changed elsewhere, reloading");
this.flashNotification("Page changed elsewhere, reloading");
this.reloadPage();
}
},
pageListUpdated: (pages) => {
this.ui.viewDispatch({
type: "pages-listed",
pages: pages,
});
},
});
// This constructor will always be followed by an invocatition of init()
}
/**
* Initialize the client
* This is a separated from the constructor to allow for async initialization
*/
async init() {
// Load settings
this.settings = await this.loadSettings();
this.pageNavigator = new PathPageNavigator(
this.settings.indexPage,
);
this.initNavigator();
// Pinging a remote space to ensure we're authenticated properly, if not will result in a redirect to auth page
try {
await this.remoteSpacePrimitives.getFileMeta("SETTINGS");
} catch {
console.info(
console.warn(
"Could not reach remote server, either we're offline or not authenticated",
);
}
await this.reloadPlugs();
this.pageNavigator.subscribe(async (pageName, pos: number | string) => {
console.log("Now navigating to", pageName);
if (!this.editorView) {
return;
}
const stateRestored = await this.loadPage(pageName);
if (pos) {
if (typeof pos === "string") {
console.log("Navigating to anchor", pos);
// We're going to look up the anchor through a direct page store query...
// TODO: This should be extracted
const posLookup = await this.system.localSyscall(
"index.get",
[
pageName,
`a:${pageName}:${pos}`,
],
);
if (!posLookup) {
return this.flashNotification(
`Could not find anchor @${pos}`,
"error",
);
} else {
pos = +posLookup;
}
}
this.editorView.dispatch({
selection: { anchor: pos },
effects: EditorView.scrollIntoView(pos, { y: "start" }),
});
} else if (!stateRestored) {
// Somewhat ad-hoc way to determine if the document contains frontmatter and if so, putting the cursor _after it_.
const pageText = this.editorView.state.sliceDoc();
// Default the cursor to be at position 0
let initialCursorPos = 0;
const match = frontMatterRegex.exec(pageText);
if (match) {
// Frontmatter found, put cursor after it
initialCursorPos = match[0].length;
}
// By default scroll to the top
this.editorView.scrollDOM.scrollTop = 0;
this.editorView.dispatch({
selection: { anchor: initialCursorPos },
// And then scroll down if required
scrollIntoView: true,
});
}
});
this.loadCustomStyles().catch(console.error);
// Kick off background sync
@ -319,13 +206,134 @@ export class Client {
await this.dispatchAppEvent("editor:init");
}
private initNavigator() {
this.pageNavigator = new PathPageNavigator(
this.settings.indexPage,
);
this.pageNavigator.subscribe(async (pageName, pos: number | string) => {
console.log("Now navigating to", pageName);
const stateRestored = await this.loadPage(pageName);
if (pos) {
if (typeof pos === "string") {
console.log("Navigating to anchor", pos);
// We're going to look up the anchor through a direct page store query...
// TODO: This should be extracted
const posLookup = await this.system.localSyscall(
"index.get",
[
pageName,
`a:${pageName}:${pos}`,
],
);
if (!posLookup) {
return this.flashNotification(
`Could not find anchor @${pos}`,
"error",
);
} else {
pos = +posLookup;
}
}
this.editorView.dispatch({
selection: { anchor: pos },
effects: EditorView.scrollIntoView(pos, { y: "start" }),
});
} else if (!stateRestored) {
// Somewhat ad-hoc way to determine if the document contains frontmatter and if so, putting the cursor _after it_.
const pageText = this.editorView.state.sliceDoc();
// Default the cursor to be at position 0
let initialCursorPos = 0;
const match = frontMatterRegex.exec(pageText);
if (match) {
// Frontmatter found, put cursor after it
initialCursorPos = match[0].length;
}
// By default scroll to the top
this.editorView.scrollDOM.scrollTop = 0;
this.editorView.dispatch({
selection: { anchor: initialCursorPos },
// And then scroll down if required
scrollIntoView: true,
});
}
});
}
initSpace() {
this.remoteSpacePrimitives = new HttpSpacePrimitives(
location.origin,
window.silverBulletConfig.spaceFolderPath,
true,
);
this.plugSpaceRemotePrimitives = new PlugSpacePrimitives(
this.remoteSpacePrimitives,
this.system.namespaceHook,
);
let fileFilterFn: (s: string) => boolean = () => true;
this.localSpacePrimitives = new FilteredSpacePrimitives(
new FileMetaSpacePrimitives(
new EventedSpacePrimitives(
// Using fallback space primitives here to allow (by default) local reads to "fall through" to HTTP when files aren't synced yet
new FallbackSpacePrimitives(
new IndexedDBSpacePrimitives(
`${this.dbPrefix}_space`,
globalThis.indexedDB,
),
this.plugSpaceRemotePrimitives,
),
this.eventHook,
),
this.system.indexSyscalls,
),
(meta) => fileFilterFn(meta.name),
async () => {
// Run when a list of files has been retrieved
await this.loadSettings();
if (typeof this.settings?.spaceIgnore === "string") {
fileFilterFn = gitIgnoreCompiler(this.settings.spaceIgnore).accepts;
} else {
fileFilterFn = () => true;
}
},
);
this.space = new Space(this.localSpacePrimitives, this.kvStore);
this.space.on({
pageChanged: (meta) => {
// Only reload when watching the current page (to avoid reloading when switching pages)
if (this.space.watchInterval && this.currentPage === meta.name) {
console.log("Page changed elsewhere, reloading");
this.flashNotification("Page changed elsewhere, reloading");
this.reloadPage();
}
},
pageListUpdated: (pages) => {
this.ui.viewDispatch({
type: "pages-listed",
pages: pages,
});
},
});
this.space.watch();
}
async loadSettings(): Promise<BuiltinSettings> {
let settingsText: string | undefined;
try {
settingsText = (await this.space.readPage("SETTINGS")).text;
} catch {
console.log("No SETTINGS page, falling back to default");
} catch (e) {
console.info("No SETTINGS page, falling back to default", e);
settingsText = "```yaml\nindexPage: index\n```\n";
}
const settings = parseYamlSettings(settingsText!) as BuiltinSettings;
@ -336,6 +344,10 @@ export class Client {
return settings;
}
get currentPage(): string | undefined {
return this.ui.viewState.currentPage;
}
save(immediate = false): Promise<void> {
return new Promise((resolve, reject) => {
if (this.saveTimeout) {
@ -355,7 +367,7 @@ export class Client {
this.space
.writePage(
this.currentPage,
this.editorView!.state.sliceDoc(0),
this.editorView.state.sliceDoc(0),
true,
)
.then(async (meta) => {
@ -583,7 +595,7 @@ export class Client {
// Some other modal UI element is visible, don't focus editor now
return;
}
this.editorView!.focus();
this.editorView.focus();
}
async navigate(
@ -616,10 +628,6 @@ export class Client {
async loadPage(pageName: string): Promise<boolean> {
const loadingDifferentPage = pageName !== this.currentPage;
const editorView = this.editorView;
if (!editorView) {
return false;
}
const previousPage = this.currentPage;
// Persist current page state and nicely close page
@ -644,12 +652,20 @@ export class Client {
throw new Error("Got HTML page, not markdown");
}
} catch (e: any) {
// Not found, new page
console.log("Creating new page", pageName);
doc = {
text: "",
meta: { name: pageName, lastModified: 0, perm: "rw" } as PageMeta,
};
if (e.message.includes("Not found")) {
// Not found, new page
console.log("Page doesn't exist, creating new page:", pageName);
doc = {
text: "",
meta: { name: pageName, lastModified: 0, perm: "rw" } as PageMeta,
};
} else {
console.error("Could not load page", pageName, e);
doc = {
text: `**ERROR**: ${e.message}`,
meta: { name: pageName, lastModified: 0, perm: "ro" } as PageMeta,
};
}
}
const editorState = createEditorState(
@ -737,7 +753,7 @@ export class Client {
}
getContext(): string | undefined {
const state = this.editorView!.state;
const state = this.editorView.state;
const selection = state.selection.main;
if (selection.empty) {
return syntaxTree(state).resolveInner(selection.from).type.name;

View File

@ -170,7 +170,7 @@ export function admonitionPlugin(editor: Client) {
widget: new AdmonitionIconWidget(
iconRange.from + 1,
admonitionType,
editor.editorView!,
editor.editorView,
),
inclusive: true,
}).range(iconRange.from, iconRange.to),

View File

@ -49,7 +49,7 @@ export function cleanCommandLinkPlugin(editor: Client) {
(e) => {
if (e.altKey) {
// Move cursor into the link
return editor.editorView!.dispatch({
return editor.editorView.dispatch({
selection: { anchor: from + 2 },
});
}

View File

@ -118,15 +118,15 @@ export function attachmentExtension(editor: Client) {
// Only do rich text paste if shift is NOT down
if (richText && !shiftDown) {
// Are we in a fencede code block?
const editorText = editor.editorView!.state.sliceDoc();
const editorText = editor.editorView.state.sliceDoc();
const tree = lezerToParseTree(
editorText,
syntaxTree(editor.editorView!.state).topNode,
syntaxTree(editor.editorView.state).topNode,
);
addParentPointers(tree);
const currentNode = nodeAtPos(
tree,
editor.editorView!.state.selection.main.from,
editor.editorView.state.selection.main.from,
);
if (currentNode) {
const fencedParentNode = findParentMatching(
@ -141,7 +141,7 @@ export function attachmentExtension(editor: Client) {
const markdown = striptHtmlComments(turndownService.turndown(richText))
.trim();
const view = editor.editorView!;
const view = editor.editorView;
const selection = view.state.selection.main;
view.dispatch({
changes: [
@ -221,11 +221,11 @@ export function attachmentExtension(editor: Client) {
if (mimeType.startsWith("image/")) {
attachmentMarkdown = `![](${encodeURI(finalFileName)})`;
}
editor.editorView!.dispatch({
editor.editorView.dispatch({
changes: [
{
insert: attachmentMarkdown,
from: editor.editorView!.state.selection.main.from,
from: editor.editorView.state.selection.main.from,
},
],
});

View File

@ -41,7 +41,7 @@ class IFrameWidget extends WidgetType {
iframe.style.height = data.height + "px";
break;
case "setBody":
this.editor.editorView!.dispatch({
this.editor.editorView.dispatch({
changes: {
from: this.from,
to: this.to,
@ -50,7 +50,7 @@ class IFrameWidget extends WidgetType {
});
break;
case "blur":
this.editor.editorView!.dispatch({
this.editor.editorView.dispatch({
selection: { anchor: this.from },
});
this.editor.focus();

View File

@ -26,7 +26,7 @@ class TableViewWidget extends WidgetType {
// Pulling data-pos to put the cursor in the right place, falling back
// to the start of the table.
const dataAttributes = (e.target as any).dataset;
this.editor.editorView!.dispatch({
this.editor.editorView.dispatch({
selection: {
anchor: dataAttributes.pos ? +dataAttributes.pos : this.pos,
},

View File

@ -90,7 +90,7 @@ export function cleanWikiLinkPlugin(editor: Client) {
callback: (e) => {
if (e.altKey) {
// Move cursor into the link
return editor.editorView!.dispatch({
return editor.editorView.dispatch({
selection: { anchor: from + 2 },
});
}

View File

@ -26,12 +26,12 @@ export class MainUI {
constructor(private editor: Client) {
// Make keyboard shortcuts work even when the editor is in read only mode or not focused
globalThis.addEventListener("keydown", (ev) => {
if (!editor.editorView?.hasFocus) {
if (!editor.editorView.hasFocus) {
if ((ev.target as any).closest(".cm-editor")) {
// In some cm element, let's back out
return;
}
if (runScopeHandlers(editor.editorView!, ev, "editor")) {
if (runScopeHandlers(editor.editorView, ev, "editor")) {
ev.preventDefault();
}
}
@ -70,11 +70,9 @@ export class MainUI {
}, [viewState.currentPage]);
useEffect(() => {
if (editor.editorView) {
editor.tweakEditorDOM(
editor.editorView.contentDOM,
);
}
editor.tweakEditorDOM(
editor.editorView.contentDOM,
);
}, [viewState.uiOptions.forcedROMode]);
useEffect(() => {
@ -190,7 +188,7 @@ export class MainUI {
onRename={async (newName) => {
if (!newName) {
// Always move cursor to the start of the page
editor.editorView?.dispatch({
editor.editorView.dispatch({
selection: { anchor: 0 },
});
editor.focus();

View File

@ -75,7 +75,7 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
boost: def.slashCommand.boost,
apply: () => {
// Delete slash command part
this.editor.editorView?.dispatch({
this.editor.editorView.dispatch({
changes: {
from: prefix!.from + prefixText.indexOf("/"),
to: ctx.pos,

View File

@ -106,7 +106,12 @@ self.addEventListener("fetch", (event: any) => {
// Must be a page URL, let's serve index.html which will handle it
return (await caches.match(precacheFiles["/"])) || fetch(request);
}
})(),
})().catch((e) => {
console.warn("[Service worker]", "Fetch failed:", e);
return new Response("Offline", {
status: 503, // Service Unavailable
});
}),
);
});

View File

@ -19,13 +19,13 @@ export function editorSyscalls(editor: Client): SysCallMapping {
return editor.currentPage!;
},
"editor.getText": () => {
return editor.editorView?.state.sliceDoc();
return editor.editorView.state.sliceDoc();
},
"editor.getCursor": (): number => {
return editor.editorView!.state.selection.main.from;
return editor.editorView.state.selection.main.from;
},
"editor.getSelection": (): { from: number; to: number } => {
return editor.editorView!.state.selection.main;
return editor.editorView.state.selection.main;
},
"editor.save": () => {
return editor.save(true);
@ -97,7 +97,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
});
},
"editor.insertAtPos": (_ctx, text: string, pos: number) => {
editor.editorView!.dispatch({
editor.editorView.dispatch({
changes: {
insert: text,
from: pos,
@ -105,7 +105,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
});
},
"editor.replaceRange": (_ctx, from: number, to: number, text: string) => {
editor.editorView!.dispatch({
editor.editorView.dispatch({
changes: {
insert: text,
from: from,
@ -114,13 +114,13 @@ export function editorSyscalls(editor: Client): SysCallMapping {
});
},
"editor.moveCursor": (_ctx, pos: number, center = false) => {
editor.editorView!.dispatch({
editor.editorView.dispatch({
selection: {
anchor: pos,
},
});
if (center) {
editor.editorView!.dispatch({
editor.editorView.dispatch({
effects: [
EditorView.scrollIntoView(
pos,
@ -133,8 +133,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
}
},
"editor.setSelection": (_ctx, from: number, to: number) => {
const editorView = editor.editorView!;
editorView.dispatch({
editor.editorView.dispatch({
selection: {
anchor: from,
head: to,
@ -143,7 +142,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
},
"editor.insertAtCursor": (_ctx, text: string) => {
const editorView = editor.editorView!;
const editorView = editor.editorView;
const from = editorView.state.selection.main.from;
editorView.dispatch({
changes: {
@ -156,7 +155,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
});
},
"editor.dispatch": (_ctx, change: Transaction) => {
editor.editorView!.dispatch(change);
editor.editorView.dispatch(change);
},
"editor.prompt": (
_ctx,
@ -182,7 +181,7 @@ export function editorSyscalls(editor: Client): SysCallMapping {
});
},
"editor.vimEx": (_ctx, exCommand: string) => {
const cm = vimGetCm(editor.editorView!)!;
const cm = vimGetCm(editor.editorView)!;
return Vim.handleEx(cm, exCommand);
},
// Sync
@ -191,19 +190,19 @@ export function editorSyscalls(editor: Client): SysCallMapping {
},
// Folding
"editor.fold": () => {
foldCode(editor.editorView!);
foldCode(editor.editorView);
},
"editor.unfold": () => {
unfoldCode(editor.editorView!);
unfoldCode(editor.editorView);
},
"editor.toggleFold": () => {
toggleFold(editor.editorView!);
toggleFold(editor.editorView);
},
"editor.foldAll": () => {
foldAll(editor.editorView!);
foldAll(editor.editorView);
},
"editor.unfoldAll": () => {
unfoldAll(editor.editorView!);
unfoldAll(editor.editorView);
},
};