Merge pull request #33 from jacobtread/feat-running-locator

feat: running timekeep locator, data-running-within and refactoring
This commit is contained in:
Jacob 2025-03-16 21:17:14 +13:00 committed by GitHub
commit ae028cef73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 403 additions and 282 deletions

View file

@ -1,6 +1,6 @@
import React from "react";
import { Timekeep } from "@/schema";
import { Store, useStore } from "@/store";
import { Timekeep } from "@/timekeep/schema";
import { App as ObsidianApp } from "obsidian";
import { TimekeepSettings } from "@/settings";
import { AppContext } from "@/contexts/use-app-context";

View file

@ -1,6 +1,6 @@
import moment from "moment";
import { useStore } from "@/store";
import { Timekeep } from "@/schema";
import { Timekeep } from "@/timekeep/schema";
import React, { useState, useEffect } from "react";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";

View file

@ -6,8 +6,8 @@ import { Notice } from "obsidian";
import { Platform } from "obsidian";
import { mkdir, writeFile } from "fs/promises";
import { PdfExportBehavior } from "@/settings";
import { stripTimekeepRuntimeData } from "@/schema";
import { createCSV, createMarkdownTable } from "@/export";
import { stripTimekeepRuntimeData } from "@/timekeep/schema";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";

View file

@ -1,5 +1,5 @@
import moment from "moment";
import { TimeEntry } from "@/schema";
import { TimeEntry } from "@/timekeep/schema";
import React, { useMemo, useState } from "react";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";

View file

@ -1,6 +1,6 @@
import moment from "moment";
import { TimeEntry } from "@/schema";
import { formatDurationLong } from "@/utils";
import { TimeEntry } from "@/timekeep/schema";
import React, { useState, useEffect } from "react";
import { isEntryRunning, getEntryDuration } from "@/timekeep";

View file

@ -1,4 +1,4 @@
import { TimeEntry } from "@/schema";
import { TimeEntry } from "@/timekeep/schema";
import { useDialog } from "@/contexts/use-dialog";
import { removeEntry, updateEntry } from "@/timekeep";
import { useSettings } from "@/contexts/use-settings-context";

View file

@ -1,4 +1,4 @@
import { TimeEntry } from "@/schema";
import { TimeEntry } from "@/timekeep/schema";
import React, { useMemo, Fragment } from "react";
import TimesheetRow from "@/components/TimesheetRow";
import { useSettings } from "@/contexts/use-settings-context";

View file

@ -1,6 +1,6 @@
import React from "react";
import { Timekeep, stripTimekeepRuntimeData } from "@/schema";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";
import { Timekeep, stripTimekeepRuntimeData } from "@/timekeep/schema";
type Props = {
// Callback to save the timekeep

View file

@ -1,6 +1,6 @@
import React from "react";
import { Timekeep } from "@/schema";
import type { Moment } from "moment";
import { Timekeep } from "@/timekeep/schema";
import { getTotalDuration } from "@/timekeep";
import { TimekeepSettings } from "@/settings";
import { Page, View, Text, Document, StyleSheet } from "@/pdf";

View file

@ -2,8 +2,8 @@ import type { Moment } from "moment";
import React, { Fragment } from "react";
import { getEntryDuration } from "@/timekeep";
import { TimekeepSettings } from "@/settings";
import { Timekeep, TimeEntry } from "@/schema";
import { View, Text, StyleSheet } from "@/pdf";
import { Timekeep, TimeEntry } from "@/timekeep/schema";
import { formatPdfRowDate, formatDurationLong } from "@/utils";
type Props = {

View file

@ -1,5 +1,5 @@
import { Store } from "@/store";
import { Timekeep } from "@/schema";
import { Timekeep } from "@/timekeep/schema";
import { useContext, createContext } from "react";
export type TimekeepStore = Store<Timekeep>;

View file

@ -1,5 +1,5 @@
import { Timekeep } from "@/schema";
import type { Moment } from "moment";
import { Timekeep } from "@/timekeep/schema";
import { TimekeepSettings } from "@/settings";
import { RawTableRow, createRawTable } from "@/export";

View file

@ -1,5 +1,5 @@
import { TimeEntry } from "@/schema";
import type { Moment } from "moment";
import { TimeEntry } from "@/timekeep/schema";
import { TimekeepSettings } from "@/settings";
import { formatDuration, formatTimestamp } from "@/utils";
import { getEntryDuration, getEntriesSorted } from "@/timekeep";

View file

@ -1,7 +1,7 @@
import type { Moment } from "moment";
import { formatDuration } from "@/utils";
import { getTotalDuration } from "@/timekeep";
import { Timekeep, TimeEntry } from "@/schema";
import { Timekeep, TimeEntry } from "@/timekeep/schema";
import { DurationFormat, TimekeepSettings } from "@/settings";
import { RawTableRow, TOTAL_COLUMNS, createRawTable } from "@/export";

View file

@ -3,19 +3,19 @@ import { Store, createStore } from "@/store";
import { TimekeepSettingsTab } from "@/settings-tab";
import { PluginManifest, App as ObsidianApp } from "obsidian";
import { Plugin, MarkdownPostProcessorContext } from "obsidian";
import { load, extractTimekeepCodeblocks } from "@/timekeep/parser";
import { SortOrder, defaultSettings, TimekeepSettings } from "@/settings";
import {
load,
isKeepRunning,
isEntryRunning,
getRunningEntry,
getEntryDuration,
getTotalDuration,
extractTimekeepCodeblocks,
} from "@/timekeep";
import { Timekeep, TimeEntry } from "./schema";
import { Timekeep, TimeEntry } from "./timekeep/schema";
import { TimekeepMarkdownView } from "./views/timekeep-markdown-view";
import { TimekeepLocatorModal } from "./views/timekeep-locator-modal";
export default class TimekeepPlugin extends Plugin {
settingsStore: Store<TimekeepSettings>;
@ -96,5 +96,11 @@ export default class TimekeepPlugin extends Plugin {
e.replaceSelection('\n```timekeep\n{"entries": []}\n```\n');
},
});
this.addCommand({
id: `find`,
name: `Find running trackers`,
callback: () => new TimekeepLocatorModal(this.app).open(),
});
}
}

View file

@ -1,11 +1,12 @@
import moment from "moment";
import { extractTimekeepCodeblocks } from "./parser";
import {
SortOrder,
UnstartedOrder,
defaultSettings,
TimekeepSettings,
} from "./settings";
} from "../settings";
import {
Timekeep,
TimeEntry,
@ -15,10 +16,7 @@ import {
stripTimekeepRuntimeData,
} from "./schema";
import {
load,
LoadError,
withEntry,
LoadSuccess,
createEntry,
removeEntry,
updateEntry,
@ -33,9 +31,7 @@ import {
setEntryCollapsed,
getUniqueEntryHash,
stopRunningEntries,
replaceTimekeepCodeblock,
extractTimekeepCodeblocks,
} from "./timekeep";
} from "./index";
/**
* Generates a code block surrounding the provided JSON
@ -64,132 +60,6 @@ const createCodeBlock = (
return output;
};
describe("replacing content", () => {
it("should replace codeblock contents", () => {
const lineStart = 4; // Line the codeblock should start on
const lineEnd = lineStart + 2; // Line the codeblock should end on
// Input data to replace
const input = createCodeBlock(
`{"entries":[{"name":"Block 1","startTime":"2024-03-17T01:33:51.630Z","endTime":"2024-03-17T01:33:55.151Z","subEntries":null}]}`,
4,
4
);
// Timekeep with a renamed block
const inputTimekeep: Timekeep = {
entries: [
{
id: "49b99108-b1ad-4355-baa9-89c49c342be2",
name: "Block 2",
startTime: moment("2024-03-17T01:33:51.630Z"),
endTime: moment("2024-03-17T01:33:55.151Z"),
subEntries: null,
},
],
};
// Value with the renamed block
const expected = createCodeBlock(
`{"entries":[{"name":"Block 2","startTime":"2024-03-17T01:33:51.630Z","endTime":"2024-03-17T01:33:55.151Z","subEntries":null}]}`,
4,
4
);
const output = replaceTimekeepCodeblock(
inputTimekeep,
input,
lineStart,
lineEnd
);
expect(output).toBe(expected);
});
it("should fail if codeblock is missing", () => {
const input = createCodeBlock("", 4, 4);
// Start not code fences
expect(() =>
replaceTimekeepCodeblock({ entries: [] }, input, 2, 4)
).toThrow();
// End not code fences
expect(() =>
replaceTimekeepCodeblock({ entries: [] }, input, 4, 8)
).toThrow();
});
});
describe("loading timekeep", () => {
it("should give empty timekeep when given empty string", () => {
const result = load("");
expect(result.success).toBe(true);
const successResult = result as LoadSuccess;
// Ensure the contents match
expect(successResult.timekeep).toEqual({ entries: [] });
});
it("should load valid timekeep successfully", () => {
const data = `{"entries":[{"name":"Block 1","startTime":"2024-03-17T01:33:51.630Z","endTime":"2024-03-17T01:33:55.151Z","subEntries":null},{"name":"Block 2","startTime":"2024-03-17T01:33:51.630Z","endTime":null,"subEntries":null},{"name":"Non Started Block","startTime":null,"endTime":null,"subEntries":null}]}`;
const expected = {
entries: [
{
name: "Block 1",
startTime: moment("2024-03-17T01:33:51.630Z"),
endTime: moment("2024-03-17T01:33:55.151Z"),
subEntries: null,
},
{
name: "Block 2",
startTime: moment("2024-03-17T01:33:51.630Z"),
endTime: null,
subEntries: null,
},
{
name: "Non Started Block",
startTime: null,
endTime: null,
subEntries: null,
},
],
};
const result = load(data);
expect(result.success).toBe(true);
const successResult = result as LoadSuccess;
// Ensure the contents match
expect(stripTimekeepRuntimeData(successResult.timekeep)).toEqual(
expected
);
});
it("should give error on invalid timekeep (JSON)", () => {
const data = "{";
const result = load(data);
expect(result.success).toBe(false);
const errorResult = result as LoadError;
expect(errorResult.error).toBe("Failed to parse timekeep JSON");
});
it("should give error on invalid timekeep (validation)", () => {
const data = `{"entries":[{"startTime":"2024-03-17T01:33:51.630Z","endTime":"2024-03-17T01:33:55.151Z","subEntries":null}]}`;
const result = load(data);
expect(result.success).toBe(false);
});
});
describe("manipulating entries", () => {
describe("update entry", () => {
it("updating existing entry should succeed", () => {

View file

@ -2,135 +2,8 @@ import { v4 as uuid } from "uuid";
import type { Moment } from "moment";
import { strHash } from "@/utils/text";
import { isEmptyString } from "@/utils";
import { Timekeep, TimeEntry, TimeEntryGroup } from "@/timekeep/schema";
import { SortOrder, UnstartedOrder, TimekeepSettings } from "@/settings";
import {
TIMEKEEP,
Timekeep,
TimeEntry,
TimeEntryGroup,
stripTimekeepRuntimeData,
} from "@/schema";
export type LoadResult = LoadSuccess | LoadError;
export type LoadSuccess = { success: true; timekeep: Timekeep };
export type LoadError = { success: false; error: string };
/**
* Attempts to load a {@see Timekeep} from the provided
* JSON string
*
* @param value The JSON string to load from
* @return The load result
*/
export function load(value: string): LoadResult {
// Empty string should create an empty timekeep
if (isEmptyString(value)) {
return { success: true, timekeep: { entries: [] } };
}
// Load the JSON value
let parsedValue: unknown;
try {
parsedValue = JSON.parse(value);
} catch (e) {
return {
success: false,
error: "Failed to parse timekeep JSON",
};
}
// Parse the data against the schema
const timekeepResult = TIMEKEEP.safeParse(parsedValue);
if (!timekeepResult.success) {
return {
success: false,
error: timekeepResult.error.toString(),
};
}
const timekeep = timekeepResult.data;
return { success: true, timekeep };
}
/**
* Extracts timekeep codeblocks from the provided file
* contents.
*
* @param value The file text contents
* @returns The extracted timekeep blocks
*/
export function extractTimekeepCodeblocks(value: string): Timekeep[] {
const out: Timekeep[] = [];
const lines = value.replace("\n\r", "\n").split("\n");
for (let i = 0; i < lines.length; i++) {
const startLine = lines[i];
// Skip lines till a timekeep block is found
if (!startLine.startsWith("```timekeep")) {
continue;
}
// Find end of codeblock
const endLineIndex = lines.indexOf("```", i);
if (endLineIndex === -1) {
continue;
}
let content = "";
for (let lineIndex = i + 1; lineIndex < endLineIndex; lineIndex++) {
content += lines[lineIndex] + "\n";
}
const result = load(content);
if (result.success) {
out.push(result.timekeep);
}
}
return out;
}
/**
* Replaces the contents of a specific timekeep codeblock within
* a file returning the modified contents to be saved
*/
export function replaceTimekeepCodeblock(
timekeep: Timekeep,
content: string,
lineStart: number,
lineEnd: number
): string {
const timekeepJSON = JSON.stringify(stripTimekeepRuntimeData(timekeep));
// The actual JSON is the line after the code block start
const contentStart = lineStart + 1;
const contentLength = lineEnd - contentStart;
// Split the content into lines
const lines = content.split("\n");
// Sanity checks to prevent overriding content
if (!lines[lineStart].startsWith("```")) {
throw new Error(
"Content timekeep out of sync, line number for codeblock start doesn't match: " +
content[lineStart]
);
}
if (!lines[lineEnd].startsWith("```")) {
throw new Error(
"Content timekeep out of sync, line number for codeblock end doesn't match" +
content[lineEnd]
);
}
// Splice the new JSON content in between the codeblock, removing the old codeblock lines
lines.splice(contentStart, contentLength, timekeepJSON);
return lines.join("\n");
}
/**
* Creates a new entry that has just started

171
src/timekeep/parser.test.ts Normal file
View file

@ -0,0 +1,171 @@
import moment from "moment";
import { Timekeep, stripTimekeepRuntimeData } from "./schema";
import {
load,
LoadError,
LoadSuccess,
replaceTimekeepCodeblock,
} from "./parser";
/**
* Generates a code block surrounding the provided JSON
* with the provided leading and trailing number of lines
*
* @param json The JSON to put between the codeblocks
* @param linesBefore Number of lines before the codeblock
* @param linesAfter Number of lines after the codeblock
* @returns The generated codeblock
*/
const createCodeBlock = (
json: string,
linesBefore: number,
linesAfter: number
) => {
let output = "";
for (let i = 0; i < linesBefore; i++) {
output += "\n";
}
output += "```timekeep\n";
output += json;
output += "\n```";
for (let i = 0; i < linesAfter; i++) {
output += "\n";
}
return output;
};
describe("replacing content", () => {
it("should replace codeblock contents", () => {
const lineStart = 4; // Line the codeblock should start on
const lineEnd = lineStart + 2; // Line the codeblock should end on
// Input data to replace
const input = createCodeBlock(
`{"entries":[{"name":"Block 1","startTime":"2024-03-17T01:33:51.630Z","endTime":"2024-03-17T01:33:55.151Z","subEntries":null}]}`,
4,
4
);
// Timekeep with a renamed block
const inputTimekeep: Timekeep = {
entries: [
{
id: "49b99108-b1ad-4355-baa9-89c49c342be2",
name: "Block 2",
startTime: moment("2024-03-17T01:33:51.630Z"),
endTime: moment("2024-03-17T01:33:55.151Z"),
subEntries: null,
},
],
};
// Value with the renamed block
const expected = createCodeBlock(
`{"entries":[{"name":"Block 2","startTime":"2024-03-17T01:33:51.630Z","endTime":"2024-03-17T01:33:55.151Z","subEntries":null}]}`,
4,
4
);
const output = replaceTimekeepCodeblock(
inputTimekeep,
input,
lineStart,
lineEnd
);
expect(output).toBe(expected);
});
it("should fail if codeblock is missing", () => {
const input = createCodeBlock("", 4, 4);
// Start not code fences
expect(() =>
replaceTimekeepCodeblock({ entries: [] }, input, 2, 4)
).toThrow();
// End not code fences
expect(() =>
replaceTimekeepCodeblock({ entries: [] }, input, 4, 8)
).toThrow();
});
});
describe("loading timekeep", () => {
it("should give empty timekeep when given empty string", () => {
const result = load("");
expect(result.success).toBe(true);
const successResult = result as LoadSuccess;
// Ensure the contents match
expect(successResult.timekeep).toEqual({ entries: [] });
});
it("should load valid timekeep successfully", () => {
const data = `{"entries":[{"name":"Block 1","startTime":"2024-03-17T01:33:51.630Z","endTime":"2024-03-17T01:33:55.151Z","subEntries":null},{"name":"Block 2","startTime":"2024-03-17T01:33:51.630Z","endTime":null,"subEntries":null},{"name":"Non Started Block","startTime":null,"endTime":null,"subEntries":null}]}`;
const expected = {
entries: [
{
name: "Block 1",
startTime: moment("2024-03-17T01:33:51.630Z"),
endTime: moment("2024-03-17T01:33:55.151Z"),
subEntries: null,
},
{
name: "Block 2",
startTime: moment("2024-03-17T01:33:51.630Z"),
endTime: null,
subEntries: null,
},
{
name: "Non Started Block",
startTime: null,
endTime: null,
subEntries: null,
},
],
};
const result = load(data);
expect(result.success).toBe(true);
const successResult = result as LoadSuccess;
// Ensure the contents match
expect(stripTimekeepRuntimeData(successResult.timekeep)).toEqual(
expected
);
});
it("should give error on invalid timekeep (JSON)", () => {
const data = "{";
const result = load(data);
expect(result.success).toBe(false);
const errorResult = result as LoadError;
expect(errorResult.error).toBe("Failed to parse timekeep JSON");
});
it("should tolerate a timekeep with leading or trailing whitespaces", () => {
const input = `
\`\`\`timekeep
\`\`\`
`;
// Start not code fences
replaceTimekeepCodeblock({ entries: [] }, input, 1, 2);
});
it("should give error on invalid timekeep (validation)", () => {
const data = `{"entries":[{"startTime":"2024-03-17T01:33:51.630Z","endTime":"2024-03-17T01:33:55.151Z","subEntries":null}]}`;
const result = load(data);
expect(result.success).toBe(false);
});
});

129
src/timekeep/parser.ts Normal file
View file

@ -0,0 +1,129 @@
import { isEmptyString } from "@/utils";
import {
TIMEKEEP,
Timekeep,
stripTimekeepRuntimeData,
} from "@/timekeep/schema";
export type LoadResult = LoadSuccess | LoadError;
export type LoadSuccess = { success: true; timekeep: Timekeep };
export type LoadError = { success: false; error: string };
/**
* Attempts to load a {@see Timekeep} from the provided
* JSON string
*
* @param value The JSON string to load from
* @return The load result
*/
export function load(value: string): LoadResult {
// Empty string should create an empty timekeep
if (isEmptyString(value)) {
return { success: true, timekeep: { entries: [] } };
}
// Load the JSON value
let parsedValue: unknown;
try {
parsedValue = JSON.parse(value);
} catch (e) {
return {
success: false,
error: "Failed to parse timekeep JSON",
};
}
// Parse the data against the schema
const timekeepResult = TIMEKEEP.safeParse(parsedValue);
if (!timekeepResult.success) {
return {
success: false,
error: timekeepResult.error.toString(),
};
}
const timekeep = timekeepResult.data;
return { success: true, timekeep };
}
/**
* Extracts timekeep codeblocks from the provided file
* contents.
*
* @param value The file text contents
* @returns The extracted timekeep blocks
*/
export function extractTimekeepCodeblocks(value: string): Timekeep[] {
const out: Timekeep[] = [];
const lines = value.replace("\n\r", "\n").split("\n");
for (let i = 0; i < lines.length; i++) {
const startLine = lines[i];
// Skip lines till a timekeep block is found
if (!startLine.trim().startsWith("```timekeep")) {
continue;
}
// Find end of codeblock
const endLineIndex = lines.findIndex(
(line, index) => index > i && line.trim() === "```"
);
if (endLineIndex === -1) {
continue;
}
let content = "";
for (let lineIndex = i + 1; lineIndex < endLineIndex; lineIndex++) {
content += lines[lineIndex] + "\n";
}
const result = load(content);
if (result.success) {
out.push(result.timekeep);
}
}
return out;
}
/**
* Replaces the contents of a specific timekeep codeblock within
* a file returning the modified contents to be saved
*/
export function replaceTimekeepCodeblock(
timekeep: Timekeep,
content: string,
lineStart: number,
lineEnd: number
): string {
const timekeepJSON = JSON.stringify(stripTimekeepRuntimeData(timekeep));
// The actual JSON is the line after the code block start
const contentStart = lineStart + 1;
const contentLength = lineEnd - contentStart;
// Split the content into lines
const lines = content.split("\n");
// Sanity checks to prevent overriding content
if (!lines[lineStart].trim().startsWith("```")) {
throw new Error(
"Content timekeep out of sync, line number for codeblock start doesn't match: " +
content[lineStart]
);
}
if (!lines[lineEnd].trim().startsWith("```")) {
throw new Error(
"Content timekeep out of sync, line number for codeblock end doesn't match" +
content[lineEnd]
);
}
// Splice the new JSON content in between the codeblock, removing the old codeblock lines
lines.splice(contentStart, contentLength, timekeepJSON);
return lines.join("\n");
}

View file

@ -1,7 +1,6 @@
import moment from "moment";
import { v4 as uuid } from "uuid";
import { TIMEKEEP } from "./schema";
import { TIMEKEEP } from "@/timekeep/schema";
jest.mock("uuid", () => ({
v4: jest.fn(() => "mocked-uuid"),

View file

@ -0,0 +1,73 @@
import { getRunningEntry } from "@/timekeep";
import { App, TFile, SuggestModal } from "obsidian";
import { Timekeep, TimeEntry } from "@/timekeep/schema";
import { extractTimekeepCodeblocks } from "@/timekeep/parser";
interface TimekeepResult {
timekeep: Timekeep;
running: TimeEntry;
file: TFile;
}
export class TimekeepLocatorModal extends SuggestModal<TimekeepResult> {
results: TimekeepResult[] | undefined = undefined;
constructor(app: App) {
super(app);
}
async getSuggestions(query: string): Promise<TimekeepResult[]> {
if (this.results === undefined) {
this.results = await this.getResults();
}
const queryLower = query.toLowerCase();
return this.results.filter((result) => {
return (
result.running.name.toLowerCase().contains(queryLower) ||
result.file.path.toLowerCase().contains(queryLower)
);
});
}
async getResults(): Promise<TimekeepResult[]> {
const markdownFiles = this.app.vault.getMarkdownFiles();
const batchSize = 10;
const results: TimekeepResult[] = [];
for (let i = 0; i < markdownFiles.length; i += batchSize) {
const batch = markdownFiles.slice(i, i + batchSize);
await Promise.allSettled(
batch.map(async (file) => {
const content = await this.app.vault.cachedRead(file);
const timekeeps = extractTimekeepCodeblocks(content);
console.log(timekeeps);
for (const timekeep of timekeeps) {
const running = getRunningEntry(timekeep.entries);
if (running === null) continue;
results.push({
timekeep,
running,
file,
});
}
})
);
}
return results;
}
renderSuggestion(value: TimekeepResult, el: HTMLElement) {
el.createEl("div", { text: value.running.name });
el.createEl("small", { text: value.file.path });
}
onChooseSuggestion(item: TimekeepResult, _evt: MouseEvent | KeyboardEvent) {
this.app.workspace.getLeaf().openFile(item.file);
}
}

View file

@ -5,8 +5,8 @@ import { Store, createStore } from "@/store";
import { App as ObsidianApp } from "obsidian";
import { TimekeepSettings } from "@/settings";
import { Root, createRoot } from "react-dom/client";
import { Timekeep, stripTimekeepRuntimeData } from "@/schema";
import { LoadResult, replaceTimekeepCodeblock } from "@/timekeep";
import { Timekeep, stripTimekeepRuntimeData } from "@/timekeep/schema";
import { LoadResult, replaceTimekeepCodeblock } from "@/timekeep/parser";
import {
TFile,
TAbstractFile,