From c24e164d7bb977dd259a6656f9d7d4cdb76ed6f1 Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Wed, 5 Mar 2025 02:24:57 +1300 Subject: [PATCH 1/3] feat: command to find currently running trackers --- src/main.ts | 7 +++ src/timekeep.ts | 7 ++- src/views/timekeep-locator-modal.ts | 72 +++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/views/timekeep-locator-modal.ts diff --git a/src/main.ts b/src/main.ts index c33b601..f904855 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import { import { Timekeep, TimeEntry } from "./schema"; import { TimekeepMarkdownView } from "./views/timekeep-markdown-view"; +import { TimekeepLocatorModal } from "./views/timekeep-locator-modal"; export default class TimekeepPlugin extends Plugin { settingsStore: Store; @@ -96,5 +97,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(), + }); } } diff --git a/src/timekeep.ts b/src/timekeep.ts index 13467a7..44f4a85 100644 --- a/src/timekeep.ts +++ b/src/timekeep.ts @@ -73,7 +73,10 @@ export function extractTimekeepCodeblocks(value: string): Timekeep[] { } // Find end of codeblock - const endLineIndex = lines.indexOf("```", i); + const endLineIndex = lines.findIndex( + (line, index) => index > i && line.trim() === "```" + ); + if (endLineIndex === -1) { continue; } @@ -119,7 +122,7 @@ export function replaceTimekeepCodeblock( ); } - if (!lines[lineEnd].startsWith("```")) { + if (!lines[lineEnd].trim().startsWith("```")) { throw new Error( "Content timekeep out of sync, line number for codeblock end doesn't match" + content[lineEnd] diff --git a/src/views/timekeep-locator-modal.ts b/src/views/timekeep-locator-modal.ts new file mode 100644 index 0000000..dfe15a6 --- /dev/null +++ b/src/views/timekeep-locator-modal.ts @@ -0,0 +1,72 @@ +import { Timekeep, TimeEntry } from "@/schema"; +import { App, TFile, SuggestModal } from "obsidian"; +import { getRunningEntry, extractTimekeepCodeblocks } from "@/timekeep"; + +interface TimekeepResult { + timekeep: Timekeep; + running: TimeEntry; + file: TFile; +} + +export class TimekeepLocatorModal extends SuggestModal { + results: TimekeepResult[] | undefined = undefined; + + constructor(app: App) { + super(app); + } + + async getSuggestions(query: string): Promise { + 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 { + 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); + } +} From a6412d55f975f8eca6ad5f3bc466e91094758a22 Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Sun, 16 Mar 2025 20:56:50 +1300 Subject: [PATCH 2/3] refactor: reorganize modules and split parsing from manipulation --- src/App.tsx | 2 +- src/components/TimesheetCounters.tsx | 2 +- src/components/TimesheetExportActions.tsx | 2 +- src/components/TimesheetRow.tsx | 2 +- src/components/TimesheetRowDuration.tsx | 2 +- src/components/TimesheetRowEditing.tsx | 2 +- src/components/TimesheetRows.tsx | 2 +- src/components/TimesheetSaveError.tsx | 2 +- src/components/pdf/TimesheetPdf.tsx | 2 +- src/components/pdf/TimesheetPdfTable.tsx | 2 +- src/contexts/use-timekeep-store.ts | 2 +- src/export/csv.ts | 2 +- src/export/index.ts | 2 +- src/export/markdown-table.ts | 2 +- src/main.ts | 5 +- .../index.test.ts} | 136 +-------------- src/{timekeep.ts => timekeep/index.ts} | 132 +------------- src/timekeep/parser.test.ts | 162 ++++++++++++++++++ src/timekeep/parser.ts | 129 ++++++++++++++ src/{ => timekeep}/schema.test.ts | 3 +- src/{ => timekeep}/schema.ts | 0 src/views/timekeep-locator-modal.ts | 5 +- src/views/timekeep-markdown-view.ts | 4 +- 23 files changed, 317 insertions(+), 287 deletions(-) rename src/{timekeep.test.ts => timekeep/index.test.ts} (92%) rename src/{timekeep.ts => timekeep/index.ts} (81%) create mode 100644 src/timekeep/parser.test.ts create mode 100644 src/timekeep/parser.ts rename src/{ => timekeep}/schema.test.ts (96%) rename src/{ => timekeep}/schema.ts (100%) diff --git a/src/App.tsx b/src/App.tsx index 9b6c881..e75dcf9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; diff --git a/src/components/TimesheetCounters.tsx b/src/components/TimesheetCounters.tsx index 1b0fcd7..b0097e7 100644 --- a/src/components/TimesheetCounters.tsx +++ b/src/components/TimesheetCounters.tsx @@ -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"; diff --git a/src/components/TimesheetExportActions.tsx b/src/components/TimesheetExportActions.tsx index 16488a1..a85217f 100644 --- a/src/components/TimesheetExportActions.tsx +++ b/src/components/TimesheetExportActions.tsx @@ -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"; diff --git a/src/components/TimesheetRow.tsx b/src/components/TimesheetRow.tsx index e53af97..4c572d3 100644 --- a/src/components/TimesheetRow.tsx +++ b/src/components/TimesheetRow.tsx @@ -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"; diff --git a/src/components/TimesheetRowDuration.tsx b/src/components/TimesheetRowDuration.tsx index 2324a21..3fbc39e 100644 --- a/src/components/TimesheetRowDuration.tsx +++ b/src/components/TimesheetRowDuration.tsx @@ -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"; diff --git a/src/components/TimesheetRowEditing.tsx b/src/components/TimesheetRowEditing.tsx index bf42612..dce28d7 100644 --- a/src/components/TimesheetRowEditing.tsx +++ b/src/components/TimesheetRowEditing.tsx @@ -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"; diff --git a/src/components/TimesheetRows.tsx b/src/components/TimesheetRows.tsx index 3b79971..abca230 100644 --- a/src/components/TimesheetRows.tsx +++ b/src/components/TimesheetRows.tsx @@ -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"; diff --git a/src/components/TimesheetSaveError.tsx b/src/components/TimesheetSaveError.tsx index 8a0a649..61e2e4f 100644 --- a/src/components/TimesheetSaveError.tsx +++ b/src/components/TimesheetSaveError.tsx @@ -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 diff --git a/src/components/pdf/TimesheetPdf.tsx b/src/components/pdf/TimesheetPdf.tsx index dafe3bf..0b15a9a 100644 --- a/src/components/pdf/TimesheetPdf.tsx +++ b/src/components/pdf/TimesheetPdf.tsx @@ -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"; diff --git a/src/components/pdf/TimesheetPdfTable.tsx b/src/components/pdf/TimesheetPdfTable.tsx index 5fa1f92..2ded053 100644 --- a/src/components/pdf/TimesheetPdfTable.tsx +++ b/src/components/pdf/TimesheetPdfTable.tsx @@ -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 = { diff --git a/src/contexts/use-timekeep-store.ts b/src/contexts/use-timekeep-store.ts index c784e47..c342d44 100644 --- a/src/contexts/use-timekeep-store.ts +++ b/src/contexts/use-timekeep-store.ts @@ -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; diff --git a/src/export/csv.ts b/src/export/csv.ts index 4f5aab6..d193beb 100644 --- a/src/export/csv.ts +++ b/src/export/csv.ts @@ -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"; diff --git a/src/export/index.ts b/src/export/index.ts index b1b7475..7df1ae8 100644 --- a/src/export/index.ts +++ b/src/export/index.ts @@ -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"; diff --git a/src/export/markdown-table.ts b/src/export/markdown-table.ts index cf5a958..428599a 100644 --- a/src/export/markdown-table.ts +++ b/src/export/markdown-table.ts @@ -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"; diff --git a/src/main.ts b/src/main.ts index f904855..7493819 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,18 +3,17 @@ 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"; diff --git a/src/timekeep.test.ts b/src/timekeep/index.test.ts similarity index 92% rename from src/timekeep.test.ts rename to src/timekeep/index.test.ts index ee96708..aadc39a 100644 --- a/src/timekeep.test.ts +++ b/src/timekeep/index.test.ts @@ -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", () => { diff --git a/src/timekeep.ts b/src/timekeep/index.ts similarity index 81% rename from src/timekeep.ts rename to src/timekeep/index.ts index 44f4a85..d84ece0 100644 --- a/src/timekeep.ts +++ b/src/timekeep/index.ts @@ -2,138 +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.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].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"); -} /** * Creates a new entry that has just started diff --git a/src/timekeep/parser.test.ts b/src/timekeep/parser.test.ts new file mode 100644 index 0000000..b2c97a6 --- /dev/null +++ b/src/timekeep/parser.test.ts @@ -0,0 +1,162 @@ +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 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); + }); +}); diff --git a/src/timekeep/parser.ts b/src/timekeep/parser.ts new file mode 100644 index 0000000..788409e --- /dev/null +++ b/src/timekeep/parser.ts @@ -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.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].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"); +} diff --git a/src/schema.test.ts b/src/timekeep/schema.test.ts similarity index 96% rename from src/schema.test.ts rename to src/timekeep/schema.test.ts index 1acc009..00ed834 100644 --- a/src/schema.test.ts +++ b/src/timekeep/schema.test.ts @@ -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"), diff --git a/src/schema.ts b/src/timekeep/schema.ts similarity index 100% rename from src/schema.ts rename to src/timekeep/schema.ts diff --git a/src/views/timekeep-locator-modal.ts b/src/views/timekeep-locator-modal.ts index dfe15a6..89585e8 100644 --- a/src/views/timekeep-locator-modal.ts +++ b/src/views/timekeep-locator-modal.ts @@ -1,6 +1,7 @@ -import { Timekeep, TimeEntry } from "@/schema"; +import { getRunningEntry } from "@/timekeep"; import { App, TFile, SuggestModal } from "obsidian"; -import { getRunningEntry, extractTimekeepCodeblocks } from "@/timekeep"; +import { Timekeep, TimeEntry } from "@/timekeep/schema"; +import { extractTimekeepCodeblocks } from "@/timekeep/parser"; interface TimekeepResult { timekeep: Timekeep; diff --git a/src/views/timekeep-markdown-view.ts b/src/views/timekeep-markdown-view.ts index 001e84a..1ab5559 100644 --- a/src/views/timekeep-markdown-view.ts +++ b/src/views/timekeep-markdown-view.ts @@ -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, From 83d7e78e26817a1c6fbdea5b03727ab2842e8bb6 Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Sun, 16 Mar 2025 21:08:03 +1300 Subject: [PATCH 3/3] feat: testing and checks for tolerating whitespace on code fences --- src/timekeep/parser.test.ts | 9 +++++++++ src/timekeep/parser.ts | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/timekeep/parser.test.ts b/src/timekeep/parser.test.ts index b2c97a6..8633db1 100644 --- a/src/timekeep/parser.test.ts +++ b/src/timekeep/parser.test.ts @@ -152,6 +152,15 @@ describe("loading timekeep", () => { 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}]}`; diff --git a/src/timekeep/parser.ts b/src/timekeep/parser.ts index 788409e..c5f3c4b 100644 --- a/src/timekeep/parser.ts +++ b/src/timekeep/parser.ts @@ -61,7 +61,7 @@ export function extractTimekeepCodeblocks(value: string): Timekeep[] { const startLine = lines[i]; // Skip lines till a timekeep block is found - if (!startLine.startsWith("```timekeep")) { + if (!startLine.trim().startsWith("```timekeep")) { continue; } @@ -108,7 +108,7 @@ export function replaceTimekeepCodeblock( const lines = content.split("\n"); // Sanity checks to prevent overriding content - if (!lines[lineStart].startsWith("```")) { + if (!lines[lineStart].trim().startsWith("```")) { throw new Error( "Content timekeep out of sync, line number for codeblock start doesn't match: " + content[lineStart]