refactor: generalize saving and view logic for more consistent and easy

to manage logic, extract editor scroll tracking, fix content component
and replaceable component logic
This commit is contained in:
Jacobtread 2026-04-11 23:47:15 +12:00
parent f71fb6685f
commit 490e9102b8
29 changed files with 708 additions and 620 deletions

View file

@ -6,7 +6,6 @@ import { createMockContainer } from "@/__mocks__/obsidian";
import { ContentComponent } from "./ContentComponent";
import { DomComponent } from "@/components/DomComponent";
import { ReplaceableComponent } from "@/components/ReplaceableComponent";
describe("ContentComponent", () => {
@ -24,29 +23,31 @@ describe("ContentComponent", () => {
});
it("should be able to set content to different components", () => {
class FirstComponent extends DomComponent {
class FirstComponent extends ReplaceableComponent {
constructor(containerEl: HTMLElement) {
super(containerEl);
}
onload(): void {
super.onload();
createContainer(): HTMLElement {
return createDiv();
}
const wrapperEl = this.containerEl.createSpan({ text: "First" });
this.wrapperEl = wrapperEl;
render(containerEl: HTMLElement): void {
containerEl.createSpan({ text: "First" });
}
}
class SecondComponent extends DomComponent {
class SecondComponent extends ReplaceableComponent {
constructor(containerEl: HTMLElement) {
super(containerEl);
}
onload(): void {
super.onload();
createContainer(): HTMLElement {
return createDiv();
}
const wrapperEl = this.containerEl.createSpan({ text: "Second" });
this.wrapperEl = wrapperEl;
render(containerEl: HTMLElement): void {
containerEl.createSpan({ text: "Second" });
}
}
@ -58,10 +59,6 @@ describe("ContentComponent", () => {
comp.setContent(new SecondComponent(container));
expect(comp.containerEl!.children[0].textContent).toBe("Second");
comp.setContent(undefined);
expect(comp.getContent()).toBeUndefined();
expect(comp.containerEl!.children.length).toBe(0);
});
it("using two replaceable component should trigger the previous", () => {

View file

@ -1,17 +1,15 @@
import type { Component } from "obsidian";
import { DomComponent } from "@/components/DomComponent";
import { ReplaceableComponent } from "@/components/ReplaceableComponent";
export class ContentComponent<C extends Component> extends DomComponent {
#content: C | undefined = undefined;
export class ContentComponent<C extends ReplaceableComponent> extends DomComponent {
content: C | undefined = undefined;
constructor(containerEl: HTMLElement) {
super(containerEl);
}
getContent(): C | undefined {
return this.#content;
return this.content;
}
/**
@ -19,32 +17,28 @@ export class ContentComponent<C extends Component> extends DomComponent {
*
* @param content The new content to show
*/
setContent(content: C | undefined) {
let previousContent: C | undefined = this.#content;
setContent(content: C) {
let previousContent: C | undefined = this.content;
let previousElement: HTMLElement | undefined;
if (previousContent) {
// Mark for replacement if both are replaceable components
if (
content instanceof ReplaceableComponent &&
previousContent instanceof ReplaceableComponent
) {
previousElement = previousContent.wrapperEl;
previousContent.skipUnloadReplace = true;
}
previousElement = previousContent.wrapperEl;
previousContent.skipUnloadReplace = true;
this.removeChild(previousContent);
}
this.#content = content;
this.content = content;
// Set the previous element to be replaced by the new content
if (content instanceof ReplaceableComponent && previousElement) {
if (previousElement !== undefined) {
content.previousElement = previousElement;
}
if (content !== undefined) {
this.addChild(content);
}
this.addChild(content);
}
isForcedUnload(): boolean {
return true;
}
}

View file

@ -43,7 +43,10 @@ export class DomComponent extends Component {
}
isUnloadSkipped() {
return this.parent !== null && this.parent.unmounted;
if (this.parent === null) return false;
if (this.parent.isForcedUnload()) return false;
return this.parent.unmounted;
}
unload(): void {
@ -55,9 +58,6 @@ export class DomComponent extends Component {
onunload(): void {
super.onunload();
// Mark ourselves as unmounted
this.unmounted = true;
// Skip unmounting from the DOM if the parent is already unmounted
if (this.isUnloadSkipped()) {
return;
@ -66,4 +66,15 @@ export class DomComponent extends Component {
// Remove our wrapper if the parent is not unmounted already
this.wrapperEl?.remove();
}
/**
* Whether to force the unloading of children, used by ContentComponent
* which itself does not detach anything from the DOM instead relying
* on the underlying ReplaceableComponent for DOM content
*
* @returns Whether the unload of children should be forced
*/
isForcedUnload() {
return false;
}
}

View file

@ -0,0 +1,22 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from "vitest";
import { createMockContainer } from "@/__mocks__/obsidian";
import { EmptyComponent } from "./EmptyComponent";
describe("EmptyComponent", () => {
let container: HTMLElement;
let component: EmptyComponent;
beforeEach(() => {
container = createMockContainer();
component = new EmptyComponent(container);
});
it("should load without error", () => {
expect(() => component.load()).not.toThrow();
expect(container.children.length).toBe(1);
});
});

View file

@ -0,0 +1,13 @@
import { ReplaceableComponent } from "../ReplaceableComponent";
export class EmptyComponent extends ReplaceableComponent {
constructor(containerEl: HTMLElement) {
super(containerEl);
}
createContainer(): HTMLElement {
return createDiv();
}
render(_wrapperEl: HTMLElement): void {}
}

View file

@ -0,0 +1 @@
export { EmptyComponent } from "./EmptyComponent";

View file

@ -24,7 +24,6 @@ export abstract class ReplaceableComponent extends DomComponent {
}
this.wrapperEl = wrapperEl;
super.onload();
this.render(wrapperEl);

View file

@ -2,7 +2,7 @@
import type { App } from "obsidian";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import type { CustomOutputFormat } from "@/output";
@ -12,9 +12,6 @@ import { createStore, type Store } from "@/store";
import { Timesheet } from "./Timesheet";
import { TimesheetApp } from "@/components/TimesheetApp";
import { TimesheetSaveError } from "@/components/TimesheetSaveError";
import type { Timekeep } from "@/timekeep/schema";
import { TimekeepAutocomplete } from "@/service/autocomplete";
@ -25,52 +22,33 @@ describe("Timesheet", () => {
let vault: MockVault;
let app: App;
let timekeep: Store<Timekeep>;
let saveError: Store<boolean>;
let settings: Store<TimekeepSettings>;
let customOutputFormats: Store<Record<string, CustomOutputFormat>>;
let registry: TimekeepRegistry;
let autocomplete: TimekeepAutocomplete;
let component: Timesheet;
let handleSaveTimekeep: Mock<(value: Timekeep) => Promise<void>>;
beforeEach(() => {
app = {} as App;
timekeep = createStore({ entries: [] });
saveError = createStore(false);
vault = new MockVault();
containerEl = createMockContainer();
settings = createStore(defaultSettings);
customOutputFormats = createStore({});
registry = new TimekeepRegistry(vault.asVault(), settings);
autocomplete = new TimekeepAutocomplete(registry, settings);
handleSaveTimekeep = vi.fn();
component = new Timesheet(
containerEl,
app,
timekeep,
saveError,
settings,
customOutputFormats,
autocomplete,
handleSaveTimekeep
autocomplete
);
});
it("should load without error", () => {
expect(() => component.load()).not.toThrow();
});
it("default content should be the timesheet app", () => {
component.load();
expect(component.getContent()).toBeInstanceOf(TimesheetApp);
});
it("on save error the content should be a save error message", () => {
component.load();
saveError.setState(true);
expect(component.getContent()).toBeInstanceOf(TimesheetSaveError);
});
});

View file

@ -1,28 +1,29 @@
import type { App } from "obsidian";
import { App } from "obsidian";
import type { CustomOutputFormat } from "@/output";
import type { TimekeepSettings } from "@/settings";
import type { Store } from "@/store";
import { CustomOutputFormat } from "@/output";
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { ContentComponent } from "@/components/ContentComponent";
import { TimesheetApp } from "@/components/TimesheetApp";
import { TimesheetSaveError } from "@/components/TimesheetSaveError";
import { ReplaceableComponent } from "../ReplaceableComponent";
import type { Timekeep } from "@/timekeep/schema";
import { TimesheetCounters } from "@/components/TimesheetCounters";
import { TimesheetExportActions } from "@/components/TimesheetExportActions";
import { TimesheetRunningEntry } from "@/components/TimesheetRunningEntry";
import { TimesheetStartForm } from "@/components/TimesheetStartForm";
import { TimesheetTable } from "@/components/TimesheetTable";
import { Timekeep } from "@/timekeep/schema";
import { TimekeepAutocomplete } from "@/service/autocomplete";
/**
* Wrapper component for the timesheet app that handles
* display error messages when saving fails
* View component for the timesheet app as a whole
*/
export class Timesheet extends ContentComponent<TimesheetApp | TimesheetSaveError> {
export class Timesheet extends ReplaceableComponent {
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Store for save error state */
saveError: Store<boolean>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Access to custom output formats */
@ -30,56 +31,55 @@ export class Timesheet extends ContentComponent<TimesheetApp | TimesheetSaveErro
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
/** Callback to save the timekeep */
handleSaveTimekeep: (value: Timekeep) => Promise<void>;
constructor(
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
saveError: Store<boolean>,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
autocomplete: TimekeepAutocomplete,
handleSaveTimekeep: (value: Timekeep) => Promise<void>
autocomplete: TimekeepAutocomplete
) {
super(containerEl);
this.app = app;
this.timekeep = timekeep;
this.saveError = saveError;
this.settings = settings;
this.customOutputFormats = customOutputFormats;
this.autocomplete = autocomplete;
this.handleSaveTimekeep = handleSaveTimekeep;
}
onload(): void {
super.onload();
const render = this.update.bind(this);
const unsubscribeSaveError = this.saveError.subscribe(render);
this.register(unsubscribeSaveError);
render();
createContainer(): HTMLElement {
return createDiv({
cls: "timekeep-container",
});
}
update() {
const saveError = this.saveError.getState();
if (saveError) {
this.setContent(
new TimesheetSaveError(this.containerEl, this.timekeep, this.handleSaveTimekeep)
);
} else {
this.setContent(
new TimesheetApp(
this.containerEl,
this.app,
this.timekeep,
this.settings,
this.customOutputFormats,
this.autocomplete
)
);
}
render(wrapperEl: HTMLElement): void {
const counters = new TimesheetCounters(wrapperEl, this.settings, this.timekeep);
const startForm = new TimesheetStartForm(
wrapperEl,
this.timekeep,
this.settings,
this.autocomplete
);
const runningEntry = new TimesheetRunningEntry(wrapperEl, this.timekeep, this.settings);
const table = new TimesheetTable(wrapperEl, this.app, this.timekeep, this.settings);
const exportActions = new TimesheetExportActions(
wrapperEl,
this.app,
this.timekeep,
this.settings,
this.customOutputFormats
);
this.addChild(counters);
this.addChild(runningEntry);
this.addChild(startForm);
this.addChild(table);
this.addChild(exportActions);
}
}

View file

@ -1,54 +0,0 @@
// @vitest-environment happy-dom
import type { App } from "obsidian";
import { beforeEach, describe, expect, it } from "vitest";
import type { CustomOutputFormat } from "@/output";
import { createMockContainer, MockVault } from "@/__mocks__/obsidian";
import { defaultSettings, type TimekeepSettings } from "@/settings";
import { createStore, type Store } from "@/store";
import { TimesheetApp } from "./TimesheetApp";
import type { Timekeep } from "@/timekeep/schema";
import { TimekeepAutocomplete } from "@/service/autocomplete";
import { TimekeepRegistry } from "@/service/registry";
describe("TimesheetApp", () => {
let containerEl: HTMLElement;
let vault: MockVault;
let app: App;
let timekeep: Store<Timekeep>;
let settings: Store<TimekeepSettings>;
let customOutputFormats: Store<Record<string, CustomOutputFormat>>;
let registry: TimekeepRegistry;
let autocomplete: TimekeepAutocomplete;
let component: TimesheetApp;
beforeEach(() => {
app = {} as App;
timekeep = createStore({ entries: [] });
vault = new MockVault();
containerEl = createMockContainer();
settings = createStore(defaultSettings);
customOutputFormats = createStore({});
registry = new TimekeepRegistry(vault.asVault(), settings);
autocomplete = new TimekeepAutocomplete(registry, settings);
component = new TimesheetApp(
containerEl,
app,
timekeep,
settings,
customOutputFormats,
autocomplete
);
});
it("should load without error", () => {
expect(() => component.load()).not.toThrow();
});
});

View file

@ -1,86 +0,0 @@
import { App } from "obsidian";
import { CustomOutputFormat } from "@/output";
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { DomComponent } from "@/components/DomComponent";
import { TimesheetCounters } from "@/components/TimesheetCounters";
import { TimesheetExportActions } from "@/components/TimesheetExportActions";
import { TimesheetRunningEntry } from "@/components/TimesheetRunningEntry";
import { TimesheetStartForm } from "@/components/TimesheetStartForm";
import { TimesheetTable } from "@/components/TimesheetTable";
import { Timekeep } from "@/timekeep/schema";
import { TimekeepAutocomplete } from "@/service/autocomplete";
/**
* View component for the timesheet app as a whole
*/
export class TimesheetApp extends DomComponent {
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Access to custom output formats */
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
constructor(
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
autocomplete: TimekeepAutocomplete
) {
super(containerEl);
this.app = app;
this.timekeep = timekeep;
this.settings = settings;
this.customOutputFormats = customOutputFormats;
this.autocomplete = autocomplete;
}
onload(): void {
super.onload();
const wrapperEl = this.containerEl.createDiv({
cls: "timekeep-container",
});
this.wrapperEl = wrapperEl;
const counters = new TimesheetCounters(wrapperEl, this.settings, this.timekeep);
const startForm = new TimesheetStartForm(
wrapperEl,
this.timekeep,
this.settings,
this.autocomplete
);
const runningEntry = new TimesheetRunningEntry(wrapperEl, this.timekeep, this.settings);
const table = new TimesheetTable(wrapperEl, this.app, this.timekeep, this.settings);
const exportActions = new TimesheetExportActions(
wrapperEl,
this.app,
this.timekeep,
this.settings,
this.customOutputFormats
);
this.addChild(counters);
this.addChild(runningEntry);
this.addChild(startForm);
this.addChild(table);
this.addChild(exportActions);
}
}

View file

@ -1 +0,0 @@
export { TimesheetApp } from "./TimesheetApp";

View file

@ -1,9 +1,9 @@
import { DomComponent } from "@/components/DomComponent";
import { ReplaceableComponent } from "../ReplaceableComponent";
/**
* Component for rendering a load error for a timesheet
*/
export class TimesheetLoadError extends DomComponent {
export class TimesheetLoadError extends ReplaceableComponent {
/** The load error message */
error: string;
@ -13,13 +13,13 @@ export class TimesheetLoadError extends DomComponent {
this.error = error;
}
onload(): void {
super.onload();
const wrapperEl = this.containerEl.createDiv({
createContainer(): HTMLElement {
return createDiv({
cls: "timekeep-container",
});
this.wrapperEl = wrapperEl;
}
render(wrapperEl: HTMLElement): void {
wrapperEl.createEl("p", {
text: `Failed to load timekeep: ${this.error}`,
});

View file

@ -11,6 +11,7 @@ import { createMockContainer } from "@/__mocks__/obsidian";
import { defaultSettings } from "@/settings";
import { createStore } from "@/store";
import { EmptyComponent } from "../EmptyComponent";
import { TimesheetRunningEntry } from "./TimesheetRunningEntry";
import { TimesheetRunningEntryEditing } from "./TimesheetRunningEntryEditing";
import { TimesheetRunningEntryViewing } from "./TimesheetRunningEntryViewing";
@ -41,7 +42,7 @@ describe("TimesheetRunningEntry", () => {
it("should be undefined content without a running entry", () => {
component = new TimesheetRunningEntry(containerEl, timekeep, settings);
component.load();
expect(component.getContent()).toBeUndefined();
expect(component.getContent()).toBeInstanceOf(EmptyComponent);
});
it("should be TimesheetStartRunning when theres a running entry", () => {
@ -114,7 +115,7 @@ describe("TimesheetRunningEntry", () => {
expect(component.getContent()).toBeInstanceOf(TimesheetRunningEntryEditing);
});
it("after a timekeep update when editing if theres no more current entry should return to undefined state", () => {
it("after a timekeep update when editing if theres no more current entry should return to empty component state", () => {
const start = moment();
timekeep.setState({
entries: [
@ -138,6 +139,6 @@ describe("TimesheetRunningEntry", () => {
timekeep.setState({ entries: [] });
expect(component.getContent()).toBeUndefined();
expect(component.getContent()).toBeInstanceOf(EmptyComponent);
});
});

View file

@ -3,6 +3,7 @@ import type { Store } from "@/store";
import { assert } from "@/utils/assert";
import { EmptyComponent } from "../EmptyComponent";
import { TimesheetRunningEntryEditing } from "./TimesheetRunningEntryEditing";
import { TimesheetRunningEntryViewing } from "./TimesheetRunningEntryViewing";
@ -12,7 +13,7 @@ import { getRunningEntry } from "@/timekeep/queries";
import type { Timekeep } from "@/timekeep/schema";
export class TimesheetRunningEntry extends ContentComponent<
TimesheetRunningEntryViewing | TimesheetRunningEntryEditing
TimesheetRunningEntryViewing | TimesheetRunningEntryEditing | EmptyComponent
> {
/** Access to the timekeep */
timekeep: Store<Timekeep>;
@ -57,7 +58,7 @@ export class TimesheetRunningEntry extends ContentComponent<
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
if (!currentEntry) {
this.setContent(undefined);
this.setContent(new EmptyComponent(contentEl));
return;
}
@ -82,7 +83,7 @@ export class TimesheetRunningEntry extends ContentComponent<
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
if (!currentEntry) {
this.setContent(undefined);
this.setContent(new EmptyComponent(contentEl));
return;
}

View file

@ -3,7 +3,8 @@ import type { Store } from "@/store";
import { assert } from "@/utils/assert";
import { DomComponent } from "@/components/DomComponent";
import { ReplaceableComponent } from "../ReplaceableComponent";
import { createObsidianIcon } from "@/components/obsidianIcon";
import { getRunningEntry } from "@/timekeep/queries";
@ -14,7 +15,7 @@ import { updateEntry } from "@/timekeep/update";
* The editing section for editing the currently
* running time entry within the start section
*/
export class TimesheetRunningEntryEditing extends DomComponent {
export class TimesheetRunningEntryEditing extends ReplaceableComponent {
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
@ -47,15 +48,17 @@ export class TimesheetRunningEntryEditing extends DomComponent {
this.onFinishEditing = onFinishEditing;
}
onload(): void {
super.onload();
const formEl = this.containerEl.createEl("form", {
createContainer(): HTMLElement {
return createEl("form", {
cls: "timekeep-start-area",
attr: {
"data-area": "start",
},
});
formEl.setAttribute("data-area", "start");
}
render(formEl: HTMLElement): void {
this.registerDomEvent(formEl, "submit", this.onSave.bind(this));
this.wrapperEl = formEl;
const nameWrapperEl = formEl.createDiv({
cls: "timekeep-name-wrapper",

View file

@ -6,7 +6,8 @@ import type { Store } from "@/store";
import { assert } from "@/utils/assert";
import { formatTimestamp } from "@/utils/time";
import { DomComponent } from "@/components/DomComponent";
import { ReplaceableComponent } from "../ReplaceableComponent";
import { createObsidianIcon } from "@/components/obsidianIcon";
import { getPathToEntry } from "@/timekeep/queries";
@ -16,7 +17,7 @@ import { stopTimekeep } from "@/timekeep/update";
/**
* The "Running" timer section of the timesheet start are
*/
export class TimesheetRunningEntryViewing extends DomComponent {
export class TimesheetRunningEntryViewing extends ReplaceableComponent {
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
@ -52,14 +53,16 @@ export class TimesheetRunningEntryViewing extends DomComponent {
this.onStartEditing = onStartEditing;
}
onload(): void {
super.onload();
const formEl = this.containerEl.createEl("form", {
createContainer(): HTMLElement {
return createEl("form", {
cls: "timekeep-start-area",
attr: {
"data-area": "running",
},
});
formEl.setAttribute("data-area", "running");
this.wrapperEl = formEl;
}
render(formEl: HTMLElement): void {
this.registerDomEvent(formEl, "submit", this.onStop.bind(this));
const nameWrapperEl = formEl.createDiv({

View file

@ -12,7 +12,6 @@ import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema";
describe("TimesheetSaveError", () => {
let container: HTMLElement;
let timekeepStore: Store<Timekeep>;
let handleSaveTimekeep: (value: Timekeep) => Promise<void>;
let component: TimesheetSaveError;
let writeText: Mock<() => any>;
@ -20,8 +19,7 @@ describe("TimesheetSaveError", () => {
beforeEach(() => {
container = createMockContainer();
timekeepStore = createStore({ entries: [] });
handleSaveTimekeep = vi.fn().mockResolvedValue(undefined as void);
component = new TimesheetSaveError(container, timekeepStore, handleSaveTimekeep);
component = new TimesheetSaveError(container, timekeepStore);
writeText = vi.fn().mockResolvedValue(undefined);
@ -55,9 +53,10 @@ describe("TimesheetSaveError", () => {
});
it("calls handleSaveTimekeep on retry button click", () => {
const setState = vi.spyOn(timekeepStore, "setState");
component.load();
component.onRetrySave();
expect(handleSaveTimekeep).toHaveBeenCalledWith(timekeepStore.getState());
expect(setState).toHaveBeenCalledWith(timekeepStore.getState());
});
it("writes JSON to clipboard on copy button click", async () => {

View file

@ -1,39 +1,28 @@
import type { Store } from "@/store";
import { DomComponent } from "@/components/DomComponent";
import { ReplaceableComponent } from "../ReplaceableComponent";
import { stripTimekeepRuntimeData, type Timekeep } from "@/timekeep/schema";
type HandleSaveTimekeep = (value: Timekeep) => Promise<void>;
/**
* Component for showing the "Failed to save current timekeep" error
*/
export class TimesheetSaveError extends DomComponent {
export class TimesheetSaveError extends ReplaceableComponent {
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Callback to save the timekeep */
handleSaveTimekeep: HandleSaveTimekeep;
constructor(
containerEl: HTMLElement,
timekeep: Store<Timekeep>,
handleSaveTimekeep: HandleSaveTimekeep
) {
constructor(containerEl: HTMLElement, timekeep: Store<Timekeep>) {
super(containerEl);
this.timekeep = timekeep;
this.handleSaveTimekeep = handleSaveTimekeep;
}
onload(): void {
super.onload();
const wrapperEl = this.containerEl.createDiv({
createContainer(): HTMLElement {
return createDiv({
cls: "timekeep-container",
});
this.wrapperEl = wrapperEl;
}
render(wrapperEl: HTMLElement): void {
const errorEl = wrapperEl.createDiv({ cls: "timekeep-error" });
errorEl.createEl("h1", { text: "Warning" });
errorEl.createEl("p", { text: "Failed to save current timekeep" });
@ -57,8 +46,8 @@ export class TimesheetSaveError extends DomComponent {
}
onRetrySave() {
// Attempt to save the current timekeep
void this.handleSaveTimekeep(this.timekeep.getState());
// Set the state to itself to trigger another save attempt
this.timekeep.setState(this.timekeep.getState());
}
async onCopy() {

View file

@ -108,6 +108,7 @@ export class TimesheetTable extends DomComponent {
clearRows() {
// Unload existing children and reset the rows list
for (const row of this.#rows) {
console.log(row);
this.removeChild(row);
}

View file

@ -0,0 +1,36 @@
import type { TFile, Vault } from "obsidian";
import type { TimesheetSaveAdapter } from "./TimesheetSaveAdapter";
import { stripTimekeepRuntimeData, type Timekeep } from "@/timekeep/schema";
export class TimesheetFileSaveAdapter implements TimesheetSaveAdapter {
/** Access to the vault */
vault: Vault;
/** File that should be saved to */
file: TFile | null;
constructor(vault: Vault, file: TFile | null) {
this.vault = vault;
this.file = file;
}
onLoad(): void {}
onUnload(): void {}
async onSave(timekeep: Timekeep): Promise<void> {
if (this.file === null) {
return;
}
try {
const stripped = stripTimekeepRuntimeData(timekeep);
const serialized = JSON.stringify(stripped);
await this.vault.modify(this.file, serialized);
} catch (error) {
console.error("failed to save file", error);
}
}
}

View file

@ -0,0 +1,72 @@
import {
TFile,
Vault,
type EventRef,
type MarkdownPostProcessorContext,
type TAbstractFile,
} from "obsidian";
import type { TimesheetSaveAdapter } from "./TimesheetSaveAdapter";
import { replaceTimekeepCodeblock } from "@/timekeep/parser";
import type { Timekeep } from "@/timekeep/schema";
export class TimesheetMarkdownSaveAdapter implements TimesheetSaveAdapter {
/** Access to the vault */
vault: Vault;
/** Context from the markdown */
context: MarkdownPostProcessorContext;
/** HTML container element */
containerEl: HTMLElement;
/** Path to the file the timekeep is within */
fileSourcePath: string;
/** Event ref for the registered rename event */
renameEventRef: EventRef | undefined;
constructor(vault: Vault, containerEl: HTMLElement, context: MarkdownPostProcessorContext) {
this.vault = vault;
this.containerEl = containerEl;
this.context = context;
this.fileSourcePath = context.sourcePath;
}
onLoad(): void {
this.renameEventRef = this.vault.on("rename", this.onVaultRename.bind(this));
}
onUnload(): void {
if (this.renameEventRef) {
this.vault.offref(this.renameEventRef);
}
}
onVaultRename(file: TAbstractFile, oldName: string) {
if (file instanceof TFile && oldName == this.fileSourcePath) {
this.fileSourcePath = file.path;
}
}
async onSave(timekeep: Timekeep): Promise<void> {
const sectionInfo = this.context.getSectionInfo(this.containerEl);
// Ensure we actually have a section to write to
if (sectionInfo === null) throw new Error("Section to write did not exist");
const file = this.vault.getFileByPath(this.fileSourcePath);
// Ensure the file still exists
if (file === null) throw new Error("File no longer exists");
// Replace the stored timekeep block with the new one
await this.vault.process(file, (data) => {
return replaceTimekeepCodeblock(
timekeep,
data,
sectionInfo.lineStart,
sectionInfo.lineEnd
);
});
}
}

View file

@ -0,0 +1,9 @@
import { Timekeep } from "@/timekeep/schema";
export interface TimesheetSaveAdapter {
onLoad(): void;
onUnload(): void;
onSave(timekeep: Timekeep): Promise<void>;
}

View file

@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EditorScrollTracker } from "./editorScrollTracker";
describe("EditorScrollTracker", () => {
let mockApp: any;
let mockEditor: any;
beforeEach(() => {
mockEditor = {
getScrollInfo: vi.fn(),
scrollTo: vi.fn(),
};
mockApp = {
workspace: {
activeEditor: {
editor: mockEditor,
},
},
};
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
describe("save", () => {
it("saves scroll info when editor exists", () => {
const scrollInfo = { top: 100, left: 50 };
mockEditor.getScrollInfo.mockReturnValue(scrollInfo);
const tracker = new EditorScrollTracker(mockApp);
tracker.save();
expect(mockEditor.getScrollInfo).toHaveBeenCalled();
expect(tracker.restoreScrollInfo).toEqual(scrollInfo);
});
it("does nothing if activeEditor is missing", () => {
mockApp.workspace.activeEditor = null;
const tracker = new EditorScrollTracker(mockApp);
tracker.save();
expect(tracker.restoreScrollInfo).toBeNull();
});
it("does nothing if editor is missing", () => {
mockApp.workspace.activeEditor.editor = null;
const tracker = new EditorScrollTracker(mockApp);
tracker.save();
expect(tracker.restoreScrollInfo).toBeNull();
});
});
describe("restore", () => {
it("restores scroll when info and editor exist", () => {
const tracker = new EditorScrollTracker(mockApp);
tracker.restoreScrollInfo = { top: 200, left: 75 };
tracker.restore();
expect(mockEditor.scrollTo).toHaveBeenCalledWith(75, 200);
});
it("does nothing if restoreScrollInfo is null", () => {
const tracker = new EditorScrollTracker(mockApp);
tracker.restore();
expect(mockEditor.scrollTo).not.toHaveBeenCalled();
});
it("does nothing if activeEditor is missing", () => {
const tracker = new EditorScrollTracker(mockApp);
tracker.restoreScrollInfo = { top: 10, left: 20 };
mockApp.workspace.activeEditor = null;
tracker.restore();
expect(mockEditor.scrollTo).not.toHaveBeenCalled();
});
it("does nothing if editor is missing", () => {
const tracker = new EditorScrollTracker(mockApp);
tracker.restoreScrollInfo = { top: 10, left: 20 };
mockApp.workspace.activeEditor.editor = null;
tracker.restore();
expect(mockEditor.scrollTo).not.toHaveBeenCalled();
});
});
describe("queueRestore", () => {
it("queues restore after delay", () => {
const tracker = new EditorScrollTracker(mockApp);
tracker.restoreScrollInfo = { top: 300, left: 150 };
tracker.queueRestore(100);
expect(mockEditor.scrollTo).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(mockEditor.scrollTo).toHaveBeenCalledWith(150, 300);
});
it("clears previous timeout before setting a new one", () => {
const tracker = new EditorScrollTracker(mockApp);
tracker.restoreScrollInfo = { top: 400, left: 200 };
const clearSpy = vi.spyOn(global, "clearTimeout");
tracker.queueRestore(100);
tracker.queueRestore(200);
expect(clearSpy).toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(mockEditor.scrollTo).toHaveBeenCalledTimes(1);
expect(mockEditor.scrollTo).toHaveBeenCalledWith(200, 400);
});
});
});

View file

@ -0,0 +1,48 @@
import type { App } from "obsidian";
/**
* Utility to handle tracking the editor scroll for saving and restoring
* scroll on the markdown view
*/
export class EditorScrollTracker {
app: App;
restoreScrollTimeout: ReturnType<typeof setTimeout> | null = null;
restoreScrollInfo: { top: number; left: number } | null = null;
constructor(app: App) {
this.app = app;
}
save() {
const activeEditor = this.app.workspace.activeEditor;
if (!activeEditor) return;
const editor = activeEditor.editor;
if (!editor) return;
this.restoreScrollInfo = editor.getScrollInfo();
}
restore() {
if (this.restoreScrollInfo === null) return;
const activeEditor = this.app.workspace.activeEditor;
if (!activeEditor) return;
const editor = activeEditor.editor;
if (!editor) return;
editor.scrollTo(this.restoreScrollInfo.left, this.restoreScrollInfo.top);
}
/**
* Queue a restore to happen after a delay
*
* @param delayMs The delay in milliseconds before restoring
*/
queueRestore(delayMs: number = 50) {
if (this.restoreScrollTimeout) {
clearTimeout(this.restoreScrollTimeout);
}
this.restoreScrollTimeout = setTimeout(this.restore.bind(this), delayMs);
}
}

View file

@ -1,15 +1,13 @@
import moment from "moment";
import { EditableFileView, TFile, WorkspaceLeaf } from "obsidian";
import { CustomOutputFormat } from "@/output";
import { TimesheetFileSaveAdapter } from "@/save/TimesheetFileSaveAdapter";
import { TimekeepSettings } from "@/settings";
import { createStore, Store } from "@/store";
import { Timesheet } from "@/components/Timesheet";
import { TimesheetLoadError } from "@/components/TimesheetLoadError";
import TimekeepView from "./TimekeepView";
import { load, LoadResult } from "@/timekeep/parser";
import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema";
import { TimekeepAutocomplete } from "@/service/autocomplete";
@ -21,13 +19,16 @@ export default class TimekeepFileView extends EditableFileView {
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
/** Loading result for the timekeep data */
loadResult: Store<LoadResult | null>;
/** Container wrapper element */
wrapperEl: HTMLElement | undefined;
/** The rendered timesheet component */
timesheet: Timesheet | TimesheetLoadError | undefined;
/** Loading result for the timekeep data */
loadResult: Store<LoadResult | null>;
/** The timekeep view */
timesheet: TimekeepView | undefined;
/** Adapter for saving to the file */
saveAdapter: TimesheetFileSaveAdapter;
constructor(
leaf: WorkspaceLeaf,
@ -38,10 +39,11 @@ export default class TimekeepFileView extends EditableFileView {
super(leaf);
this.loadResult = createStore(null);
this.settings = settings;
this.customOutputFormats = customOutputFormats;
this.autocomplete = autocomplete;
this.saveAdapter = new TimesheetFileSaveAdapter(this.app.vault, this.file);
}
onload(): void {
@ -50,68 +52,16 @@ export default class TimekeepFileView extends EditableFileView {
const wrapperEl = this.contentEl.createDiv({ cls: "timekeep-file" });
this.wrapperEl = wrapperEl;
const render = this.render.bind(this);
const unsubscribeLoadResult = this.loadResult.subscribe(render);
render();
this.register(unsubscribeLoadResult);
}
render() {
if (!this.wrapperEl) {
return;
}
const loadResult = this.loadResult.getState();
if (!loadResult) return;
if (this.timesheet) {
this.removeChild(this.timesheet);
}
// Render the content
if (loadResult.success) {
const timekeep = loadResult.timekeep;
const timekeepStore = createStore(timekeep);
const saveErrorStore = createStore(false);
const trySave = this.trySave.bind(this);
const handleSaveTimekeep = async (timekeep: Timekeep) => {
// Attempt to save the timekeep changes
const result = await trySave(timekeep);
const saveError = !result;
// Update the save error state
if (saveErrorStore.getState() !== saveError) {
saveErrorStore.setState(saveError);
}
};
// Subscribe to save when timekeep changes
timekeepStore.subscribe(() => {
void handleSaveTimekeep(timekeepStore.getState());
});
const timesheet = new Timesheet(
this.wrapperEl,
this.app,
timekeepStore,
saveErrorStore,
this.settings,
this.customOutputFormats,
this.autocomplete,
handleSaveTimekeep
);
this.addChild(timesheet);
this.timesheet = timesheet;
} else {
const timesheet = new TimesheetLoadError(this.wrapperEl, loadResult.error);
this.addChild(timesheet);
this.timesheet = timesheet;
}
this.timesheet = new TimekeepView(
wrapperEl,
this.app,
this.settings,
this.customOutputFormats,
this.autocomplete,
this.loadResult,
this.saveAdapter
);
this.addChild(this.timesheet);
}
getViewType(): string {
@ -129,74 +79,8 @@ export default class TimekeepFileView extends EditableFileView {
async onLoadFile(file: TFile): Promise<void> {
await super.onLoadFile(file);
this.saveAdapter.file = file;
const fileData = await this.app.vault.read(file);
this.loadResult.setState(load(fileData));
}
async onUnloadFile(file: TFile): Promise<void> {
await super.onUnloadFile(file);
}
/**
* Attempts to save the file normally, if this fails it also attempts
* to save a fallback file
*
* @param timekeep
* @returns Promise of a boolean indicating weather the save was a success
*/
async trySave(timekeep: Timekeep): Promise<boolean> {
try {
await this.save(timekeep);
return true;
} catch (e) {
console.error("Failed to save timekeep", e);
try {
await this.saveFallback(timekeep);
} catch (e) {
console.error("Couldn't save timekeep fallback", e);
}
return false;
}
}
/**
* Attempts to save the timekeep within the current file
*
* @param timekeep The new timekeep data to save
*/
async save(timekeep: Timekeep) {
if (this.file === null) {
return;
}
try {
const stripped = stripTimekeepRuntimeData(timekeep);
const serialized = JSON.stringify(stripped);
await this.app.vault.modify(this.file, serialized);
} catch (error) {
console.error("failed to save file", error);
}
}
/**
* Fallback saving in case writing back to the timekeep block fails,
* if writing back fails attempt to write to a backup temporary file
* using the current date time
*
* @param timekeep The timekeep to save
*/
async saveFallback(timekeep: Timekeep) {
// Fallback in case of write failure, attempt to write to another file
const backupFileName = `timekeep-write-backup-${moment().format("YYYY-MM-DD HH-mm-ss")}.json`;
// Write to the backup file
await this.app.vault.create(
backupFileName,
JSON.stringify(stripTimekeepRuntimeData(timekeep))
);
}
}

View file

@ -1,45 +1,46 @@
import type { App } from "obsidian";
import type { TAbstractFile, MarkdownPostProcessorContext } from "obsidian";
import type { MarkdownPostProcessorContext } from "obsidian";
import moment from "moment";
import { TFile, MarkdownRenderChild } from "obsidian";
import { MarkdownRenderChild } from "obsidian";
import type { CustomOutputFormat } from "@/output";
import type { TimekeepSettings } from "@/settings";
import { TimesheetMarkdownSaveAdapter } from "@/save/TimesheetMarkdownSaveAdapter";
import { type Store, createStore } from "@/store";
import { EditorScrollTracker } from "@/utils/editorScrollTracker";
import { Timesheet } from "@/components/Timesheet";
import { TimesheetLoadError } from "@/components/TimesheetLoadError";
import TimekeepView from "./TimekeepView";
import { type LoadResult, load, replaceTimekeepCodeblock } from "@/timekeep/parser";
import { type Timekeep, stripTimekeepRuntimeData } from "@/timekeep/schema";
import { type LoadResult, load } from "@/timekeep/parser";
import { TimekeepAutocomplete } from "@/service/autocomplete";
export default class TimekeepMarkdownView extends MarkdownRenderChild {
// Obsidian app instance
/** Obsidian app instance */
app: App;
// Timekeep settings store
settingsStore: Store<TimekeepSettings>;
// Custom output formats store
/** Timekeep settings store */
settings: Store<TimekeepSettings>;
/** Custom output formats store */
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
// Markdown context for the current markdown block
context: MarkdownPostProcessorContext;
// Timekeep load result
loadResult: LoadResult;
/** The rendered timesheet component */
timesheet: Timesheet | TimesheetLoadError | undefined;
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
// Path to the file the timekeep is within
fileSourcePath: string;
/** Timekeep load result */
loadResult: Store<LoadResult | null>;
/** The timekeep view */
timesheet: TimekeepView | undefined;
/** Scroll tracking for restoring scroll after save */
scrollTracker: EditorScrollTracker;
/** Adapter for how the timesheet should be saved */
saveAdapter: TimesheetMarkdownSaveAdapter;
constructor(
containerEl: HTMLElement,
app: App,
settingsStore: Store<TimekeepSettings>,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
autocomplete: TimekeepAutocomplete,
context: MarkdownPostProcessorContext,
@ -47,24 +48,24 @@ export default class TimekeepMarkdownView extends MarkdownRenderChild {
) {
super(containerEl);
this.app = app;
this.settingsStore = settingsStore;
this.settings = settings;
this.customOutputFormats = customOutputFormats;
this.autocomplete = autocomplete;
this.context = context;
this.loadResult = loadResult;
// Set initial file path
this.fileSourcePath = context.sourcePath;
this.loadResult = createStore(loadResult);
this.scrollTracker = new EditorScrollTracker(app);
this.saveAdapter = new TimesheetMarkdownSaveAdapter(this.app.vault, containerEl, context);
}
/**
* Create a markdown code block processor for this view
*
* @param app
* @param settingsStore
* @param customOutputFormats
* @param autocomplete
* @returns
* @param app The app to use
* @param settingsStore The settings store
* @param customOutputFormats The custom output formats store
* @param autocomplete The autocomplete store
* @returns The markdown post processor factory
*/
static markdownPostProcessor(
app: App,
@ -89,155 +90,22 @@ export default class TimekeepMarkdownView extends MarkdownRenderChild {
}
onload(): void {
// Hook file renaming to update the file we are saving to if its renamed
this.registerEvent(
this.app.vault.on("rename", (file: TAbstractFile, oldName: string) => {
if (file instanceof TFile && oldName == this.fileSourcePath) {
this.fileSourcePath = file.path;
}
})
super.onload();
this.timesheet = new TimekeepView(
this.containerEl,
this.app,
this.settings,
this.customOutputFormats,
this.autocomplete,
this.loadResult,
this.saveAdapter
);
// Render the content
if (this.loadResult.success) {
const timekeep = this.loadResult.timekeep;
// Bind save event handlers to update scroll
this.timesheet.onBeforeSave = this.scrollTracker.save.bind(this.scrollTracker);
this.timesheet.onAfterSave = this.scrollTracker.queueRestore.bind(this.scrollTracker);
const timekeepStore = createStore(timekeep);
const saveErrorStore = createStore(false);
const trySave = this.trySave.bind(this);
const handleSaveTimekeep = async (timekeep: Timekeep) => {
// Attempt to save the timekeep changes
const result = await trySave(timekeep);
const saveError = !result;
// Update the save error state
if (saveErrorStore.getState() !== saveError) {
saveErrorStore.setState(saveError);
}
};
// Subscribe to save when timekeep changes
timekeepStore.subscribe(() => {
void handleSaveTimekeep(timekeepStore.getState());
});
const timesheet = new Timesheet(
this.containerEl,
this.app,
timekeepStore,
saveErrorStore,
this.settingsStore,
this.customOutputFormats,
this.autocomplete,
handleSaveTimekeep
);
this.addChild(timesheet);
this.timesheet = timesheet;
} else {
const timesheet = new TimesheetLoadError(this.containerEl, this.loadResult.error);
this.addChild(timesheet);
this.timesheet = timesheet;
}
}
restoreScrollTimeout: ReturnType<typeof setTimeout> | null = null;
restoreScrollInfo: { top: number; left: number } | null = null;
saveEditorScroll() {
const activeEditor = this.app.workspace.activeEditor;
if (!activeEditor) return;
const editor = activeEditor.editor;
if (!editor) return;
this.restoreScrollInfo = editor.getScrollInfo();
}
restoreEditorScroll() {
if (this.restoreScrollInfo === null) return;
const activeEditor = this.app.workspace.activeEditor;
if (!activeEditor) return;
const editor = activeEditor.editor;
if (!editor) return;
editor.scrollTo(this.restoreScrollInfo.left, this.restoreScrollInfo.top);
}
/**
* Attempts to save the file normally, if this fails it also attempts
* to save a fallback file
*
* @param timekeep
* @returns Promise of a boolean indicating weather the save was a success
*/
async trySave(timekeep: Timekeep): Promise<boolean> {
this.saveEditorScroll();
try {
await this.save(timekeep);
return true;
} catch (e) {
console.error("Failed to save timekeep", e);
try {
await this.saveFallback(timekeep);
} catch (e) {
console.error("Couldn't save timekeep fallback", e);
}
return false;
} finally {
// Queue scroll restoration after 50ms (Should be enough for the codeblock to re-render)
this.restoreScrollTimeout = setTimeout(this.restoreEditorScroll.bind(this), 50);
}
}
/**
* Attempts to save the timekeep within the current file
*
* @param timekeep The new timekeep data to save
*/
async save(timekeep: Timekeep) {
const sectionInfo = this.context.getSectionInfo(this.containerEl);
// Ensure we actually have a section to write to
if (sectionInfo === null) throw new Error("Section to write did not exist");
const file = this.app.vault.getFileByPath(this.fileSourcePath);
// Ensure the file still exists
if (file === null) throw new Error("File no longer exists");
// Replace the stored timekeep block with the new one
await this.app.vault.process(file, (data) => {
return replaceTimekeepCodeblock(
timekeep,
data,
sectionInfo.lineStart,
sectionInfo.lineEnd
);
});
}
/**
* Fallback saving in case writing back to the timekeep block fails,
* if writing back fails attempt to write to a backup temporary file
* using the current date time
*
* @param timekeep The timekeep to save
*/
async saveFallback(timekeep: Timekeep) {
// Fallback in case of write failure, attempt to write to another file
const backupFileName = `timekeep-write-backup-${moment().format("YYYY-MM-DD HH-mm-ss")}.json`;
// Write to the backup file
await this.app.vault.create(
backupFileName,
JSON.stringify(stripTimekeepRuntimeData(timekeep))
);
this.addChild(this.timesheet);
}
}

166
src/views/TimekeepView.ts Normal file
View file

@ -0,0 +1,166 @@
import moment from "moment";
import { App } from "obsidian";
import { CustomOutputFormat } from "@/output";
import { TimesheetSaveAdapter } from "@/save/TimesheetSaveAdapter";
import { TimekeepSettings } from "@/settings";
import { createStore, Store } from "@/store";
import { ContentComponent } from "@/components/ContentComponent";
import { EmptyComponent } from "@/components/EmptyComponent";
import { Timesheet } from "@/components/Timesheet";
import { TimesheetLoadError } from "@/components/TimesheetLoadError";
import { TimesheetSaveError } from "@/components/TimesheetSaveError";
import { LoadResult } from "@/timekeep/parser";
import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema";
import { TimekeepAutocomplete } from "@/service/autocomplete";
export default class TimekeepView extends ContentComponent<
Timesheet | TimesheetLoadError | TimesheetSaveError | EmptyComponent
> {
/** Access to the obsidian app */
app: App;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Access to custom output formats */
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
/** Loading result for the timekeep data */
loadResult: Store<LoadResult | null>;
/** Store for the timekeep state */
timekeep: Store<Timekeep>;
/** Store for save error state */
saveError: Store<boolean>;
/** Adapter for how the timesheet should be saved */
saveAdapter: TimesheetSaveAdapter;
onBeforeSave: VoidFunction | undefined;
onAfterSave: VoidFunction | undefined;
constructor(
containerEl: HTMLElement,
app: App,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
autocomplete: TimekeepAutocomplete,
loadResult: Store<LoadResult | null>,
saveAdapter: TimesheetSaveAdapter
) {
super(containerEl);
this.app = app;
this.loadResult = loadResult;
this.timekeep = createStore({ entries: [] });
this.saveError = createStore(false);
this.settings = settings;
this.customOutputFormats = customOutputFormats;
this.autocomplete = autocomplete;
this.saveAdapter = saveAdapter;
}
onload(): void {
super.onload();
this.saveAdapter.onLoad();
const onUpdateContent = this.onUpdateContent.bind(this);
this.register(this.loadResult.subscribe(onUpdateContent));
this.register(this.saveError.subscribe(onUpdateContent));
onUpdateContent();
}
onunload(): void {
super.onunload();
this.saveAdapter.onUnload();
}
onUpdateContent() {
const saveError = this.saveError.getState();
const loadResult = this.loadResult.getState();
if (!loadResult) {
return this.setContent(new EmptyComponent(this.containerEl));
}
if (saveError) {
this.setContent(new TimesheetSaveError(this.containerEl, this.timekeep));
} else if (loadResult.success) {
const timekeep = loadResult.timekeep;
this.timekeep.setState(timekeep);
this.register(this.timekeep.subscribe(this.onSave.bind(this)));
this.setContent(
new Timesheet(
this.containerEl,
this.app,
this.timekeep,
this.settings,
this.customOutputFormats,
this.autocomplete
)
);
} else {
this.setContent(new TimesheetLoadError(this.containerEl, loadResult.error));
}
}
async onSave() {
if (this.onBeforeSave) this.onBeforeSave();
const timekeep = this.timekeep.getState();
try {
await this.saveAdapter.onSave(timekeep);
// Clear error state on success
if (this.saveError.getState()) {
this.saveError.setState(false);
}
return true;
} catch (e) {
console.error("Failed to save timekeep", e);
try {
await this.saveFallback(timekeep);
} catch (e) {
console.error("Couldn't save timekeep fallback", e);
}
this.saveError.setState(true);
} finally {
if (this.onAfterSave) this.onAfterSave();
}
}
/**
* Fallback handling if saving the timekeep through the regular
* adapter fails:
*
* Write a backup to a temporary file using the current date and
* time in the file name
*
* @param timekeep The timekeep to save
*/
async saveFallback(timekeep: Timekeep) {
// Fallback in case of write failure, attempt to write to another file
const backupFileName = `timekeep-write-backup-${moment().format("YYYY-MM-DD HH-mm-ss")}.json`;
// Write to the backup file
await this.app.vault.create(
backupFileName,
JSON.stringify(stripTimekeepRuntimeData(timekeep))
);
}
}

File diff suppressed because one or more lines are too long