mirror of
https://github.com/jacobtread/obsidian-timekeep.git
synced 2026-07-22 10:10:27 +00:00
feat: dedicated timekeep file format
This commit is contained in:
parent
34fec579cb
commit
836cf5d6c5
10 changed files with 352 additions and 17 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,3 +25,4 @@ dist
|
|||
coverage
|
||||
|
||||
test-vault/.obsidian
|
||||
test-vault/*
|
||||
|
|
|
|||
|
|
@ -88,8 +88,7 @@ export class Timesheet extends Component {
|
|||
this.timekeep,
|
||||
this.settings,
|
||||
this.customOutputFormats,
|
||||
this.autocomplete,
|
||||
this.handleSaveTimekeep
|
||||
this.autocomplete
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
109
src/components/timesheetFileEntry.ts
Normal file
109
src/components/timesheetFileEntry.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export class TimesheetTable extends Component {
|
|||
* timekeep data are updated
|
||||
*/
|
||||
onUpdate() {
|
||||
this.clearRows();
|
||||
this.updateWrapperSize();
|
||||
this.renderRows();
|
||||
}
|
||||
|
|
|
|||
14
src/main.ts
14
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<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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
195
src/views/timekeep-file-view.ts
Normal file
195
src/views/timekeep-file-view.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Reference in a new issue