feat: dedicated timekeep file format

This commit is contained in:
Jacobtread 2026-03-30 20:01:53 +13:00
parent 34fec579cb
commit 836cf5d6c5
10 changed files with 352 additions and 17 deletions

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ dist
coverage
test-vault/.obsidian
test-vault/*

View file

@ -88,8 +88,7 @@ export class Timesheet extends Component {
this.timekeep,
this.settings,
this.customOutputFormats,
this.autocomplete,
this.handleSaveTimekeep
this.autocomplete
);
}

View file

@ -29,9 +29,6 @@ export class TimesheetApp extends Component {
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
/** Callback to save the timekeep */
handleSaveTimekeep: (value: Timekeep) => Promise<void>;
/** Wrapper element containing the component content */
#wrapperEl: HTMLElement | undefined;
@ -41,8 +38,7 @@ export class TimesheetApp extends Component {
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
autocomplete: TimekeepAutocomplete,
handleSaveTimekeep: (value: Timekeep) => Promise<void>
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 {

View file

@ -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<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Access to custom output formats */
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
/** Wrapper element containing the component content */
#wrapperEl: HTMLElement | undefined;
timesheetApp: TimesheetApp | undefined;
onDelete: VoidFunction;
constructor(
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
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();
}
}

View file

@ -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;

View file

@ -87,6 +87,7 @@ export class TimesheetTable extends Component {
* timekeep data are updated
*/
onUpdate() {
this.clearRows();
this.updateWrapperSize();
this.renderRows();
}

View file

@ -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<TimekeepSettings> = 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() {

View file

@ -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;
}

View file

@ -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<TimekeepSettings>;
/** Access to custom output formats */
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
/** Loading result for the timekeep data */
loadResult: Store<LoadResult | null>;
wrapperEl: HTMLElement | undefined;
/** The rendered timesheet component */
timesheet: Timesheet | TimesheetLoadError | undefined;
constructor(
leaf: WorkspaceLeaf,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
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<void> {
await super.onLoadFile(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

@ -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<TimekeepSettings>,