mirror of
https://github.com/Ellpeck/ObsidianSimpleTimeTracker.git
synced 2024-11-28 12:08:34 +01:00
474 lines
18 KiB
TypeScript
474 lines
18 KiB
TypeScript
import {moment, App, MarkdownSectionInformation, ButtonComponent, TextComponent, TFile, MarkdownRenderer, Component, MarkdownRenderChild} from "obsidian";
|
|
import {SimpleTimeTrackerSettings} from "./settings";
|
|
|
|
export interface Tracker {
|
|
entries: Entry[];
|
|
}
|
|
|
|
export interface Entry {
|
|
name: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
subEntries: Entry[];
|
|
collapsed?: boolean;
|
|
}
|
|
|
|
export async function saveTracker(tracker: Tracker, app: App, fileName: string, section: MarkdownSectionInformation): Promise<void> {
|
|
let file = app.vault.getAbstractFileByPath(fileName) as TFile;
|
|
if (!file)
|
|
return;
|
|
let content = await app.vault.read(file);
|
|
|
|
// figure out what part of the content we have to edit
|
|
let lines = content.split("\n");
|
|
let prev = lines.filter((_, i) => i <= section.lineStart).join("\n");
|
|
let next = lines.filter((_, i) => i >= section.lineEnd).join("\n");
|
|
// edit only the code block content, leave the rest untouched
|
|
content = `${prev}\n${JSON.stringify(tracker)}\n${next}`;
|
|
|
|
await app.vault.modify(file, content);
|
|
}
|
|
|
|
export function loadTracker(json: string): Tracker {
|
|
if (json) {
|
|
try {
|
|
let ret = JSON.parse(json);
|
|
fixLegacyTimestamps(ret.entries);
|
|
return ret;
|
|
} catch (e) {
|
|
console.log(`Failed to parse Tracker from ${json}`);
|
|
}
|
|
}
|
|
return {entries: []};
|
|
}
|
|
|
|
type GetFile = () => string;
|
|
|
|
export function displayTracker(tracker: Tracker, element: HTMLElement, getFile: GetFile, getSectionInfo: () => MarkdownSectionInformation, settings: SimpleTimeTrackerSettings, component: MarkdownRenderChild): void {
|
|
|
|
element.addClass("simple-time-tracker-container");
|
|
// add start/stop controls
|
|
let running = isRunning(tracker);
|
|
let btn = new ButtonComponent(element)
|
|
.setClass("clickable-icon")
|
|
.setIcon(`lucide-${running ? "stop" : "play"}-circle`)
|
|
.setTooltip(running ? "End" : "Start")
|
|
.onClick(async () => {
|
|
if (running) {
|
|
endRunningEntry(tracker);
|
|
} else {
|
|
startNewEntry(tracker, newSegmentNameBox.getValue());
|
|
}
|
|
await saveTracker(tracker, this.app, getFile(), getSectionInfo());
|
|
});
|
|
btn.buttonEl.addClass("simple-time-tracker-btn");
|
|
let newSegmentNameBox = new TextComponent(element)
|
|
.setPlaceholder("Segment name")
|
|
.setDisabled(running);
|
|
newSegmentNameBox.inputEl.addClass("simple-time-tracker-txt");
|
|
|
|
// add timers
|
|
let timer = element.createDiv({cls: "simple-time-tracker-timers"});
|
|
let currentDiv = timer.createEl("div", {cls: "simple-time-tracker-timer"});
|
|
let current = currentDiv.createEl("span", {cls: "simple-time-tracker-timer-time"});
|
|
currentDiv.createEl("span", {text: "Current"});
|
|
let totalDiv = timer.createEl("div", {cls: "simple-time-tracker-timer"});
|
|
let total = totalDiv.createEl("span", {cls: "simple-time-tracker-timer-time", text: "0s"});
|
|
totalDiv.createEl("span", {text: "Total"});
|
|
|
|
if (tracker.entries.length > 0) {
|
|
// add table
|
|
let table = element.createEl("table", {cls: "simple-time-tracker-table"});
|
|
table.createEl("tr").append(
|
|
createEl("th", {text: "Segment"}),
|
|
createEl("th", {text: "Start time"}),
|
|
createEl("th", {text: "End time"}),
|
|
createEl("th", {text: "Duration"}),
|
|
createEl("th"));
|
|
|
|
for (let entry of orderedEntries(tracker.entries, settings))
|
|
addEditableTableRow(tracker, entry, table, newSegmentNameBox, running, getFile, getSectionInfo, settings, 0, component);
|
|
|
|
// add copy buttons
|
|
let buttons = element.createEl("div", {cls: "simple-time-tracker-bottom"});
|
|
new ButtonComponent(buttons)
|
|
.setButtonText("Copy as table")
|
|
.onClick(() => navigator.clipboard.writeText(createMarkdownTable(tracker, settings)));
|
|
new ButtonComponent(buttons)
|
|
.setButtonText("Copy as CSV")
|
|
.onClick(() => navigator.clipboard.writeText(createCsv(tracker, settings)));
|
|
}
|
|
|
|
|
|
setCountdownValues(tracker, current, total, currentDiv, settings);
|
|
let intervalId = window.setInterval(() => {
|
|
// we delete the interval timer when the element is removed
|
|
if (!element.isConnected) {
|
|
window.clearInterval(intervalId);
|
|
return;
|
|
}
|
|
setCountdownValues(tracker, current, total, currentDiv, settings);
|
|
}, 1000);
|
|
}
|
|
|
|
function startSubEntry(entry: Entry, name: string): void {
|
|
// if this entry is not split yet, we add its time as a sub-entry instead
|
|
if (!entry.subEntries) {
|
|
entry.subEntries = [{...entry, name: `Part 1`}];
|
|
entry.startTime = null;
|
|
entry.endTime = null;
|
|
}
|
|
|
|
if (!name)
|
|
name = `Part ${entry.subEntries.length + 1}`;
|
|
entry.subEntries.push({name: name, startTime: moment().toISOString(), endTime: null, subEntries: null});
|
|
}
|
|
|
|
function startNewEntry(tracker: Tracker, name: string): void {
|
|
if (!name)
|
|
name = `Segment ${tracker.entries.length + 1}`;
|
|
let entry: Entry = {name: name, startTime: moment().toISOString(), endTime: null, subEntries: null};
|
|
tracker.entries.push(entry);
|
|
}
|
|
|
|
function endRunningEntry(tracker: Tracker): void {
|
|
let entry = getRunningEntry(tracker.entries);
|
|
entry.endTime = moment().toISOString();
|
|
}
|
|
|
|
function removeEntry(entries: Entry[], toRemove: Entry): boolean {
|
|
if (entries.contains(toRemove)) {
|
|
entries.remove(toRemove);
|
|
return true;
|
|
} else {
|
|
for (let entry of entries) {
|
|
if (entry.subEntries && removeEntry(entry.subEntries, toRemove)) {
|
|
// if we only have one sub entry remaining, we can merge back into our main entry
|
|
if (entry.subEntries.length == 1) {
|
|
let single = entry.subEntries[0];
|
|
entry.startTime = single.startTime;
|
|
entry.endTime = single.endTime;
|
|
entry.subEntries = null;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isRunning(tracker: Tracker): boolean {
|
|
return !!getRunningEntry(tracker.entries);
|
|
}
|
|
|
|
function getRunningEntry(entries: Entry[]): Entry {
|
|
for (let entry of entries) {
|
|
// if this entry has sub entries, check if one of them is running
|
|
if (entry.subEntries) {
|
|
let running = getRunningEntry(entry.subEntries);
|
|
if (running)
|
|
return running;
|
|
} else {
|
|
// if this entry has no sub entries and no end time, it's running
|
|
if (!entry.endTime)
|
|
return entry;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getDuration(entry: Entry): number {
|
|
if (entry.subEntries) {
|
|
return getTotalDuration(entry.subEntries);
|
|
} else {
|
|
let endTime = entry.endTime ? moment(entry.endTime) : moment();
|
|
return endTime.diff(moment(entry.startTime));
|
|
}
|
|
}
|
|
|
|
function getTotalDuration(entries: Entry[]): number {
|
|
let ret = 0;
|
|
for (let entry of entries)
|
|
ret += getDuration(entry);
|
|
return ret;
|
|
}
|
|
|
|
function setCountdownValues(tracker: Tracker, current: HTMLElement, total: HTMLElement, currentDiv: HTMLDivElement, settings: SimpleTimeTrackerSettings): void {
|
|
let running = getRunningEntry(tracker.entries);
|
|
if (running && !running.endTime) {
|
|
current.setText(formatDuration(getDuration(running), settings));
|
|
currentDiv.hidden = false;
|
|
} else {
|
|
currentDiv.hidden = true;
|
|
}
|
|
total.setText(formatDuration(getTotalDuration(tracker.entries), settings));
|
|
}
|
|
|
|
function formatTimestamp(timestamp: string, settings: SimpleTimeTrackerSettings): string {
|
|
return moment(timestamp).format(settings.timestampFormat);
|
|
}
|
|
|
|
function formatEditableTimestamp(timestamp: string, settings: SimpleTimeTrackerSettings): string {
|
|
return moment(timestamp).format(settings.editableTimestampFormat);
|
|
}
|
|
|
|
function unformatEditableTimestamp(formatted: string, settings: SimpleTimeTrackerSettings): string {
|
|
return moment(formatted, settings.editableTimestampFormat).toISOString();
|
|
}
|
|
|
|
function formatDuration(totalTime: number, settings: SimpleTimeTrackerSettings): string {
|
|
let ret = "";
|
|
let duration = moment.duration(totalTime);
|
|
let hours = settings.fineGrainedDurations ? duration.hours() : Math.floor(duration.asHours());
|
|
|
|
if (settings.timestampDurations) {
|
|
if (settings.fineGrainedDurations) {
|
|
let days = Math.floor(duration.asDays());
|
|
if (days > 0)
|
|
ret += days + ".";
|
|
}
|
|
ret += `${hours.toString().padStart(2, "0")}:${duration.minutes().toString().padStart(2, "0")}:${duration.seconds().toString().padStart(2, "0")}`;
|
|
} else {
|
|
if (settings.fineGrainedDurations) {
|
|
let years = Math.floor(duration.asYears());
|
|
if (years > 0)
|
|
ret += years + "y ";
|
|
if (duration.months() > 0)
|
|
ret += duration.months() + "M ";
|
|
if (duration.days() > 0)
|
|
ret += duration.days() + "d ";
|
|
}
|
|
if (hours > 0)
|
|
ret += hours + "h ";
|
|
if (duration.minutes() > 0)
|
|
ret += duration.minutes() + "m ";
|
|
ret += duration.seconds() + "s";
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
function fixLegacyTimestamps(entries: Entry[]): void {
|
|
for (let entry of entries) {
|
|
if (entry.startTime && !isNaN(+entry.startTime))
|
|
entry.startTime = moment.unix(+entry.startTime).toISOString();
|
|
if (entry.endTime && !isNaN(+entry.endTime))
|
|
entry.endTime = moment.unix(+entry.endTime).toISOString();
|
|
|
|
if (entry.subEntries)
|
|
fixLegacyTimestamps(entry.subEntries);
|
|
}
|
|
}
|
|
|
|
function createMarkdownTable(tracker: Tracker, settings: SimpleTimeTrackerSettings): string {
|
|
let table = [["Segment", "Start time", "End time", "Duration"]];
|
|
for (let entry of orderedEntries(tracker.entries, settings))
|
|
table.push(...createTableSection(entry, settings));
|
|
table.push(["**Total**", "", "", `**${formatDuration(getTotalDuration(tracker.entries), settings)}**`]);
|
|
|
|
let ret = "";
|
|
// calculate the width every column needs to look neat when monospaced
|
|
let widths = Array.from(Array(4).keys()).map(i => Math.max(...table.map(a => a[i].length)));
|
|
for (let r = 0; r < table.length; r++) {
|
|
// add separators after first row
|
|
if (r == 1)
|
|
ret += "| " + Array.from(Array(4).keys()).map(i => "-".repeat(widths[i])).join(" | ") + " |\n";
|
|
|
|
let row: string[] = [];
|
|
for (let i = 0; i < 4; i++)
|
|
row.push(table[r][i].padEnd(widths[i], " "));
|
|
ret += "| " + row.join(" | ") + " |\n";
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
function createCsv(tracker: Tracker, settings: SimpleTimeTrackerSettings): string {
|
|
let ret = "";
|
|
for (let entry of orderedEntries(tracker.entries, settings)) {
|
|
for (let row of createTableSection(entry, settings))
|
|
ret += row.join(settings.csvDelimiter) + "\n";
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
function createTableSection(entry: Entry, settings: SimpleTimeTrackerSettings): string[][] {
|
|
let ret = [[
|
|
entry.name,
|
|
entry.startTime ? formatTimestamp(entry.startTime, settings) : "",
|
|
entry.endTime ? formatTimestamp(entry.endTime, settings) : "",
|
|
entry.endTime || entry.subEntries ? formatDuration(getDuration(entry), settings) : ""]];
|
|
if (entry.subEntries) {
|
|
for (let sub of orderedEntries(entry.subEntries, settings))
|
|
ret.push(...createTableSection(sub, settings));
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
function orderedEntries(entries: Entry[], settings: SimpleTimeTrackerSettings): Entry[] {
|
|
return settings.reverseSegmentOrder ? entries.slice().reverse() : entries;
|
|
}
|
|
|
|
function addEditableTableRow(tracker: Tracker, entry: Entry, table: HTMLTableElement, newSegmentNameBox: TextComponent, trackerRunning: boolean, getFile: GetFile, getSectionInfo: () => MarkdownSectionInformation, settings: SimpleTimeTrackerSettings, indent: number, component: MarkdownRenderChild): void {
|
|
let entryRunning = getRunningEntry(tracker.entries) == entry;
|
|
let row = table.createEl("tr");
|
|
|
|
let nameField = new EditableField(row, indent, entry.name);
|
|
let startField = new EditableTimestampField(row, (entry.startTime), settings);
|
|
let endField = new EditableTimestampField(row, (entry.endTime), settings);
|
|
|
|
row.createEl("td", {text: entry.endTime || entry.subEntries ? formatDuration(getDuration(entry), settings) : ""});
|
|
|
|
renderNameAsMarkdown(nameField.label, getFile, component);
|
|
|
|
let expandButton = new ButtonComponent(nameField.label)
|
|
.setClass("clickable-icon")
|
|
.setClass("simple-time-tracker-expand-button")
|
|
.setIcon(`chevron-${entry.collapsed ? 'right' : 'down'}`)
|
|
.setTooltip(entry.collapsed ? "Expand" : "Collapse")
|
|
.onClick(async () => {
|
|
if (entry.collapsed) {
|
|
delete entry.collapsed;
|
|
} else {
|
|
entry.collapsed = true;
|
|
}
|
|
await saveTracker(tracker, this.app, getFile(), getSectionInfo());
|
|
});
|
|
if (!entry.subEntries?.length) expandButton.buttonEl.style.visibility = 'hidden';
|
|
let nameWrapper = nameField.cell.createDiv({cls: "simple-time-tracker-table-expandwrapper"});
|
|
nameWrapper.style.marginLeft = nameField.label.style.marginLeft;
|
|
nameField.label.style.marginLeft = null;
|
|
nameWrapper.insertBefore(expandButton.buttonEl, null);
|
|
nameWrapper.insertBefore(nameField.label, null);
|
|
|
|
let entryButtons = row.createEl("td");
|
|
entryButtons.addClass("simple-time-tracker-table-buttons");
|
|
new ButtonComponent(entryButtons)
|
|
.setClass("clickable-icon")
|
|
.setIcon(`lucide-play`)
|
|
.setTooltip("Continue")
|
|
.setDisabled(trackerRunning)
|
|
.onClick(async () => {
|
|
startSubEntry(entry, newSegmentNameBox.getValue());
|
|
await saveTracker(tracker, this.app, getFile(), getSectionInfo());
|
|
});
|
|
let editButton = new ButtonComponent(entryButtons)
|
|
.setClass("clickable-icon")
|
|
.setTooltip("Edit")
|
|
.setIcon("lucide-pencil")
|
|
.onClick(async () => {
|
|
if (nameField.editing()) {
|
|
entry.name = nameField.endEdit();
|
|
expandButton.buttonEl.style.display = null;
|
|
startField.endEdit();
|
|
entry.startTime = startField.getTimestamp();
|
|
if (!entryRunning) {
|
|
endField.endEdit();
|
|
entry.endTime = endField.getTimestamp();
|
|
}
|
|
await saveTracker(tracker, this.app, getFile(), getSectionInfo());
|
|
editButton.setIcon("lucide-pencil");
|
|
|
|
renderNameAsMarkdown(nameField.label, getFile, component);
|
|
} else {
|
|
nameField.beginEdit(entry.name);
|
|
expandButton.buttonEl.style.display = 'none';
|
|
// only allow editing start and end times if we don't have sub entries
|
|
if (!entry.subEntries) {
|
|
startField.beginEdit(entry.startTime);
|
|
if (!entryRunning)
|
|
endField.beginEdit(entry.endTime);
|
|
}
|
|
editButton.setIcon("lucide-check");
|
|
}
|
|
});
|
|
new ButtonComponent(entryButtons)
|
|
.setClass("clickable-icon")
|
|
.setTooltip("Remove")
|
|
.setIcon("lucide-trash")
|
|
.setDisabled(entryRunning)
|
|
.onClick(async () => {
|
|
if (!confirm("Are you sure you want to delete this entry?")) {
|
|
return;
|
|
}
|
|
removeEntry(tracker.entries, entry);
|
|
await saveTracker(tracker, this.app, getFile(), getSectionInfo());
|
|
});
|
|
|
|
if (entry.subEntries && !entry.collapsed) {
|
|
for (let sub of orderedEntries(entry.subEntries, settings))
|
|
addEditableTableRow(tracker, sub, table, newSegmentNameBox, trackerRunning, getFile, getSectionInfo, settings, indent + 1, component);
|
|
}
|
|
}
|
|
|
|
function renderNameAsMarkdown(label: HTMLSpanElement, getFile: GetFile, component: Component): void {
|
|
void MarkdownRenderer.renderMarkdown(label.innerHTML, label, getFile(), component);
|
|
// rendering wraps it in a paragraph
|
|
label.innerHTML = label.querySelector("p").innerHTML;
|
|
}
|
|
|
|
|
|
class EditableField {
|
|
cell: HTMLTableCellElement;
|
|
label: HTMLSpanElement;
|
|
box: TextComponent;
|
|
|
|
constructor(row: HTMLTableRowElement, indent: number, value: string) {
|
|
this.cell = row.createEl("td");
|
|
this.label = this.cell.createEl("span", {text: value});
|
|
this.label.style.marginLeft = `${indent}em`;
|
|
this.box = new TextComponent(this.cell).setValue(value);
|
|
this.box.inputEl.addClass("simple-time-tracker-input");
|
|
this.box.inputEl.hide();
|
|
}
|
|
|
|
editing(): boolean {
|
|
return this.label.hidden;
|
|
}
|
|
|
|
beginEdit(value: string): void {
|
|
this.label.hidden = true;
|
|
this.box.setValue(value);
|
|
this.box.inputEl.show();
|
|
}
|
|
|
|
endEdit(): string {
|
|
const value = this.box.getValue();
|
|
this.label.setText(value);
|
|
this.box.inputEl.hide();
|
|
this.label.hidden = false;
|
|
return value;
|
|
}
|
|
}
|
|
|
|
class EditableTimestampField extends EditableField {
|
|
settings: SimpleTimeTrackerSettings;
|
|
|
|
constructor(row: HTMLTableRowElement, value: string, settings: SimpleTimeTrackerSettings) {
|
|
super(row, 0, value ? formatTimestamp(value, settings) : "");
|
|
this.settings = settings;
|
|
}
|
|
|
|
beginEdit(value: string): void {
|
|
super.beginEdit(value ? formatEditableTimestamp(value, this.settings) : "");
|
|
}
|
|
|
|
endEdit(): string {
|
|
const value = this.box.getValue();
|
|
let displayValue = value;
|
|
if (value) {
|
|
const timestamp = unformatEditableTimestamp(value, this.settings);
|
|
displayValue = formatTimestamp(timestamp, this.settings);
|
|
}
|
|
this.label.setText(displayValue);
|
|
this.box.inputEl.hide();
|
|
this.label.hidden = false;
|
|
return value;
|
|
}
|
|
|
|
getTimestamp(): string {
|
|
if (this.box.getValue()) {
|
|
return unformatEditableTimestamp(this.box.getValue(), this.settings);
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
}
|