feat: autocomplete and registry improvements

This commit is contained in:
Jacobtread 2026-03-29 17:53:11 +13:00
parent 8ce8b29dd9
commit 98da21c0de
23 changed files with 874 additions and 90 deletions

View file

@ -9,6 +9,7 @@
"timesheet",
"unstarted",
"Timekeeps",
"pdfmake"
"pdfmake",
"activedescendant"
]
}

10
package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "1.16.0",
"license": "MIT",
"dependencies": {
"fuse.js": "^7.1.0",
"moment": "^2.30.1",
"p-limit": "^7.3.0",
"pdfmake": "^0.3.7",
@ -1664,6 +1665,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",

View file

@ -18,6 +18,7 @@
"test": "vitest run --coverage"
},
"dependencies": {
"fuse.js": "^7.1.0",
"moment": "^2.30.1",
"p-limit": "^7.3.0",
"pdfmake": "^0.3.7",

View file

@ -1,6 +1,7 @@
import { App, Component } from "obsidian";
import { CustomOutputFormat } from "@/output";
import { TimekeepAutocomplete } from "@/service/autocomplete";
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { Timekeep } from "@/timekeep/schema";
@ -26,6 +27,8 @@ export class Timesheet extends Component {
settings: Store<TimekeepSettings>;
/** Access to custom output formats */
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
/** Callback to save the timekeep */
handleSaveTimekeep: (value: Timekeep) => Promise<void>;
@ -40,6 +43,7 @@ export class Timesheet extends Component {
saveError: Store<boolean>,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
autocomplete: TimekeepAutocomplete,
handleSaveTimekeep: (value: Timekeep) => Promise<void>
) {
super();
@ -51,6 +55,7 @@ export class Timesheet extends Component {
this.saveError = saveError;
this.settings = settings;
this.customOutputFormats = customOutputFormats;
this.autocomplete = autocomplete;
this.handleSaveTimekeep = handleSaveTimekeep;
}
@ -83,6 +88,7 @@ export class Timesheet extends Component {
this.timekeep,
this.settings,
this.customOutputFormats,
this.autocomplete,
this.handleSaveTimekeep
);
}

View file

@ -1,6 +1,7 @@
import { App, Component } from "obsidian";
import { CustomOutputFormat } from "@/output";
import { TimekeepAutocomplete } from "@/service/autocomplete";
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { Timekeep } from "@/timekeep/schema";
@ -25,6 +26,8 @@ export class TimesheetApp extends Component {
settings: Store<TimekeepSettings>;
/** Access to custom output formats */
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
/** Callback to save the timekeep */
handleSaveTimekeep: (value: Timekeep) => Promise<void>;
@ -38,6 +41,7 @@ export class TimesheetApp extends Component {
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
autocomplete: TimekeepAutocomplete,
handleSaveTimekeep: (value: Timekeep) => Promise<void>
) {
super();
@ -48,6 +52,8 @@ export class TimesheetApp extends Component {
this.timekeep = timekeep;
this.settings = settings;
this.customOutputFormats = customOutputFormats;
this.autocomplete = autocomplete;
this.handleSaveTimekeep = handleSaveTimekeep;
}
@ -67,7 +73,13 @@ export class TimesheetApp extends Component {
const counters = new TimesheetCounters(wrapperEl, this.settings, this.timekeep);
const start = new TimesheetStart(wrapperEl, this.app, this.timekeep, this.settings);
const start = new TimesheetStart(
wrapperEl,
this.app,
this.timekeep,
this.settings,
this.autocomplete
);
const table = new TimesheetTable(wrapperEl, this.app, this.timekeep, this.settings);

View file

@ -0,0 +1,374 @@
import Fuse, { FuseResult } from "fuse.js";
import { Component, debounce } from "obsidian";
import { TimekeepAutocomplete } from "@/service/autocomplete";
export class TimesheetNameInput extends Component {
#containerEl: HTMLElement;
/** Access to autocomplete */
autocomplete: TimekeepAutocomplete;
/** Name input wrapper container */
#wrapperEl: HTMLDivElement | undefined;
/** Name input */
#inputEl: HTMLInputElement | undefined;
/** Suggestions container element */
#suggestionsEl: HTMLDivElement | undefined;
/** Current list of suggestions */
#suggestions: FuseResult<string>[] = [];
/** Whether the suggestions should be open */
#suggestionsOpen: boolean = false;
/** Index of the currently focused suggestion */
#suggestionFocusIndex: number = -1;
constructor(containerEl: HTMLElement, autocomplete: TimekeepAutocomplete) {
super();
this.#containerEl = containerEl;
this.autocomplete = autocomplete;
}
onload(): void {
super.onload();
const wrapperEl = this.#containerEl.createDiv({ cls: "timekeep-name-containers" });
this.#wrapperEl = wrapperEl;
const inputEl = wrapperEl.createEl("input", {
cls: "timekeep-name",
placeholder: "Example Block",
type: "text",
});
inputEl.id = "timekeepBlockName";
inputEl.role = "combobox";
inputEl.setAttribute("aria-expanded", "false");
inputEl.setAttribute("aria-controls", "timekeepSuggestions");
inputEl.setAttribute("aria-autocomplete", "list");
this.#inputEl = inputEl;
const suggestionsEl = wrapperEl.createDiv({ cls: "timekeep-suggestions" });
suggestionsEl.id = "timekeepSuggestions";
suggestionsEl.role = "listbox";
suggestionsEl.hidden = true;
this.#suggestionsEl = suggestionsEl;
this.registerDomEvent(
inputEl,
"input",
debounce(this.onDebouncedChange.bind(this), 300, true)
);
this.registerDomEvent(inputEl, "focus", this.onFocus.bind(this));
this.registerDomEvent(inputEl, "keydown", this.onKeyDown.bind(this));
this.registerDomEvent(wrapperEl, "mousedown", this.onClickOutside.bind(this));
this.registerDomEvent(wrapperEl, "touchstart", this.onClickOutside.bind(this), {
passive: true,
});
this.registerDomEvent(suggestionsEl, "mousedown", this.onClickSuggestions.bind(this));
}
onunload(): void {
super.onunload();
this.#wrapperEl?.remove();
}
/**
* Get the current list of suggestions based on the currently
* typed input words
*/
getFilteredSuggestions(): FuseResult<string>[] {
const autocomplete = this.autocomplete;
const suggestions = autocomplete.names.getState();
const value = this.getValue();
const fuse = new Fuse(suggestions, {
includeMatches: true,
shouldSort: true,
});
const results = fuse.search(value);
return results;
}
/**
* Renders the suggestion children within the suggestion container
* for the current available suggestions
*/
renderSuggestions() {
const suggestionsEl = this.#suggestionsEl;
if (!suggestionsEl) return;
suggestionsEl.empty();
const suggestions = this.#suggestions;
if (suggestions.length < 1) return;
for (let i = 0; i < suggestions.length; i += 1) {
const result = suggestions[i];
const suggestion = result.item;
const suggestionEl = suggestionsEl.createDiv({ cls: "timekeep-suggestion" });
suggestionEl.role = "option";
suggestionEl.id = `timekeepSuggestion-${i}`;
suggestionEl.setAttribute("aria-selected", "false");
suggestionEl.setAttribute("value", suggestion);
if (result.matches && result.matches.length > 0) {
const match = result.matches[0];
let lastIndex = 0;
for (const [start, end] of match.indices) {
if (start > lastIndex) {
suggestionEl.appendText(suggestion.slice(lastIndex, start));
}
const text = suggestion.slice(start, end + 1);
suggestionEl.createEl("mark", { text });
lastIndex = end + 1;
}
if (lastIndex < suggestion.length) {
suggestionEl.appendText(suggestion.slice(lastIndex));
}
} else {
suggestionEl.setText(suggestion);
}
}
}
/**
* Handle clicking suggestion elements
*
* @param event The click event
*/
onClickSuggestions(event: MouseEvent) {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (!target.id.startsWith("timekeepSuggestion-")) return;
const suggestion = target;
const value = suggestion.getAttribute("value");
if (!value) return;
this.onSelectSuggestion(value);
}
/**
* Handle changes to the input value, this updates the filtered
* suggestions list and triggers re-rendering of the suggestions
*/
onDebouncedChange() {
const suggestions = this.getFilteredSuggestions();
this.#suggestions = suggestions;
this.updateSuggestionsOpen();
this.renderSuggestions();
if (suggestions.length > 0) {
this.setSuggestionFocus(0);
}
}
/**
* Handle clicks and close the suggestion box if they are
* outside of the container
*
* @param event The click / touch event
*/
onClickOutside(event: MouseEvent | TouchEvent) {
if (
event.target &&
event.target instanceof HTMLElement &&
this.#wrapperEl &&
!this.#wrapperEl.contains(event.target)
) {
this.setSuggestionsOpen(false);
}
}
/**
* Handle input focus, this should open the suggestions
* box if it is not already open
*/
onFocus() {
this.setSuggestionsOpen(true);
}
/**
* Handle keyboard events for selecting the suggestion
* items using the keyboard
*
* @param event The keyboard event
*/
onKeyDown(event: KeyboardEvent) {
const suggestionsEl = this.#suggestionsEl;
if (!suggestionsEl) return;
const suggestionsOpen = !(suggestionsEl.hidden ?? false);
if (!suggestionsOpen && event.key === "ArrowDown") {
this.setSuggestionsOpen(true);
this.setSuggestionFocus(0);
return;
}
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
const focusIndex = this.#suggestionFocusIndex;
const nextFocusIndex = Math.min(focusIndex + 1, this.#suggestions.length - 1);
this.setSuggestionFocus(nextFocusIndex);
break;
}
case "ArrowUp": {
event.preventDefault();
const focusIndex = this.#suggestionFocusIndex;
const prevFocusIndex = Math.max(focusIndex - 1, 0);
this.setSuggestionFocus(prevFocusIndex);
break;
}
case "Enter": {
const focusIndex = this.#suggestionFocusIndex;
if (focusIndex >= 0) {
event.preventDefault();
const suggestion = this.#suggestions[focusIndex];
if (suggestion) {
this.onSelectSuggestion(suggestion.item);
}
}
break;
}
case "Escape": {
this.setSuggestionsOpen(false);
break;
}
default:
break;
}
}
/**
* Clamps the current suggestion focus index to be within
* the current suggestion bounds
*/
clampSuggestionFocus() {
if (this.#suggestions.length > 0) {
const focusIndex = this.#suggestionFocusIndex;
const newFocusIndex =
focusIndex === -1 ? 0 : Math.min(focusIndex, this.#suggestions.length - 1);
this.setSuggestionFocus(newFocusIndex);
} else {
this.setSuggestionFocus(-1);
}
}
/**
* Set the focused suggestion index within the drop down menu.
*
* @param index The index that is focused
*/
setSuggestionFocus(index: number) {
this.#suggestionFocusIndex = index;
const inputEl = this.#inputEl;
const suggestionsEl = this.#suggestionsEl;
if (!suggestionsEl || !inputEl) return;
const children = suggestionsEl.querySelectorAll(".timekeep-suggestion");
for (let i = 0; i < children.length; i++) {
const child = children.item(i);
const selected = index === i;
child.setAttribute("aria-selected", String(selected));
// Bring the suggestion element into view if its selected
if (selected) {
child.scrollIntoView({
block: "nearest",
});
}
}
// Update the input aria-activedescendant for screen readers
if (index === -1) {
inputEl.removeAttribute("aria-activedescendant");
} else {
inputEl.setAttribute(
"aria-activedescendant",
`timekeepSuggestion-${this.#suggestionFocusIndex}`
);
}
}
/**
* Set the open state of the suggestions box
*
* @param value The open state of the box
*/
setSuggestionsOpen(value: boolean) {
this.#suggestionsOpen = value;
this.updateSuggestionsOpen();
}
/**
* Updates the open state of the suggestion box to actually show or
* hide the element, this is to ensure an empty suggestion box is
* not shown
*/
updateSuggestionsOpen() {
const inputEl = this.#inputEl;
const suggestionsEl = this.#suggestionsEl;
if (!inputEl || !suggestionsEl) return;
// Force closed state if theres no suggestions
const open = this.#suggestions.length < 1 ? false : this.#suggestionsOpen;
suggestionsEl.hidden = !open;
inputEl.setAttribute("aria-expanded", String(open));
// Update the focus
this.clampSuggestionFocus();
}
/**
* Handle selecting a suggestion value, this updates the
* current input value to match the suggestion
*
* @param value The suggestion value
*/
onSelectSuggestion(value: string) {
if (this.#inputEl) {
this.#inputEl.value = value;
}
this.setSuggestionsOpen(false);
this.setSuggestionFocus(-1);
}
/**
* Getter for the name input value
*
* @returns The name value
*/
getValue(): string {
return this.#inputEl?.value ?? "";
}
/**
* Reset the name input value to an empty string
*/
resetValue() {
if (!this.#inputEl) return;
this.#inputEl.value = "";
}
}

View file

@ -5,9 +5,11 @@ import type { TimekeepSettings } from "@/settings";
import type { Store } from "@/store";
import type { Timekeep } from "@/timekeep/schema";
import { TimekeepAutocomplete } from "@/service/autocomplete";
import { getRunningEntry, startNewEntry } from "@/timekeep";
import { createObsidianIcon } from "./obsidianIcon";
import { TimesheetNameInput } from "./timesheetNameInput";
import { TimekeepStartEditing } from "./timesheetStartEditing";
import { TimesheetStartRunning } from "./timesheetStartRunning";
@ -24,6 +26,8 @@ export class TimesheetStart extends Component {
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Access to autocomplete */
autocomplete: TimekeepAutocomplete;
/** Wrapper for the component content */
#wrapperEl: HTMLElement | undefined;
@ -31,7 +35,8 @@ export class TimesheetStart extends Component {
#contentEl: HTMLElement | undefined;
/** Name input for starting entries */
#nameInputEl: HTMLInputElement | undefined;
#nameInput: TimesheetNameInput | undefined;
/** Warning message element */
#blockPauseWarningEl: HTMLElement | undefined;
/** Start button element */
@ -44,13 +49,16 @@ export class TimesheetStart extends Component {
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>
settings: Store<TimekeepSettings>,
autocomplete: TimekeepAutocomplete
) {
super();
this.app = app;
this.timekeep = timekeep;
this.settings = settings;
this.autocomplete = autocomplete;
this.#containerEl = containerEl;
}
@ -87,13 +95,9 @@ export class TimesheetStart extends Component {
blockPauseWarningEl.hidden = true;
const nameInputEl = nameWrapperEl.createEl("input", {
cls: "timekeep-name",
placeholder: "Example Block",
type: "text",
});
nameInputEl.id = "timekeepBlockName";
this.#nameInputEl = nameInputEl;
const nameInput = new TimesheetNameInput(nameWrapperEl, this.autocomplete);
this.#nameInput = nameInput;
this.addChild(nameInput);
const startButton = formEl.createEl("button", {
cls: "timekeep-start",
@ -212,17 +216,15 @@ export class TimesheetStart extends Component {
event.preventDefault();
event.stopPropagation();
const nameInputEl = this.#nameInputEl;
if (!nameInputEl) return;
const name = nameInputEl.value;
if (!this.#nameInput) return;
const name = this.#nameInput.getValue();
this.timekeep.setState((timekeep) => {
const currentTime = moment();
const entries = startNewEntry(name, currentTime, timekeep.entries);
// Reset name input
nameInputEl.value = "";
this.#nameInput?.resetValue();
return {
...timekeep,

View file

@ -23,8 +23,8 @@ import { load, replaceTimekeepCodeblock, extractTimekeepCodeblocks } from "@/tim
import { stopAllTimekeeps } from "./commands/stopAllTimekeeps";
import { stopFileTimekeeps } from "./commands/stopFileTimekeeps";
import { TimesheetStatusBarItem } from "./components/timesheetStatusBarItem";
import { CustomOutputFormat } from "./output";
import { TimekeepAutocomplete } from "./service/autocomplete";
import { TimekeepRegistry } from "./service/registry";
import { Timekeep, TimeEntry } from "./timekeep/schema";
import { TimekeepLocatorModal } from "./views/timekeep-locator-modal";
@ -54,6 +54,9 @@ export default class TimekeepPlugin extends Plugin {
/** Registry of all timekeeps within the vault */
registry: TimekeepRegistry | null = null;
/** Currently loaded status bar view if present */
#statusBarView: TimekeepStatusBarView | null = null;
constructor(app: ObsidianApp, manifest: PluginManifest) {
super(app, manifest);
@ -96,6 +99,16 @@ export default class TimekeepPlugin extends Plugin {
this.addSettingTab(new TimekeepSettingsTab(this.app, this));
const registry = new TimekeepRegistry(this.app.vault, this.settingsStore);
this.registry = registry;
const autocomplete = new TimekeepAutocomplete(this.registry, this.settingsStore);
this.addChild(autocomplete);
const onLoadStatusBar = this.onLoadStatusBar.bind(this);
this.settingsStore.subscribe(onLoadStatusBar);
onLoadStatusBar();
this.app.workspace.onLayoutReady(() => {
const settings = this.settingsStore.getState();
@ -103,15 +116,8 @@ export default class TimekeepPlugin extends Plugin {
return;
}
// Initialize the registry (After the layout is ready and the vault is loaded)
this.registry = new TimekeepRegistry(this.app.vault);
this.registry.concurrencyLimit = settings.registryConcurrencyLimit;
this.registry.onload();
if (settings.statusBarEnabled) {
const containerEl = this.addStatusBarItem();
new TimekeepStatusBarView(containerEl, this.app, this.registry, this.settingsStore);
}
// Initialize the registry (Only after the layout is ready and the vault is loaded)
this.addChild(registry);
});
this.registerMarkdownCodeBlockProcessor(
@ -125,6 +131,7 @@ export default class TimekeepPlugin extends Plugin {
this.app,
this.settingsStore,
this.customOutputFormats,
autocomplete,
context,
loadResult
)
@ -246,4 +253,23 @@ export default class TimekeepPlugin extends Plugin {
return newState;
});
}
private onLoadStatusBar() {
if (!this.registry) {
throw new Error("registry is not initialized");
}
if (this.#statusBarView) {
this.removeChild(this.#statusBarView);
this.#statusBarView = null;
}
const settings = this.settingsStore.getState();
if (!settings.statusBarEnabled) return;
const containerEl = this.addStatusBarItem();
const statusBarView = new TimekeepStatusBarView(containerEl, this.app, this.registry);
this.addChild(statusBarView);
this.#statusBarView = statusBarView;
}
}

View file

@ -0,0 +1,98 @@
import { Component } from "obsidian";
import { TimekeepSettings } from "@/settings";
import { createStore, Store } from "@/store";
import { getEntriesNames } from "@/timekeep/queries";
import { isNumberText } from "@/utils/number";
import { TimekeepRegistry } from "./registry";
/**
* Autocomplete registry to provide entry name autocomplete based
* on all the existing tasks within the vault
*/
export class TimekeepAutocomplete extends Component {
/** The registry of timekeeps */
registry: TimekeepRegistry;
/** Access to settings */
settings: Store<TimekeepSettings>;
/** The collection of timekeep names */
names: Store<string[]>;
constructor(registry: TimekeepRegistry, settings: Store<TimekeepSettings>) {
super();
this.registry = registry;
this.settings = settings;
this.names = createStore([]);
}
onload() {
super.onload();
const onUpdateRegistry = this.onUpdateRegistry.bind(this);
const unsubscribe = this.settings.subscribe(onUpdateRegistry);
onUpdateRegistry();
this.register(unsubscribe);
}
onUpdateRegistry() {
const settings = this.settings.getState();
if (!settings.autocompleteEnabled) {
this.names.setState([]);
return;
}
const onChangeEntries = this.onChangeEntries.bind(this);
const unsubscribe = this.registry.entries.subscribe(onChangeEntries);
onChangeEntries();
this.register(unsubscribe);
}
onChangeEntries() {
const entries = this.registry.entries.getState();
const namesSet = new Set<string>();
for (const entry of entries) {
for (const timekeep of entry.timekeeps) {
getEntriesNames(timekeep.timekeep.entries, namesSet);
}
}
const names = Array.from(namesSet)
//
.filter((name) => !TimekeepAutocomplete.isIgnoredName(name));
names.sort();
this.names.setState(names);
}
/**
* Checks if a name should be ignored from autocomplete
*
* @param name The name to check
* @returns Whether the name should be ignored
*/
static isIgnoredName(name: string) {
if (name.length < 1) {
return true;
}
// Ignore "Part 1" "Part 2", "Block 1" ...etc
if (name.startsWith("Part") || name.startsWith("Block")) {
const parts = name.split(" ");
if (parts.length !== 2) {
return false;
}
const numericPart = parts[1];
if (isNumberText(numericPart)) {
return true;
}
}
return false;
}
}

View file

@ -1,6 +1,7 @@
import { EventRef, TAbstractFile, TFile, Vault } from "obsidian";
import { Component, TAbstractFile, TFile, Vault } from "obsidian";
import { limitFunction } from "p-limit";
import { TimekeepSettings } from "@/settings";
import { createStore, Store } from "@/store";
import {
extractTimekeepCodeblocksWithPosition,
@ -15,30 +16,41 @@ export type TimekeepRegistryEntry = {
/**
* Obsidian vault registry of all timekeep instances
*/
export class TimekeepRegistry {
export class TimekeepRegistry extends Component {
/** Vault that the registry is operating on */
#vault: Vault;
/** Store for entries within the registry */
entries: Store<TimekeepRegistryEntry[]>;
/** Tracked events */
#events: EventRef[] = [];
/** Settings access */
settings: Store<TimekeepSettings>;
/** Concurrency limit for registry indexing */
concurrencyLimit: number = 10;
/** Whether the store is initialized */
initialized: boolean;
constructor(vault: Vault) {
constructor(vault: Vault, settings: Store<TimekeepSettings>) {
super();
this.#vault = vault;
this.entries = createStore([]);
this.settings = settings;
}
onload() {
const settings = this.settings.getState();
if (!settings.registryEnabled) {
return;
}
// Attach vault events
const createEvent = this.#vault.on("create", this.onFileCreated.bind(this));
const modifyEvent = this.#vault.on("modify", this.onFileModified.bind(this));
const deleteEvent = this.#vault.on("delete", this.onFileRemoved.bind(this));
this.#events.push(createEvent, modifyEvent, deleteEvent);
// Register events for unloading
this.registerEvent(createEvent);
this.registerEvent(modifyEvent);
this.registerEvent(deleteEvent);
// Load the registry from the vault
void this.loadFromVault()
@ -93,7 +105,6 @@ export class TimekeepRegistry {
this.entries.setState((entries) => {
const newEntries = entries.filter((entry) => {
console.log(file, entry.file, file === entry.file);
return entry.file !== file;
});
return newEntries;
@ -104,10 +115,11 @@ export class TimekeepRegistry {
* Load the registry from the current vault
*/
async loadFromVault() {
const settings = this.settings.getState();
const entries = await TimekeepRegistry.getTimekeepsWithinVault(
this.#vault,
true,
this.concurrencyLimit
settings.registryConcurrencyLimit
);
this.entries.setState(entries);
}

View file

@ -440,5 +440,26 @@ export class TimekeepSettingsTab extends PluginSettingTab {
}));
});
});
// Autocomplete section
new Setting(this.containerEl)
.setName("Autocomplete")
.setDesc(
"Timekeep can autocomplete entry names from existing timekeeps. This requires that the registry option above is enabled"
)
.setHeading();
new Setting(this.containerEl)
.setName("Enabled")
.setDesc("Whether to enable autocomplete.")
.addToggle((t) => {
t.setValue(settings.autocompleteEnabled);
t.onChange((v) => {
this.settingsStore.setState((currentValue) => ({
...currentValue,
autocompleteEnabled: v,
}));
});
});
}
}

View file

@ -74,6 +74,8 @@ export interface TimekeepSettings {
registryConcurrencyLimit: number;
statusBarEnabled: boolean;
autocompleteEnabled: boolean;
}
export const defaultSettings: TimekeepSettings = {
@ -102,6 +104,8 @@ export const defaultSettings: TimekeepSettings = {
registryConcurrencyLimit: 15,
statusBarEnabled: true,
autocompleteEnabled: true,
};
export function legacySettingsCompatibility(settings: TimekeepSettings): void {

View file

@ -21,6 +21,16 @@ type StoreState<T> = {
listeners: VoidFunction[];
};
export function derived<T, V>(store: Store<T>, derive: (value: T) => V): Store<V> {
const derivedStore = createStore(derive(store.getState()));
store.subscribe(() => {
const newValue = derive(store.getState());
derivedStore.setState(newValue);
});
return derivedStore;
}
/**
* Creates a new store
*

View file

@ -194,7 +194,7 @@
flex-flow: column;
flex: auto;
gap: 0.5rem;
overflow: hidden;
overflow: visible;
overflow-wrap: anywhere;
}
@ -299,3 +299,41 @@
gap: 0.25rem;
cursor: pointer;
}
.timekeep-name-containers {
display: flex;
position: relative;
}
.timekeep-suggestions {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
width: 100%;
max-height: 100px;
background-color: var(--color-base-20);
border: 1px solid var(--color-base-30);
overflow-y: scroll;
overflow-x: hidden;
scrollbar-gutter: stable;
padding: 0.5rem;
list-style: none;
}
.timekeep-suggestion {
height: calc((var(--font-ui-small) * var(--line-height-normal)) + 0.5rem);
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
white-space: nowrap;
padding: 0.25rem 0.5rem;
font-size: var(--font-ui-small);
}
.timekeep-suggestion[aria-selected="true"] {
background-color: var(--color-base-40);
}

View file

@ -0,0 +1,20 @@
import { TimeEntry } from "@/timekeep/schema";
export const input: TimeEntry[] = [
{
id: "9054dee3-8c15-493b-ad31-f070e08c2699",
name: "Part 1",
startTime: null,
endTime: null,
subEntries: null,
},
{
id: "9054dee3-8c15-493b-ad31-f070e08c2699",
name: "Part 2",
startTime: null,
endTime: null,
subEntries: null,
},
];
export const expected: string[] = ["Part 1", "Part 2"].sort();

View file

@ -0,0 +1,57 @@
import { TimeEntry } from "@/timekeep/schema";
export const input: TimeEntry[] = [
{
id: "9054dee3-8c15-493b-ad31-f070e08c2699",
name: "Part 1",
startTime: null,
endTime: null,
subEntries: null,
},
{
id: "d25ab2f5-00ac-4c89-9168-6062f7e01688",
name: "Part 2",
startTime: null,
endTime: null,
subEntries: [
{
id: "8fec44f0-9d5d-4b83-b960-63f73ec2d29d",
name: "Part 3",
startTime: null,
endTime: null,
subEntries: null,
},
{
id: "4a806a42-7463-4bbc-82e8-4ef193ed5bc6",
name: "Part 4",
startTime: null,
endTime: null,
subEntries: [
{
id: "609c792d-eebc-42bb-85a7-1614b5b0a792",
name: "Part 5",
startTime: null,
endTime: null,
subEntries: null,
},
{
id: "7c7955f5-4363-4cc1-93a5-9f654ffc8767",
name: "Part 6",
startTime: null,
endTime: null,
subEntries: null,
},
],
},
],
},
];
export const expected: string[] = [
"Part 1",
"Part 2",
"Part 3",
"Part 4",
"Part 5",
"Part 6",
].sort();

View file

@ -8,7 +8,9 @@ import {
getRunningEntry,
getEntryDuration,
getTotalDuration,
getEntriesNames,
} from "./queries";
import { TimeEntry } from "./schema";
describe("getEntryById", () => {
it("find top level entry", async () => {
@ -163,3 +165,41 @@ describe("getTotalDuration", () => {
expect(output).toBe(expected);
});
});
describe("getEntriesNames", () => {
it("empty list should return no names", () => {
const input: TimeEntry[] = [];
const expected: string[] = [];
const output = new Set<string>();
getEntriesNames(input, output);
// Sort output for consistent result
const outputSet = Array.from(output).sort();
expect(outputSet).toEqual(expected);
});
it("should return all names from a flat list", async () => {
const { input, expected } = await import("./__fixtures__/names/flatNames");
const output = new Set<string>();
getEntriesNames(input, output);
// Sort output for consistent result
const outputSet = Array.from(output).sort();
expect(outputSet).toEqual(expected);
});
it("should return all names including names from nested entries", async () => {
const { input, expected } = await import("./__fixtures__/names/nestedNames");
const output = new Set<string>();
getEntriesNames(input, output);
// Sort output for consistent result
const outputSet = Array.from(output).sort();
expect(outputSet).toEqual(expected);
});
});

View file

@ -193,3 +193,25 @@ export function getStartTime(entry: TimeEntry, newest: boolean): Moment | null {
return entry.startTime;
}
/**
* Collect the names of all entries within a collection
* of entries and any nested entries
*
* @param entries The entries to extract names from
* @param names The set to store collected names in
* @returns The list of names
*/
export function getEntriesNames(entries: TimeEntry[], names: Set<string>) {
const stack: TimeEntry[] = [...entries];
while (stack.length > 0) {
const entry: TimeEntry = stack.pop()!;
names.add(entry.name);
if (entry.subEntries !== null) {
stack.push(...entry.subEntries);
}
}
}

45
src/utils/number.test.ts Normal file
View file

@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { isNumberText } from "./number";
describe("isNumberText", () => {
it("empty string should not be treated as number text", () => {
const input = "";
const expected = false;
const output = isNumberText(input);
expect(output).toEqual(expected);
});
it("string with words should not be treated as number text", () => {
const input = "Test";
const expected = false;
const output = isNumberText(input);
expect(output).toEqual(expected);
});
it("string with words and numbers should not be treated as number text", () => {
const input = "Test 123";
const expected = false;
const output = isNumberText(input);
expect(output).toEqual(expected);
});
it("string starting with numbers and ending with words should not be treated as number text", () => {
const input = "123 Test";
const expected = false;
const output = isNumberText(input);
expect(output).toEqual(expected);
});
it("string only containing numbers should be treated as number text", () => {
const input = "123";
const expected = true;
const output = isNumberText(input);
expect(output).toEqual(expected);
});
});

10
src/utils/number.ts Normal file
View file

@ -0,0 +1,10 @@
/**
* Checks if the provided string value is made up of
* only numbers
*
* @param value The value to check
* @returns Whether the value is only numbers
*/
export function isNumberText(value: string): boolean {
return /^\d+$/.test(value);
}

View file

@ -5,6 +5,7 @@ import { TFile, TAbstractFile, MarkdownRenderChild, MarkdownPostProcessorContext
import { Timesheet } from "@/components/timesheet";
import { TimesheetLoadError } from "@/components/timesheetLoadError";
import { CustomOutputFormat } from "@/output";
import { TimekeepAutocomplete } from "@/service/autocomplete";
import { TimekeepSettings } from "@/settings";
import { Store, createStore } from "@/store";
import { LoadResult, replaceTimekeepCodeblock } from "@/timekeep/parser";
@ -23,6 +24,8 @@ export class TimekeepMarkdownView extends MarkdownRenderChild {
loadResult: LoadResult;
/** The rendered timesheet component */
timesheet: Timesheet | TimesheetLoadError | undefined;
/** Autocomplete */
autocomplete: TimekeepAutocomplete;
// Path to the file the timekeep is within
fileSourcePath: string;
@ -32,6 +35,7 @@ export class TimekeepMarkdownView extends MarkdownRenderChild {
app: ObsidianApp,
settingsStore: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
autocomplete: TimekeepAutocomplete,
context: MarkdownPostProcessorContext,
loadResult: LoadResult
) {
@ -39,6 +43,7 @@ export class TimekeepMarkdownView extends MarkdownRenderChild {
this.app = app;
this.settingsStore = settingsStore;
this.customOutputFormats = customOutputFormats;
this.autocomplete = autocomplete;
this.context = context;
this.loadResult = loadResult;
@ -84,12 +89,12 @@ export class TimekeepMarkdownView extends MarkdownRenderChild {
const timesheet = new Timesheet(
this.containerEl,
this.app,
timekeepStore,
saveErrorStore,
this.settingsStore,
this.customOutputFormats,
this.autocomplete,
handleSaveTimekeep
);

View file

@ -1,14 +1,12 @@
import { App, MarkdownView } from "obsidian";
import { App, Component, MarkdownView } from "obsidian";
import { TimesheetStatusBarItem } from "@/components/timesheetStatusBarItem";
import { TimekeepRegistry, TimekeepRegistryEntry } from "@/service/registry";
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { getRunningEntry } from "@/timekeep";
import { TimekeepWithPosition } from "@/timekeep/parser";
import { TimeEntry } from "@/timekeep/schema";
export class TimekeepStatusBarView {
export class TimekeepStatusBarView extends Component {
/** Parent container element */
#containerEl: HTMLElement;
@ -16,34 +14,36 @@ export class TimekeepStatusBarView {
app: App;
/** Access to the app registry */
registry: TimekeepRegistry;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Currently rendered items */
items: TimesheetStatusBarItem[] = [];
constructor(
containerEl: HTMLElement,
app: App,
registry: TimekeepRegistry,
settings: Store<TimekeepSettings>
) {
constructor(containerEl: HTMLElement, app: App, registry: TimekeepRegistry) {
super();
this.#containerEl = containerEl;
this.app = app;
this.registry = registry;
this.settings = settings;
// Render the initial state
this.render(this.registry.entries.getState());
// Subscribe to changes to re-render
this.registry.entries.subscribe(() => {
this.render(this.registry.entries.getState());
});
}
render(entries: TimekeepRegistryEntry[]) {
onload(): void {
super.onload();
const render = this.render.bind(this);
const unsubscribe = this.registry.entries.subscribe(render);
this.register(unsubscribe);
render();
}
onunload(): void {
super.onunload();
this.#containerEl?.remove();
}
render() {
const entries = this.registry.entries.getState();
// Unload the current children
for (const item of this.items) {
item.unload();

File diff suppressed because one or more lines are too long