diff --git a/src/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts index 7742258..231588a 100644 --- a/src/__mocks__/obsidian.ts +++ b/src/__mocks__/obsidian.ts @@ -24,7 +24,7 @@ export abstract class MockTAbstractFile { constructor(vault: Vault, path: string, parent: MockTFolder | null) { const nameIndex = path.lastIndexOf("/"); - const name = path.substring(nameIndex); + const name = path.substring(nameIndex + 1); this.vault = vault; this.path = path; @@ -44,6 +44,7 @@ export class MockTFolder extends MockTAbstractFile { ) { super(vault, path, parent); this.children = children; + this.path = path + "/"; } isRoot(): boolean { @@ -144,6 +145,13 @@ export class MockVault { } }); + create = vi.fn(async (path, data: string) => { + const file = new MockTFile(this.asVault(), path, data); + this._files[path] = file; + this._cache[file.path] = data; + this.emitEvent("create", file); + }); + cachedRead = vi.fn(async (file: TFile) => { if (!(file instanceof MockTFile)) { throw new Error("can only read files"); @@ -158,6 +166,16 @@ export class MockVault { addFile(path: string, value: string): TFile { const file = new MockTFile(this.asVault(), path, value); + + const nameIndex = path.lastIndexOf("/"); + const folderPath = path.substring(0, nameIndex); + + const folder = this._files[folderPath]; + + if (folder && folder instanceof MockTFolder) { + folder.children.push(file); + } + this._files[path] = file; this.emitEvent("create", file); diff --git a/src/commands/newTimekeepFile.ts b/src/commands/newTimekeepFile.ts new file mode 100644 index 0000000..2f5d6d1 --- /dev/null +++ b/src/commands/newTimekeepFile.ts @@ -0,0 +1,11 @@ +import { App, Command } from "obsidian"; + +import { createNewTimekeepFile } from "@/timekeep/createNewTimekeepFile"; + +export default function (app: App): Command { + return { + id: "new-timekeep-file", + name: `Create new timekeep file`, + callback: () => createNewTimekeepFile(app, app.vault.getRoot()), + }; +} diff --git a/src/main.ts b/src/main.ts index d0e9140..6c50e87 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import type { Moment } from "moment"; -import type { Vault, TFile, PluginManifest, App } from "obsidian"; +import type { Vault, TFile, PluginManifest, App, TAbstractFile, Menu } from "obsidian"; import { Plugin, TFolder } from "obsidian"; @@ -11,6 +11,7 @@ import createMerged from "@/commands/createMerged"; import exportMergedPdf from "@/commands/exportMergedPdf"; import findRunningTrackers from "@/commands/findRunningTrackers"; import insertTracker from "@/commands/insertTracker"; +import newTimekeepFile from "@/commands/newTimekeepFile"; import stopAllTimekeepsCommand from "@/commands/stopAllTimekeeps"; import stopFileTimekeepsCommand from "@/commands/stopFileTimekeeps"; import { TimesheetStatusBar } from "@/components/TimesheetStatusBar"; @@ -32,6 +33,8 @@ import { stopFileTimekeeps } from "@/timekeep/stopFileTimekeeps"; import TimekeepFileView from "@/views/TimekeepFileView"; import TimekeepMarkdownView from "@/views/TimekeepMarkdownView"; +import { createNewTimekeepFile } from "./timekeep/createNewTimekeepFile"; + export default class TimekeepPlugin extends Plugin { /** Store containing the plugin settings */ settingsStore: Store = createStore(defaultSettings); @@ -126,6 +129,7 @@ export default class TimekeepPlugin extends Plugin { this.addCommand(exportMergedPdf(this.app, this.registry, this.settingsStore)); this.addCommand(stopAllTimekeepsCommand(this.app)); this.addCommand(stopFileTimekeepsCommand(this.app)); + this.addCommand(newTimekeepFile(this.app)); // Custom timekeep file format this.registerView("timekeep", (leaf) => { @@ -139,36 +143,20 @@ export default class TimekeepPlugin extends Plugin { this.registerExtensions(["timekeep"], "timekeep"); - this.app.workspace.on("file-menu", (menu, parent) => { - if (!(parent instanceof TFolder)) return; + this.app.workspace.on("file-menu", this.onFileMenu.bind(this)); + } - const folder = parent; + private onFileMenu(menu: Menu, parent: TAbstractFile) { + if (!(parent instanceof TFolder)) return; - menu.addItem((item) => { - item.setTitle("New Timekeep") - .setIcon("clock") - .onClick(async () => { - const folderPath = folder.path; + const folder = parent; - const isNameTaken = (name: string) => - folder.children.find((child) => child.name === name) !== undefined; - - let name = "Untitled.timekeep"; - let index = 1; - - while (isNameTaken(name)) { - name = `Untitled ${index}.timekeep`; - index += 1; - } - - const filePath = `${folderPath}${name}`; - const file = await this.app.vault.create(filePath, ""); - - // Open the created file - const leaf = this.app.workspace.getLeaf(); - await leaf.openFile(file); - }); - }); + menu.addItem((item) => { + item.setTitle("New Timekeep") + .setIcon("clock") + .onClick(async () => { + await createNewTimekeepFile(this.app, folder); + }); }); } diff --git a/src/timekeep/createNewTimekeepFile.test.ts b/src/timekeep/createNewTimekeepFile.test.ts new file mode 100644 index 0000000..90c3664 --- /dev/null +++ b/src/timekeep/createNewTimekeepFile.test.ts @@ -0,0 +1,54 @@ +import { App } from "obsidian"; +import { describe, expect, it, vi } from "vitest"; + +import { MockVault } from "@/__mocks__/obsidian"; + +import { createNewTimekeepFile } from "./createNewTimekeepFile"; + +describe("createNewTimekeepFile", () => { + it("should use Untitled.timekeep when the file name is not in use", async () => { + const vault = new MockVault(); + const openFile = vi.fn(); + const app = { + vault, + workspace: { + getLeaf() { + return { openFile }; + }, + }, + } as any as App; + + const folder = vault.addFolder("test"); + await createNewTimekeepFile(app, folder); + + const files = vault.getFiles(); + const file = files.find((file) => file.name === "Untitled.timekeep"); + expect(file).toBeDefined(); + }); + + it("should increment the name counter until a unused name is found", async () => { + const vault = new MockVault(); + const openFile = vi.fn(); + const app = { + vault, + workspace: { + getLeaf() { + return { openFile }; + }, + }, + } as any as App; + + const folder = vault.addFolder("test"); + vault.addFile("test/Untitled.timekeep", ""); + + for (let i = 1; i <= 10; i++) { + vault.addFile(`test/Untitled ${i}.timekeep`, ""); + } + + await createNewTimekeepFile(app, folder); + + const files = vault.getFiles(); + const file = files.find((file) => file.name === "Untitled 11.timekeep"); + expect(file).toBeDefined(); + }); +}); diff --git a/src/timekeep/createNewTimekeepFile.ts b/src/timekeep/createNewTimekeepFile.ts new file mode 100644 index 0000000..31edd69 --- /dev/null +++ b/src/timekeep/createNewTimekeepFile.ts @@ -0,0 +1,29 @@ +import type { App, TFolder } from "obsidian"; + +/** + * Create a new timekeep file within the provided folder + * + * @param app App to use for the vault and workspace + * @param folder + */ +export async function createNewTimekeepFile(app: App, folder: TFolder) { + const folderPath = folder.path; + + const isNameTaken = (name: string) => + folder.children.find((child) => child.name === name) !== undefined; + + let name = "Untitled.timekeep"; + let index = 1; + + while (isNameTaken(name)) { + name = `Untitled ${index}.timekeep`; + index += 1; + } + + const filePath = `${folderPath}${name}`; + const file = await app.vault.create(filePath, ""); + + // Open the created file + const leaf = app.workspace.getLeaf(); + await leaf.openFile(file); +}