mirror of
https://github.com/jacobtread/obsidian-timekeep.git
synced 2026-07-22 10:10:27 +00:00
Merge pull request #33 from jacobtread/feat-running-locator
feat: running timekeep locator, data-running-within and refactoring
This commit is contained in:
commit
ae028cef73
23 changed files with 403 additions and 282 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
12
src/main.ts
12
src/main.ts
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
@ -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
171
src/timekeep/parser.test.ts
Normal 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
129
src/timekeep/parser.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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"),
|
||||
73
src/views/timekeep-locator-modal.ts
Normal file
73
src/views/timekeep-locator-modal.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue