From 836cf5d6c5d589819834d14d39bc0fd1ffeecf2b Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Mon, 30 Mar 2026 20:01:53 +1300 Subject: [PATCH] feat: dedicated timekeep file format --- .gitignore | 1 + src/components/timesheet.ts | 3 +- src/components/timesheetApp.ts | 8 +- src/components/timesheetFileEntry.ts | 109 +++++++++++++ src/components/timesheetRowContainer.ts | 6 + src/components/timesheetTable.ts | 1 + src/main.ts | 14 ++ src/styles.css | 16 ++ src/views/timekeep-file-view.ts | 195 ++++++++++++++++++++++++ src/views/timekeep-markdown-view.ts | 16 +- 10 files changed, 352 insertions(+), 17 deletions(-) create mode 100644 src/components/timesheetFileEntry.ts create mode 100644 src/views/timekeep-file-view.ts diff --git a/.gitignore b/.gitignore index 0d5d6cf..b410ad6 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist coverage test-vault/.obsidian +test-vault/* diff --git a/src/components/timesheet.ts b/src/components/timesheet.ts index 91aa043..666db2a 100644 --- a/src/components/timesheet.ts +++ b/src/components/timesheet.ts @@ -88,8 +88,7 @@ export class Timesheet extends Component { this.timekeep, this.settings, this.customOutputFormats, - this.autocomplete, - this.handleSaveTimekeep + this.autocomplete ); } diff --git a/src/components/timesheetApp.ts b/src/components/timesheetApp.ts index ae586eb..fe64b28 100644 --- a/src/components/timesheetApp.ts +++ b/src/components/timesheetApp.ts @@ -29,9 +29,6 @@ export class TimesheetApp extends Component { /** Autocomplete */ autocomplete: TimekeepAutocomplete; - /** Callback to save the timekeep */ - handleSaveTimekeep: (value: Timekeep) => Promise; - /** Wrapper element containing the component content */ #wrapperEl: HTMLElement | undefined; @@ -41,8 +38,7 @@ export class TimesheetApp extends Component { timekeep: Store, settings: Store, customOutputFormats: Store>, - autocomplete: TimekeepAutocomplete, - handleSaveTimekeep: (value: Timekeep) => Promise + autocomplete: TimekeepAutocomplete ) { super(); @@ -53,8 +49,6 @@ export class TimesheetApp extends Component { this.settings = settings; this.customOutputFormats = customOutputFormats; this.autocomplete = autocomplete; - - this.handleSaveTimekeep = handleSaveTimekeep; } onunload(): void { diff --git a/src/components/timesheetFileEntry.ts b/src/components/timesheetFileEntry.ts new file mode 100644 index 0000000..6216e50 --- /dev/null +++ b/src/components/timesheetFileEntry.ts @@ -0,0 +1,109 @@ +import { App, Component } from "obsidian"; + +import { CustomOutputFormat } from "@/output"; +import { TimekeepAutocomplete } from "@/service/autocomplete"; +import { TimekeepSettings } from "@/settings"; +import { Store } from "@/store"; +import { Timekeep } from "@/timekeep/schema"; +import { ConfirmModal } from "@/views/confirm-modal"; + +import { createObsidianIcon } from "./obsidianIcon"; +import { TimesheetApp } from "./timesheetApp"; + +/** + * View component for the timesheet app as a whole + */ +export class TimesheetFileEntry extends Component { + /** Parent container element */ + #containerEl: HTMLElement; + + /** Access to the app instance */ + app: App; + /** Access to the timekeep */ + timekeep: Store; + /** Access to the timekeep settings */ + settings: Store; + /** Access to custom output formats */ + customOutputFormats: Store>; + /** Autocomplete */ + autocomplete: TimekeepAutocomplete; + + /** Wrapper element containing the component content */ + #wrapperEl: HTMLElement | undefined; + + timesheetApp: TimesheetApp | undefined; + + onDelete: VoidFunction; + + constructor( + containerEl: HTMLElement, + + app: App, + timekeep: Store, + settings: Store, + customOutputFormats: Store>, + autocomplete: TimekeepAutocomplete, + + onDelete: VoidFunction + ) { + super(); + + this.#containerEl = containerEl; + + this.app = app; + this.timekeep = timekeep; + this.settings = settings; + this.customOutputFormats = customOutputFormats; + this.autocomplete = autocomplete; + + this.onDelete = onDelete; + } + + onunload(): void { + super.onunload(); + this.#wrapperEl?.remove(); + } + + onload(): void { + super.onload(); + + const wrapperEl = this.#containerEl.createDiv({ + cls: "timekeep-file-entry", + }); + this.#wrapperEl = wrapperEl; + + const deleteButton = wrapperEl.createEl("button", { cls: "" }); + createObsidianIcon(deleteButton, "trash", "text-button-icon"); + deleteButton.appendText("Delete"); + + this.registerDomEvent(deleteButton, "click", this.onConfirmDelete.bind(this)); + + this.timesheetApp = new TimesheetApp( + wrapperEl, + this.app, + this.timekeep, + this.settings, + this.customOutputFormats, + this.autocomplete + ); + this.addChild(this.timesheetApp); + } + + onConfirmDelete() { + const modal = new ConfirmModal( + this.app, + "Are you sure you want to delete this timesheet?", + this.onConfirmedDelete.bind(this) + ); + modal.setTitle("Confirm Delete"); + modal.open(); + } + + onConfirmedDelete(confirmed: boolean) { + if (!confirmed) { + return; + } + + this.onDelete(); + } +} diff --git a/src/components/timesheetRowContainer.ts b/src/components/timesheetRowContainer.ts index c1d56b0..7574675 100644 --- a/src/components/timesheetRowContainer.ts +++ b/src/components/timesheetRowContainer.ts @@ -62,6 +62,12 @@ export class TimesheetRowContainer extends Component { this.onViewContent(); } + onunload(): void { + super.onunload(); + + this.#rowEl?.remove(); + } + onViewEditing() { const rowEl = this.#rowEl; if (!rowEl) return; diff --git a/src/components/timesheetTable.ts b/src/components/timesheetTable.ts index 8c52830..fbd2832 100644 --- a/src/components/timesheetTable.ts +++ b/src/components/timesheetTable.ts @@ -87,6 +87,7 @@ export class TimesheetTable extends Component { * timekeep data are updated */ onUpdate() { + this.clearRows(); this.updateWrapperSize(); this.renderRows(); } diff --git a/src/main.ts b/src/main.ts index a7a9fb1..c43d5ee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,6 +31,8 @@ import { stopFileTimekeeps } from "@/timekeep/stopFileTimekeeps"; import { TimekeepMarkdownView } from "@/views/timekeep-markdown-view"; import { TimekeepStatusBarView } from "@/views/timekeep-status-bar-view"; +import { TimekeepFileView } from "./views/timekeep-file-view"; + export default class TimekeepPlugin extends Plugin { /** Store containing the plugin settings */ settingsStore: Store = createStore(defaultSettings); @@ -125,6 +127,18 @@ export default class TimekeepPlugin extends Plugin { this.addCommand(exportMergedPdf(this.app, this.settingsStore)); this.addCommand(stopAllTimekeepsCommand(this.app)); this.addCommand(stopFileTimekeepsCommand(this.app)); + + // Custom timekeep file format + this.registerView("timekeep", (leaf) => { + return new TimekeepFileView( + leaf, + this.settingsStore, + this.customOutputFormats, + autocomplete + ); + }); + + this.registerExtensions(["timekeep"], "timekeep"); } private onReady() { diff --git a/src/styles.css b/src/styles.css index d0a5c16..c8edf39 100644 --- a/src/styles.css +++ b/src/styles.css @@ -349,3 +349,19 @@ gap: 0.5rem; align-items: center; } + +.timekeep-file { + display: flex; + gap: 1rem; + flex-flow: column; + + max-width: var(--file-line-width); + margin-left: auto; + margin-right: auto; +} + +.timekeep-file-entries { + display: flex; + gap: 1rem; + flex-flow: column; +} diff --git a/src/views/timekeep-file-view.ts b/src/views/timekeep-file-view.ts new file mode 100644 index 0000000..c7a32bb --- /dev/null +++ b/src/views/timekeep-file-view.ts @@ -0,0 +1,195 @@ +import moment from "moment"; +import { EditableFileView, TFile, WorkspaceLeaf } from "obsidian"; + +import { Timesheet } from "@/components/timesheet"; +import { TimesheetLoadError } from "@/components/timesheetLoadError"; +import { CustomOutputFormat } from "@/output"; +import { TimekeepAutocomplete } from "@/service/autocomplete"; +import { TimekeepSettings } from "@/settings"; +import { createStore, Store } from "@/store"; +import { load, LoadResult } from "@/timekeep/parser"; +import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema"; + +export class TimekeepFileView extends EditableFileView { + /** Access to the timekeep settings */ + settings: Store; + /** Access to custom output formats */ + customOutputFormats: Store>; + /** Autocomplete */ + autocomplete: TimekeepAutocomplete; + + /** Loading result for the timekeep data */ + loadResult: Store; + + wrapperEl: HTMLElement | undefined; + + /** The rendered timesheet component */ + timesheet: Timesheet | TimesheetLoadError | undefined; + + constructor( + leaf: WorkspaceLeaf, + settings: Store, + customOutputFormats: Store>, + autocomplete: TimekeepAutocomplete + ) { + super(leaf); + + this.loadResult = createStore(null); + + this.settings = settings; + this.customOutputFormats = customOutputFormats; + this.autocomplete = autocomplete; + } + + onload(): void { + super.onload(); + + 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; + } + } + + getViewType(): string { + return "timekeep"; + } + + getDisplayText(): string { + return "Timekeep"; + } + + async onLoadFile(file: TFile): Promise { + await super.onLoadFile(file); + + const fileData = await this.app.vault.read(file); + this.loadResult.setState(load(fileData)); + } + + async onUnloadFile(file: TFile): Promise { + 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 { + 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)) + ); + } +} diff --git a/src/views/timekeep-markdown-view.ts b/src/views/timekeep-markdown-view.ts index e9fd652..cebde85 100644 --- a/src/views/timekeep-markdown-view.ts +++ b/src/views/timekeep-markdown-view.ts @@ -55,14 +55,14 @@ export class TimekeepMarkdownView extends MarkdownRenderChild { } /** - * Create a markdown code block processor for this view - * - * @param app - * @param settingsStore - * @param customOutputFormats - * @param autocomplete - * @returns - */ + * Create a markdown code block processor for this view + * + * @param app + * @param settingsStore + * @param customOutputFormats + * @param autocomplete + * @returns + */ static markdownPostProcessor( app: App, settingsStore: Store,