diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e6050ce --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: chhoumann +custom: https://www.buymeacoffee.com/chhoumann \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b01cd4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +# From https://github.com/st3v3nmw/obsidian-spaced-repetition/blob/master/.github/workflows/release.yml +name: Build obsidian plugin + +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 + +env: + PLUGIN_NAME: quickadd # plugin id + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: '14.x' # You might need to adjust this value to your own version + - name: Build + id: build + run: | + npm install + npm run build --if-present + mkdir ${{ env.PLUGIN_NAME }} + cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} + zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} + ls + echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ github.ref }} + with: + tag_name: ${{ github.ref }} + release_name: ${{ github.ref }} + draft: false + prerelease: false + - name: Upload zip file + id: upload-zip + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./${{ env.PLUGIN_NAME }}.zip + asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip + asset_content_type: application/zip + - name: Upload main.js + id: upload-main + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./main.js + asset_name: main.js + asset_content_type: text/javascript + - name: Upload manifest.json + id: upload-manifest + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./manifest.json + asset_name: manifest.json + asset_content_type: application/json + - name: Upload styles.css + id: upload-css + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./styles.css + asset_name: styles.css + asset_content_type: text/css \ No newline at end of file diff --git a/manifest.json b/manifest.json index 094a87f..1495830 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "quickadd", "name": "QuickAdd", - "version": "0.0.0", + "version": "0.1.0", "minAppVersion": "0.12.00", "description": "Quickly add new pages or content to your vault.", "author": "Christian B. B. Houmann", diff --git a/package.json b/package.json index a3247ac..389f572 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "quickadd", - "version": "0.0.0", + "version": "0.1.0", "description": "Quickly add new pages or content to your vault.", "main": "main.js", "scripts": { @@ -15,12 +15,16 @@ "@babel/core": "7.14.3", "@babel/preset-env": "7.14.2", "@babel/preset-typescript": "7.13.0", + "@fortawesome/free-regular-svg-icons": "5.15.3", + "@fortawesome/free-solid-svg-icons": "5.15.3", + "@popperjs/core": "^2.9.2", "@rollup/plugin-commonjs": "^18.0.0", "@rollup/plugin-node-resolve": "^11.2.1", "@rollup/plugin-typescript": "^8.2.1", "@tsconfig/svelte": "1.0.10", "@types/jest": "26.0.23", "@types/node": "14.17.1", + "@types/uuid": "8.3.0", "babel-core": "6.26.3", "babel-jest": "27.0.1", "jest": "27.0.1", @@ -30,13 +34,13 @@ "rollup-plugin-strip-code": "0.2.7", "rollup-plugin-svelte": "^7.1.0", "svelte": "^3.37.0", + "svelte-awesome": "2.3.2", "svelte-check": "^1.3.0", + "svelte-dnd-action": "0.9.8", "svelte-preprocess": "^4.7.0", "ts-jest": "27.0.0", "tslib": "^2.2.0", - "typescript": "^4.2.4" - }, - "dependencies": { - "@popperjs/core": "^2.9.2" + "typescript": "^4.2.4", + "uuid": "8.3.2" } } diff --git a/src/MacrosManager.ts b/src/MacrosManager.ts new file mode 100644 index 0000000..a293c7f --- /dev/null +++ b/src/MacrosManager.ts @@ -0,0 +1,146 @@ +import type {IMacro} from "./types/macros/IMacro"; +import {App, ButtonComponent, Modal, Setting, TextComponent, ToggleComponent} from "obsidian"; +import {MacroBuilder} from "./gui/MacroBuilder"; +import {QuickAddMacro} from "./types/macros/QuickAddMacro"; + +export class MacrosManager extends Modal { + public waitForClose: Promise; + private resolvePromise: (macros: IMacro[]) => void; + private rejectPromise: (reason?: any) => void; + private updateMacroContainer: () => void; + + constructor(public app: App, private macros: IMacro[]) { + super(app) + + this.waitForClose = new Promise( + ((resolve, reject) => { + this.rejectPromise = reject; + this.resolvePromise = resolve; + }) + ); + + this.open(); + this.display(); + } + + private display(): void { + this.contentEl.createEl('h2', {text: 'Macro Manager'}).style.textAlign = "center"; + this.addMacroSettings(); + this.addAddMacroBar(); + } + + private addMacroSettings() { + const macroContainer: HTMLDivElement = this.contentEl.createDiv(); + this.updateMacroContainer = () => { + if (this.macros.length <= 1) + macroContainer.className = "macroContainer macroContainer1"; + if (this.macros.length === 2) + macroContainer.className = "macroContainer macroContainer2"; + if (this.macros.length > 2) + macroContainer.className = "macroContainer macroContainer3"; + } + + this.macros.forEach(macro => this.addMacroSetting(macro, macroContainer)); + + this.updateMacroContainer(); + } + + private addMacroSetting(macro: IMacro, container: HTMLDivElement) { + const configureMacroContainer = container.createDiv(); + + const macroSetting: Setting = new Setting(configureMacroContainer); + macroSetting.setName(macro.name); + macroSetting.infoEl.style.fontWeight = "bold"; + + this.addMacroConfigurationItem(configureMacroContainer, itemContainerEl => { + this.addSpanWithText(itemContainerEl, "Run on plugin load"); + + const toggle: ToggleComponent = new ToggleComponent(itemContainerEl); + toggle.setValue(macro.runOnStartup); + + toggle.onChange(value => { + macro.runOnStartup = value; + + this.updateMacro(macro); + }); + }); + + configureMacroContainer.addClass("configureMacroDiv"); + this.addMacroConfigurationItem(configureMacroContainer, itemContainerEl => { + const deleteButton: ButtonComponent = new ButtonComponent(itemContainerEl); + deleteButton.setClass('mod-warning'); + deleteButton.buttonEl.style.marginRight = "0"; + + deleteButton.setButtonText("Delete").onClick(evt => { + this.macros = this.macros.filter(m => m.id !== macro.id); + this.reload(); + }); + + const configureButton: ButtonComponent = new ButtonComponent(itemContainerEl); + configureButton.setClass('mod-cta'); + configureButton.buttonEl.style.marginRight = "0"; + + configureButton.setButtonText("Configure").onClick(async evt => { + const newMacro = await new MacroBuilder(this.app, macro).waitForClose; + + if (newMacro) { + this.updateMacro(newMacro); + this.reload(); + } + }); + }); + + } + + private addMacroConfigurationItem(container: HTMLDivElement, callback: (itemContainerEl) => void, classString: string = "configureMacroDivItem") { + const item: HTMLDivElement = container.createDiv(); + item.addClass(classString); + + callback(item); + } + + private addSpanWithText(container: HTMLDivElement, text: string) { + const configureText: HTMLSpanElement = container.createEl('span'); + configureText.setText(text); + } + + private updateMacro(macro: IMacro) { + const index = this.macros.findIndex(v => v.id === macro.id); + this.macros[index] = macro; + + if (this.updateMacroContainer) + this.updateMacroContainer(); + + this.reload(); + } + + private reload(): void { + this.contentEl.empty(); + this.display(); + } + + private addAddMacroBar() { + const addMacroBarContainer: HTMLDivElement = this.contentEl.createDiv(); + addMacroBarContainer.addClass("addMacroBarContainer"); + + const nameInput: TextComponent = new TextComponent(addMacroBarContainer); + nameInput.setPlaceholder("Macro name"); + + const addMacroButton: ButtonComponent = new ButtonComponent(addMacroBarContainer); + addMacroButton.setButtonText("Add macro") + .setClass("mod-cta") + .onClick(() => { + const inputValue = nameInput.getValue(); + + if (inputValue !== "" && !this.macros.find(m => m.name === inputValue)) { + this.macros.push(new QuickAddMacro(inputValue)); + this.reload(); + } + }) + } + + onClose() { + super.onClose(); + this.resolvePromise(this.macros); + } +} \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..2861fae --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,19 @@ +export const FORMAT_SYNTAX: string[] = [ + "{{DATE}}", "{{DATE:}}", "{{VDATE:, }}", + "{{VALUE}}", "{{NAME}}", "{{VALUE:}}", "{{LINKCURRENT}}" +]; + +export const FILE_NAME_FORMAT_SYNTAX: string[] = [ + "{{DATE}}", "{{DATE:}}", "{{VDATE:, }}", + "{{VALUE}}", "{{NAME}}", "{{VALUE:}}", +] + +export const FILE_NUMBER_REGEX: RegExp = new RegExp(/([0-9]*)\.md$/); +export const DATE_REGEX: RegExp = new RegExp(/{{DATE(\+[0-9]*)?}}/); +export const DATE_REGEX_FORMATTED: RegExp = new RegExp(/{{DATE:([^}\n\r+]*)(\+[0-9]*)?}}/); +export const NAME_VALUE_REGEX: RegExp = new RegExp(/{{NAME}}|{{VALUE}}/); +export const VARIABLE_REGEX: RegExp = new RegExp(/{{VALUE:([^\n\r}]*)}}/); +export const DATE_VARIABLE_REGEX: RegExp = new RegExp(/{{VDATE:([^\n\r},]*),\s*([^\n\r},]*)}}/); +export const LINK_TO_CURRENT_FILE_REGEX: RegExp = new RegExp(/{{LINKCURRENT}}/); +export const MARKDOWN_FILE_EXTENSION_REGEX: RegExp = new RegExp(/\.md$/); +export const JAVASCRIPT_FILE_EXTENSION_REGEX: RegExp = new RegExp(/\.js$/); diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts new file mode 100644 index 0000000..b993768 --- /dev/null +++ b/src/engine/CaptureChoiceEngine.ts @@ -0,0 +1,63 @@ +import {QuickAddEngine} from "./QuickAddEngine"; +import type ICaptureChoice from "../types/choices/ICaptureChoice"; +import type {App, TFile} from "obsidian"; +import {log} from "../logger/logManager"; +import GenericInputPrompt from "../gui/GenericInputPrompt/genericInputPrompt"; +import {CaptureChoiceFormatter} from "../formatters/captureChoiceFormatter"; +import {appendToCurrentLine} from "../utility"; +import {MARKDOWN_FILE_EXTENSION_REGEX} from "../constants"; + +export class CaptureChoiceEngine extends QuickAddEngine { + choice: ICaptureChoice; + private formatter: CaptureChoiceFormatter; + + constructor(app: App, choice: ICaptureChoice) { + super(app); + this.choice = choice; + this.formatter = new CaptureChoiceFormatter(app); + } + + async run(): Promise { + const captureTo = this.choice.captureTo; + if (!captureTo) { + log.logError(`Invalid capture to for ${this.choice.name}`); + return; + } + + const filePath = await this.getFilePath(captureTo); + let content: string; + + if (!this.choice.format.enabled) + content = await GenericInputPrompt.Prompt(this.app, this.choice.name); + else + content = this.choice.format.format; + + if (this.choice.task) + content = `- [ ] ${content}`; + + if (await this.fileExists(filePath)) { + const file: TFile = await this.getFileByPath(filePath); + if (!file) return; + + const fileContent: string = await this.app.vault.read(file); + const newFileContent: string = await this.formatter.formatContent(content, this.choice, fileContent, file); + + await this.app.vault.modify(file, newFileContent); + } else { + const createdFile = await this.createFileWithInput(filePath, content); + if (!createdFile) { + log.logError(`could not create '${filePath}.'`); + return; + } + } + + if (this.choice.appendLink) + appendToCurrentLine(`[[${filePath.replace(MARKDOWN_FILE_EXTENSION_REGEX, '')}]]`, this.app); + } + + private async getFilePath(captureTo: string) { + const formattedCaptureTo: string = await this.formatter.formatFileName(captureTo, this.choice.name); + return this.formatFilePath("", formattedCaptureTo); + } + +} \ No newline at end of file diff --git a/src/engine/MacroChoiceEngine.ts b/src/engine/MacroChoiceEngine.ts new file mode 100644 index 0000000..c6de9df --- /dev/null +++ b/src/engine/MacroChoiceEngine.ts @@ -0,0 +1,66 @@ +import {QuickAddEngine} from "./QuickAddEngine"; +import type IMacroChoice from "../types/choices/IMacroChoice"; +import type {App, TAbstractFile} from "obsidian"; +import {TFile} from "obsidian"; +import type {IUserScript} from "../types/macros/IUserScript"; +import type {IObsidianCommand} from "../types/macros/IObsidianCommand"; +import {log} from "../logger/logManager"; +import {CommandType} from "../types/macros/CommandType"; +import {QuickAddApi} from "../quickAddApi"; +import type {ICommand} from "../types/macros/ICommand"; + +export class MacroChoiceEngine extends QuickAddEngine { + choice: IMacroChoice; + + constructor(app: App, choice: IMacroChoice) { + super(app); + this.choice = choice; + } + + async run(): Promise { + await this.executeCommands(this.choice.macro.commands); + } + + protected async executeCommands(commands: ICommand[]) { + for (const command of commands) { + if (command.type === CommandType.Obsidian) + await this.executeObsidianCommand(command as IObsidianCommand); + if (command.type === CommandType.UserScript) + await this.executeUserScript(command as IUserScript); + } + } + + // Slightly modified from Templater's user script engine: + // https://github.com/SilentVoid13/Templater/blob/master/src/UserTemplates/UserTemplateParser.ts + protected async executeUserScript(command: IUserScript) { + // @ts-ignore + const vaultPath = this.app.vault.adapter.getBasePath(); + const file: TAbstractFile = this.app.vault.getAbstractFileByPath(command.path); + if (!file) { + log.logError(`failed to load file ${command.path}.`); + return; + } + + if (file instanceof TFile) { + const filePath = `${vaultPath}/${file.path}`; + + if (window.require.cache[window.require.resolve(filePath)]) { + delete window.require.cache[window.require.resolve(filePath)]; + } + + // @ts-ignore + const userScript = await import(filePath); + if (!userScript.default || !(userScript.default instanceof Function)) { + log.logError(`failed to load user script ${filePath}.`); + return; + } + + await userScript.default({app: this.app, quickAddApi: QuickAddApi.GetApi(this.app)}); + } + } + + protected executeObsidianCommand(command: IObsidianCommand) { + // @ts-ignore + this.app.commands.executeCommandById(command.id); + } +} \ No newline at end of file diff --git a/src/engine/QuickAddEngine.ts b/src/engine/QuickAddEngine.ts new file mode 100644 index 0000000..737ccb0 --- /dev/null +++ b/src/engine/QuickAddEngine.ts @@ -0,0 +1,65 @@ +import type IChoice from "../types/choices/IChoice"; +import type {App} from "obsidian"; +import {MARKDOWN_FILE_EXTENSION_REGEX} from "../constants"; +import {TAbstractFile, TFile, TFolder} from "obsidian"; +import {log} from "../logger/logManager"; + +export abstract class QuickAddEngine { + abstract choice: IChoice; + public app: App; + + protected constructor(app: App) { + this.app = app; + } + + public abstract run(): void; + + protected async createFolder(folder: string): Promise { + const folderExists = await this.app.vault.adapter.exists(folder); + + if (!folderExists) { + await this.app.vault.createFolder(folder); + } + } + + protected formatFilePath(folderPath: string, fileName: string): string { + const actualFolderPath: string = folderPath ? `${folderPath}/` : ""; + const formattedFileName: string = fileName.replace(MARKDOWN_FILE_EXTENSION_REGEX, ''); + return `${actualFolderPath}${formattedFileName}.md`; + } + + protected async fileExists(filePath: string): Promise { + return await this.app.vault.adapter.exists(filePath); + } + + protected async getFileByPath(filePath: string): Promise { + const file: TAbstractFile = await this.app.vault.getAbstractFileByPath(filePath); + + if (!file) { + log.logError(`${filePath} not found`); + return null; + } + + if (file instanceof TFolder) { + log.logError(`${filePath} found but it's a folder`); + return null; + } + + if (file instanceof TFile) + return file; + } + + protected async createFileWithInput(filePath: string, fileContent: string): Promise { + const dirMatch = filePath.match(/(.*)[\/\\]/); + let dirName = ""; + if (dirMatch) dirName = dirMatch[1]; + + if (await this.app.vault.adapter.exists(dirName)) { + return await this.app.vault.create(filePath, fileContent); + } else { + await this.createFolder(dirName); + return await this.app.vault.create(filePath, fileContent) + } + } +} + diff --git a/src/engine/StartupMacroEngine.ts b/src/engine/StartupMacroEngine.ts new file mode 100644 index 0000000..b7b82c2 --- /dev/null +++ b/src/engine/StartupMacroEngine.ts @@ -0,0 +1,17 @@ +import type {App} from "obsidian"; +import type {IMacro} from "../types/macros/IMacro"; +import {MacroChoiceEngine} from "./MacroChoiceEngine"; + +export class StartupMacroEngine extends MacroChoiceEngine { + constructor(app: App, private macros: IMacro[]) { + super(app, null); + } + + async run(): Promise { + this.macros.forEach(macro => { + if (macro.runOnStartup) { + this.executeCommands(macro.commands); + } + }) + } +} \ No newline at end of file diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts new file mode 100644 index 0000000..ddf9484 --- /dev/null +++ b/src/engine/TemplateChoiceEngine.ts @@ -0,0 +1,126 @@ +import type ITemplateChoice from "../types/choices/ITemplateChoice"; +import {CompleteFormatter} from "../formatters/completeFormatter"; +import {App, TAbstractFile, TFile} from "obsidian"; +import {appendToCurrentLine, getTemplater} from "../utility"; +import {FILE_NUMBER_REGEX, MARKDOWN_FILE_EXTENSION_REGEX, NAME_VALUE_REGEX} from "../constants"; +import GenericInputPrompt from "../gui/GenericInputPrompt/genericInputPrompt"; +import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; +import {QuickAddEngine} from "./QuickAddEngine"; +import {log} from "../logger/logManager"; + +export class TemplateChoiceEngine extends QuickAddEngine { + public choice: ITemplateChoice; + private formatter: CompleteFormatter; + private readonly templater; + + constructor(app: App, choice: ITemplateChoice) { + super(app); + this.choice = choice; + this.templater = getTemplater(app); + this.formatter = new CompleteFormatter(app); + } + + public async run(): Promise { + try { + const folderPath = await this.getOrCreateFolder(); + let filePath = await this.getFilePath(folderPath); + + if (this.choice.incrementFileName) + filePath = await this.incrementFileName(filePath); + + const createdFile: TFile = await this.createFileWithTemplate(filePath); + if (!createdFile) { + log.logError(`Could not create file '${filePath}'.`); + return; + } + + if (this.choice.appendLink) { + const linkString = `[[${createdFile.path.replace(MARKDOWN_FILE_EXTENSION_REGEX, '')}]]`; + appendToCurrentLine(linkString, this.app); + } + + if (this.choice.openFile) { + if (!this.choice.openFileInNewTab.enabled) { + await this.app.workspace.activeLeaf.openFile(createdFile); + } else { + await this.app.workspace.splitActiveLeaf(this.choice.openFileInNewTab.direction) + .openFile(createdFile); + } + } + } + catch (e) { + log.logError(e.message); + } + } + + private async getFilePath(folderPath: string) { + const needName = !this.choice.fileNameFormat.enabled || !NAME_VALUE_REGEX.test(this.choice.fileNameFormat.format); + const name = needName ? await GenericInputPrompt.Prompt(this.app, this.choice.name) : ""; + if (needName && !name) throw new Error("No filename provided."); + + return this.choice.fileNameFormat.enabled ? + await this.getFormattedFilePath(folderPath) : + this.formatFilePath(folderPath, name); + } + + private async getOrCreateFolder(): Promise { + const folders: string[] = this.choice.folder.folders; + let folderPath: string; + + if (folders.length > 1) { + folderPath = await GenericSuggester.Suggest(this.app, folders, folders); + if (!folderPath) return null; + } else { + folderPath = folders[0]; + } + + if (folderPath) + await this.createFolder(folderPath); + else + folderPath = ""; + + return folderPath; + } + + private async getFormattedFilePath(folderPath: string): Promise { + const formattedName = await this.formatter.formatFileName(this.choice.fileNameFormat.format, this.choice.name); + return this.formatFilePath(folderPath, formattedName); + } + + private async incrementFileName(fileName: string) { + const numStr = FILE_NUMBER_REGEX.exec(fileName)[1]; + const fileExists = await this.app.vault.adapter.exists(fileName); + let newFileName = fileName; + + if (fileExists && numStr) { + const number = parseInt(numStr); + if (!number) throw new Error("detected numbers but couldn't get them.") + + newFileName = newFileName.replace(FILE_NUMBER_REGEX, `${number + 1}.md`); + } else if (fileExists) { + newFileName = newFileName.replace(FILE_NUMBER_REGEX, `${1}.md`); + } + + const newFileExists = await this.app.vault.adapter.exists(newFileName); + if (newFileExists) + newFileName = await this.incrementFileName(newFileName); + + return newFileName; + } + + private async createFileWithTemplate(filePath: string) { + const templateFile: TAbstractFile = this.app.vault.getAbstractFileByPath(this.choice.templatePath); + if (!(templateFile instanceof TFile)) return; + + const templateContent: string = await this.app.vault.cachedRead(templateFile); + const formattedTemplateContent: string = await this.formatter.formatFileContent(templateContent); + + const createdFile: TFile = await this.app.vault.create(filePath, formattedTemplateContent); + + if (this.templater && !this.templater.settings["trigger_on_file_creation"]) { + await this.templater.templater.overwrite_file_templates(createdFile); + } + + return createdFile; + } +} \ No newline at end of file diff --git a/src/formatters/captureChoiceFormatter.ts b/src/formatters/captureChoiceFormatter.ts new file mode 100644 index 0000000..a9c85ad --- /dev/null +++ b/src/formatters/captureChoiceFormatter.ts @@ -0,0 +1,70 @@ +import {CompleteFormatter} from "./completeFormatter"; +import type ICaptureChoice from "../types/choices/ICaptureChoice"; +import type {App, TFile} from "obsidian"; +import {log} from "../logger/logManager"; + +export class CaptureChoiceFormatter extends CompleteFormatter { + private choice: ICaptureChoice; + private file: TFile; + private fileContent: string; + + constructor(app: App) { + super(app); + } + + public async formatContent(input: string, choice: ICaptureChoice, fileContent: string, file: TFile): Promise { + this.choice = choice; + this.file = file; + this.fileContent = fileContent; + if (!choice || !file || fileContent === null) return input; + + return await this.formatFileContent(input); + } + + async formatFileContent(input: string): Promise { + const formatted = await super.formatFileContent(input); + + if (this.choice.prepend) + return `${this.fileContent}\n${input}` + + if (this.choice.insertAfter.enabled) { + const targetRegex = new RegExp(`\s*${this.choice.insertAfter.after}\s*`) + const targetPosition = this.fileContent.split("\n").findIndex(line => targetRegex.test(line)); + if (!targetPosition) { + log.logError(`unable to find insert after line in file.`); + return formatted; + } + + return this.insertTextAfterPositionInBody(formatted, this.fileContent, targetPosition); + } + + const frontmatterEndPosition = await this.getFrontmatterEndPosition(this.file); + if (!frontmatterEndPosition) + return `${formatted}${this.fileContent}`; + + return this.insertTextAfterPositionInBody(formatted, this.fileContent, frontmatterEndPosition); + } + + private async getFrontmatterEndPosition(file: TFile) { + const fileCache = await this.app.metadataCache.getFileCache(file); + + if (!fileCache || !fileCache.frontmatter) { + log.logMessage("could not get frontmatter. Maybe there isn't any.") + return 0; + } + + if (fileCache.frontmatter.position) + return fileCache.frontmatter.position.end.line; + + return 0; + } + + private insertTextAfterPositionInBody(text: string, body: string, pos: number): string { + const splitContent = body.split("\n"); + const pre = splitContent.slice(0, pos + 1).join("\n"); + const post = splitContent.slice(pos + 1).join("\n"); + + return `${pre}\n${text}${post}`; + } + +} \ No newline at end of file diff --git a/src/formatters/completeFormatter.ts b/src/formatters/completeFormatter.ts new file mode 100644 index 0000000..ec54839 --- /dev/null +++ b/src/formatters/completeFormatter.ts @@ -0,0 +1,72 @@ +import {Formatter} from "./formatter"; +import type {App, TFile} from "obsidian"; +import {MARKDOWN_FILE_EXTENSION_REGEX} from "../constants"; +import {getNaturalLanguageDates} from "../utility"; +import GenericInputPrompt from "../gui/GenericInputPrompt/genericInputPrompt"; +import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; +import {log} from "../logger/logManager"; + +export class CompleteFormatter extends Formatter { + private valueHeader: string; + + constructor(protected app: App) { + super(); + } + + protected async format(input: string): Promise { + try { + let output: string = input; + + output = this.replaceDateInString(output); + output = await this.replaceValueInString(output); + output = await this.replaceDateVariableInString(output); + output = await this.replaceVariableInString(output); + + return output; + } + catch (e) { + log.logError(e); + } + } + + async formatFileName(input: string, valueHeader: string): Promise { + this.valueHeader = valueHeader; + return await this.format(input); + } + + async formatFileContent(input: string): Promise { + let output: string = input; + + output = await this.format(output); + output = await this.replaceLinkToCurrentFileInString(output); + + return output; + } + + protected getCurrentFilePath() { + const currentFile: TFile = this.app.workspace.getActiveFile(); + if (!currentFile) return null; + + return currentFile.path.replace(MARKDOWN_FILE_EXTENSION_REGEX, ''); + } + + protected getNaturalLanguageDates() { + return getNaturalLanguageDates(this.app); + } + + protected getVariableValue(variableName: string): string { + return this.variables.get(variableName); + } + + protected async promptForValue(header?: string): Promise { + if (!this.value) + this.value = await GenericInputPrompt.Prompt(this.app, this.valueHeader ?? `Enter value`); + + return this.value; + } + + protected async suggestForValue(suggestedValues: string[]) { + return await GenericSuggester.Suggest(this.app, suggestedValues, suggestedValues); + } + +} \ No newline at end of file diff --git a/src/formatters/fileNameDisplayFormatter.ts b/src/formatters/fileNameDisplayFormatter.ts new file mode 100644 index 0000000..d44d07e --- /dev/null +++ b/src/formatters/fileNameDisplayFormatter.ts @@ -0,0 +1,39 @@ +import {Formatter} from "./formatter"; +import type {App} from "obsidian"; +import {getNaturalLanguageDates} from "../utility"; + +export class FileNameDisplayFormatter extends Formatter { + constructor(private app: App) { + super(); + } + + public async format(input: string): Promise { + let output: string = input; + + output = this.replaceDateInString(output); + output = await this.replaceValueInString(output); + output = await this.replaceDateVariableInString(output); + output = await this.replaceVariableInString(output); + + return `File Name: ${output}`; + } + protected promptForValue(header?: string): string { + return `FileName`; + } + + protected getVariableValue(variableName: string): string { + return variableName; + } + + protected getCurrentFilePath() { + return this.app.workspace.getActiveFile().path ?? ""; + } + + protected getNaturalLanguageDates() { + return getNaturalLanguageDates(this.app); + } + + protected suggestForValue(suggestedValues: string[]) { + return "_suggest_"; + } +} \ No newline at end of file diff --git a/src/formatters/formatDisplayFormatter.ts b/src/formatters/formatDisplayFormatter.ts new file mode 100644 index 0000000..e535cd4 --- /dev/null +++ b/src/formatters/formatDisplayFormatter.ts @@ -0,0 +1,40 @@ +import {Formatter} from "./formatter"; +import type {App} from "obsidian"; +import {getNaturalLanguageDates} from "../utility"; + +export class FormatDisplayFormatter extends Formatter { + constructor(private app: App) { + super(); + } + + public async format(input: string): Promise { + let output: string = input; + + output = this.replaceDateInString(output); + output = await this.replaceValueInString(output); + output = await this.replaceDateVariableInString(output); + output = await this.replaceVariableInString(output); + output = await this.replaceLinkToCurrentFileInString(output); + + return output; + } + protected promptForValue(header?: string): string { + return "_value_"; + } + + protected getVariableValue(variableName: string): string { + return variableName; + } + + protected getCurrentFilePath() { + return this.app.workspace.getActiveFile()?.path ?? "_noPageOpen_"; + } + + protected getNaturalLanguageDates() { + return getNaturalLanguageDates(this.app); + } + + protected suggestForValue(suggestedValues: string[]) { + return "_suggest_"; + } +} diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts new file mode 100644 index 0000000..21ef0d6 --- /dev/null +++ b/src/formatters/formatter.ts @@ -0,0 +1,128 @@ +import { + DATE_REGEX, DATE_REGEX_FORMATTED, + DATE_VARIABLE_REGEX, + LINK_TO_CURRENT_FILE_REGEX, + NAME_VALUE_REGEX, + VARIABLE_REGEX +} from "../constants"; +import {getDate} from "../utility"; + +export abstract class Formatter { + protected value: string; + protected variables: Map = new Map(); + + protected abstract format(input: string): Promise; + + protected replaceDateInString(input: string) { + let output: string = input; + + while (DATE_REGEX.test(output)) { + const dateMatch = DATE_REGEX.exec(output); + let offset: string = dateMatch[1] + if (offset) + offset = offset.replace('+', ''); + + output = output.replace(DATE_REGEX, getDate({offset: offset})); + } + + while (DATE_REGEX_FORMATTED.test(output)) { + const dateMatch = DATE_REGEX_FORMATTED.exec(output); + const format = dateMatch[1].replace('+', ''); + const offset = parseInt(dateMatch[2]); + + output = output.replace(DATE_REGEX_FORMATTED, getDate({format, offset})); + } + + return output; + } + + protected abstract promptForValue(header?: string): Promise | string; + + protected async replaceValueInString(input: string): Promise { + this.value = await this.promptForValue(); + let output: string = input; + + while (NAME_VALUE_REGEX.test(output)) { + output = output.replace(NAME_VALUE_REGEX, this.value); + } + + return output; + } + + protected async replaceLinkToCurrentFileInString(input) { + const currentFilePath = await this.getCurrentFilePath(); + if (!currentFilePath) return input; + + const currentFilePathLink = `[[${currentFilePath}]]`; + let output = input; + + while (LINK_TO_CURRENT_FILE_REGEX.test(output)) + output = output.replace(LINK_TO_CURRENT_FILE_REGEX, currentFilePathLink); + + return output; + } + + protected abstract getCurrentFilePath(); + + protected async replaceVariableInString(input: string) { + let output: string = input; + + while (VARIABLE_REGEX.test(output)) { + const match = VARIABLE_REGEX.exec(output); + const variableName = match[1]; + + if (variableName) { + if (!this.getVariableValue(variableName)) { + const suggestedValues = variableName.split(","); + + if (suggestedValues.length === 1) + this.variables.set(variableName, await this.promptForValue(variableName)); + else + this.variables.set(variableName, await this.suggestForValue(suggestedValues)); + } + + output = output.replace(VARIABLE_REGEX, this.getVariableValue(variableName)); + } else { + break; + } + } + + return output; + } + + protected abstract getVariableValue(variableName: string): string; + + protected abstract suggestForValue(suggestedValues: string[]); + + protected async replaceDateVariableInString(input: string) { + let output: string = input; + + while (DATE_VARIABLE_REGEX.test(output)) { + const match = DATE_VARIABLE_REGEX.exec(output); + const variableName = match[1]; + const dateFormat = match[2]; + + if (variableName && dateFormat) { + if (!this.variables[variableName]) { + this.variables[variableName] = await this.promptForValue(variableName); + + const parseAttempt = this.getNaturalLanguageDates().parseDate(this.variables[variableName]); + + if (parseAttempt) + this.variables[variableName] = window.moment().format(dateFormat); + else + throw new Error(`unable to parse date variable ${this.variables[variableName]}`); + } + + output = output.replace(DATE_VARIABLE_REGEX, this.variables[variableName]); + } else { + break; + } + } + + return output; + } + + protected abstract getNaturalLanguageDates(); + +} \ No newline at end of file diff --git a/src/gui/ChoiceBuilder/FolderList.svelte b/src/gui/ChoiceBuilder/FolderList.svelte new file mode 100644 index 0000000..22825b6 --- /dev/null +++ b/src/gui/ChoiceBuilder/FolderList.svelte @@ -0,0 +1,47 @@ + + +
+ {#each folders as folder, i} +
+ {folder} + deleteFolder(folder)} class="clickable"> + + +
+ {/each} +
+ + \ No newline at end of file diff --git a/src/gui/ChoiceBuilder/captureChoiceBuilder.ts b/src/gui/ChoiceBuilder/captureChoiceBuilder.ts new file mode 100644 index 0000000..294cd80 --- /dev/null +++ b/src/gui/ChoiceBuilder/captureChoiceBuilder.ts @@ -0,0 +1,117 @@ +import {ChoiceBuilder} from "./choiceBuilder"; +import type ICaptureChoice from "../../types/choices/ICaptureChoice"; +import type {App} from "obsidian"; +import {Setting, TextAreaComponent} from "obsidian"; +import {FormatSyntaxSuggester} from "../formatSyntaxSuggester"; +import {FORMAT_SYNTAX} from "../../constants"; +import {FormatDisplayFormatter} from "../../formatters/formatDisplayFormatter"; + +export class CaptureChoiceBuilder extends ChoiceBuilder { + choice: ICaptureChoice; + + constructor(app: App, choice: ICaptureChoice) { + super(app); + this.choice = choice; + + this.display(); + } + + protected display() { + this.addCenteredHeader(this.choice.name); + this.addCapturedToSetting(); + this.addPrependSetting(); + this.addTaskSetting(); + this.addAppendLinkSetting(); + this.addInsertAfterSetting(); + this.addFormatSetting(); + } + + private addCapturedToSetting() { + const captureToSetting: Setting = new Setting(this.contentEl) + .setName('Capture To') + .setDesc('File to capture to. Supports some format syntax.'); + + this.addFileSearchInputToSetting(captureToSetting, this.choice.captureTo, value => { + this.choice.captureTo = value; + }); + } + + private addPrependSetting() { + const prependSetting: Setting = new Setting(this.contentEl); + prependSetting.setName("Prepend") + .setDesc("Put value at the bottom of the file - otherwise at the top.") + .addToggle(toggle => { + toggle.setValue(this.choice.prepend); + toggle.onChange(value => this.choice.prepend = value); + }); + } + + private addTaskSetting() { + const taskSetting: Setting = new Setting(this.contentEl); + taskSetting.setName("Task") + .setDesc("Formats the value as a task.") + .addToggle(toggle => { + toggle.setValue(this.choice.task); + toggle.onChange(value => this.choice.task = value); + }); + } + + private addAppendLinkSetting() { + const appendLinkSetting: Setting = new Setting(this.contentEl); + appendLinkSetting.setName("Append link") + .setDesc("Append a link to the open file in the capture.") + .addToggle(toggle => { + toggle.setValue(this.choice.appendLink); + toggle.onChange(value => this.choice.appendLink = value); + }); + } + + private addInsertAfterSetting() { + const insertAfterSetting: Setting = new Setting(this.contentEl); + insertAfterSetting.setName("Insert after") + .setDesc("Insert capture after specified line.") + .addToggle(toggle => { + toggle.setValue(this.choice.insertAfter.enabled); + toggle.onChange(value => this.choice.insertAfter.enabled = value); + }) + .addText(textEl => { + textEl.setPlaceholder("Line text"); + textEl.inputEl.style.marginLeft = "10px"; + textEl.setValue(this.choice.insertAfter.after); + textEl.onChange(value => this.choice.insertAfter.after = value); + }); + } + + private addFormatSetting() { + let textField: TextAreaComponent; + const enableSetting = new Setting(this.contentEl); + enableSetting.setName("Capture format") + .setDesc("Set the format of the capture.") + .addToggle(toggleComponent => { + toggleComponent.setValue(this.choice.format.enabled) + .onChange(value => { + this.choice.format.enabled = value; + textField.setDisabled(!value); + }) + }); + + const formatInput = new TextAreaComponent(this.contentEl); + formatInput.setPlaceholder("Format"); + textField = formatInput; + formatInput.inputEl.style.width = "100%"; + formatInput.inputEl.style.marginBottom = "8px"; + formatInput.inputEl.style.height = "10rem"; + formatInput.setValue(this.choice.format.format) + .setDisabled(!this.choice.format.enabled) + .onChange(async value => { + this.choice.format.format = value; + formatDisplay.innerText = await displayFormatter.format(value); + }); + + new FormatSyntaxSuggester(this.app, textField.inputEl, FORMAT_SYNTAX); + + const formatDisplay: HTMLSpanElement = this.contentEl.createEl('span'); + const displayFormatter: FormatDisplayFormatter = new FormatDisplayFormatter(this.app); + (async () => formatDisplay.innerText = await displayFormatter.format(this.choice.format.format))(); + } +} \ No newline at end of file diff --git a/src/gui/ChoiceBuilder/choiceBuilder.ts b/src/gui/ChoiceBuilder/choiceBuilder.ts new file mode 100644 index 0000000..d4ae7f7 --- /dev/null +++ b/src/gui/ChoiceBuilder/choiceBuilder.ts @@ -0,0 +1,63 @@ +import {App, Modal, Setting} from "obsidian"; +import type IChoice from "../../types/choices/IChoice"; +import type {SvelteComponent} from "svelte"; +import {GenericTextSuggester} from "../genericTextSuggester"; + +export abstract class ChoiceBuilder extends Modal { + private resolvePromise: (input: IChoice) => void; + private rejectPromise: (reason?: any) => void; + private input: IChoice; + public waitForClose: Promise; + abstract choice: IChoice; + private didSubmit: boolean = false; + protected svelteElements: SvelteComponent[] = []; + + protected constructor(app: App) { + super(app); + + this.waitForClose = new Promise( + (resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + } + ); + + this.open(); + } + + protected abstract display(); + + protected reload() { + this.contentEl.empty(); + this.display(); + } + + protected addFileSearchInputToSetting(setting: Setting, value: string, onChangeCallback: (value: string) => void) { + setting.addSearch(searchComponent => { + searchComponent.setValue(value); + searchComponent.setPlaceholder("File path"); + + const markdownFiles: string[] = this.app.vault.getMarkdownFiles().map(f => f.path); + new GenericTextSuggester(this.app, searchComponent.inputEl, markdownFiles); + + searchComponent.onChange(onChangeCallback); + }); + } + + protected addCenteredHeader(header: string): void { + const headerEl = this.contentEl.createEl('h2'); + headerEl.style.textAlign = "center"; + headerEl.setText(header); + } + + onClose() { + super.onClose(); + this.resolvePromise(this.choice); + this.svelteElements.forEach(el => { + if (el && el.$destroy) el.$destroy(); + }) + + if(!this.didSubmit) this.rejectPromise("No answer given."); + else this.resolvePromise(this.input); + } +} \ No newline at end of file diff --git a/src/gui/ChoiceBuilder/macroChoiceBuilder.ts b/src/gui/ChoiceBuilder/macroChoiceBuilder.ts new file mode 100644 index 0000000..cdb53c3 --- /dev/null +++ b/src/gui/ChoiceBuilder/macroChoiceBuilder.ts @@ -0,0 +1,59 @@ +import {ChoiceBuilder} from "./choiceBuilder"; +import type IMacroChoice from "../../types/choices/IMacroChoice"; +import type {App} from "obsidian"; +import {Setting} from "obsidian"; +import {GenericTextSuggester} from "../genericTextSuggester"; +import type {IMacro} from "../../types/macros/IMacro"; + +export class MacroChoiceBuilder extends ChoiceBuilder { + choice: IMacroChoice; + private updateSelectedMacro: (macro: IMacro) => void; + + constructor(app: App, choice: IMacroChoice, private macros: IMacro[]) { + super(app); + this.choice = choice; + + this.display(); + } + + protected display() { + this.addCenteredHeader(this.choice.name); + this.addSelectedMacroElement(); + this.addSelectMacroSearch(); + } + + private addSelectedMacroElement() { + const selectedMacro = this.contentEl.createEl('h3'); + selectedMacro.style.textAlign = "center"; + + this.updateSelectedMacro = (macro => { + if (macro) + selectedMacro.textContent = `Selected macro: ${macro.name}`; + }); + + this.updateSelectedMacro(this.choice.macro); + } + + private addSelectMacroSearch() { + new Setting(this.contentEl) + .setName("Select macro") + .addSearch(searchComponent => { + searchComponent.setPlaceholder("Macro name"); + new GenericTextSuggester(this.app, searchComponent.inputEl, this.macros.map(m => m.name)); + + searchComponent.inputEl.addEventListener('keypress', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + const value: string = searchComponent.getValue(); + const macro = this.macros.find(m => m.name === value); + if (!macro) return; + + this.choice.macro = macro; + this.updateSelectedMacro(this.choice.macro); + + searchComponent.setValue(""); + } + }) + }) + } + +} \ No newline at end of file diff --git a/src/gui/ChoiceBuilder/templateChoiceBuilder.ts b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts new file mode 100644 index 0000000..4998103 --- /dev/null +++ b/src/gui/ChoiceBuilder/templateChoiceBuilder.ts @@ -0,0 +1,171 @@ +import {ChoiceBuilder} from "./choiceBuilder"; +import {App, Setting, TextComponent, TFolder} from "obsidian"; +import type ITemplateChoice from "../../types/choices/ITemplateChoice"; +import {FormatSyntaxSuggester} from "../formatSyntaxSuggester"; +import {FILE_NAME_FORMAT_SYNTAX} from "../../constants"; +import {NewTabDirection} from "../../types/newTabDirection"; +import FolderList from "./FolderList.svelte"; +import {FileNameDisplayFormatter} from "../../formatters/fileNameDisplayFormatter"; +import {ExclusiveSuggester} from "../exclusiveSuggester"; + +export class TemplateChoiceBuilder extends ChoiceBuilder { + choice: ITemplateChoice; + + constructor(app: App, choice: ITemplateChoice) { + super(app); + this.choice = choice; + + this.display(); + } + + protected display() { + this.addCenteredHeader(this.choice.name); + this.addTemplatePathSetting(); + this.addFileNameFormatSetting(); + this.addFolderSetting(); + this.addAppendLinkSetting(); + this.addIncrementFileNameSetting(); + this.addOpenFileSetting(); + if (this.choice.openFile) + this.addOpenFileInNewTabSetting(); + } + + private addTemplatePathSetting(): void { + const templatePathSetting = new Setting(this.contentEl) + .setName('Template Path') + .setDesc('Path to the Template.'); + + this.addFileSearchInputToSetting(templatePathSetting, this.choice.templatePath, value => { + this.choice.templatePath = value; + }); + } + + private addFileNameFormatSetting(): void { + let textField: TextComponent; + const enableSetting = new Setting(this.contentEl); + enableSetting.setName("File Name Format") + .setDesc("Set the file name format.") + .addToggle(toggleComponent => { + toggleComponent.setValue(this.choice.fileNameFormat.enabled) + .onChange(value => { + this.choice.fileNameFormat.enabled = value; + textField.setDisabled(!value); + }) + }); + + const formatDisplay: HTMLSpanElement = this.contentEl.createEl('span'); + const displayFormatter: FileNameDisplayFormatter = new FileNameDisplayFormatter(this.app); + (async () => formatDisplay.textContent = await displayFormatter.format(this.choice.fileNameFormat.format))(); + + const formatInput = new TextComponent(this.contentEl); + formatInput.setPlaceholder("File name format"); + textField = formatInput; + formatInput.inputEl.style.width = "100%"; + formatInput.inputEl.style.marginBottom = "8px"; + formatInput.setValue(this.choice.fileNameFormat.format) + .setDisabled(!this.choice.fileNameFormat.enabled) + .onChange(async value => { + this.choice.fileNameFormat.format = value; + formatDisplay.textContent = await displayFormatter.format(value); + }); + + new FormatSyntaxSuggester(this.app, textField.inputEl, FILE_NAME_FORMAT_SYNTAX); + } + + private addFolderSetting(): void { + const folderSetting: Setting = new Setting(this.contentEl); + folderSetting.setName("Create in folder") + .setDesc("Create the file in the specified folder. If multiple folders are specified, you will be prompted for which folder to create the file in.") + .addToggle(toggle => { + toggle.setValue(this.choice.folder.enabled); + toggle.onChange(value => this.choice.folder.enabled = value); + }); + + const folderList: HTMLDivElement = this.contentEl.createDiv('folderList'); + + const folderListEl = new FolderList({ + target: folderList, + props: { + folders: this.choice.folder.folders, + deleteCommand: (folder: string) => { + this.choice.folder.folders = this.choice.folder.folders.filter(f => f !== folder); + folderListEl.updateFolders(this.choice.folder.folders); + suggester.updateCurrentItems(this.choice.folder.folders); + } + } + }); + + this.svelteElements.push(folderListEl); + + const folderInput = new TextComponent(this.contentEl); + folderInput.setPlaceholder("Folder path"); + folderInput.inputEl.style.width = "100%"; + folderInput.inputEl.style.marginBottom = "8px"; + const folders: string[] = this.app.vault.getAllLoadedFiles() + .filter(f => f instanceof TFolder) + .map(folder => folder.path); + + const suggester = new ExclusiveSuggester(this.app, folderInput.inputEl, folders, this.choice.folder.folders); + + folderInput.inputEl.addEventListener('keypress', (e: KeyboardEvent) => { + const input = folderInput.inputEl.value.trim(); + if (e.key === 'Enter' && !this.choice.folder.folders.some(folder => folder === input)) { + this.choice.folder.folders.push(input); + folderListEl.updateFolders(this.choice.folder.folders); + folderInput.inputEl.value = ""; + + suggester.updateCurrentItems(this.choice.folder.folders); + } + }) + } + + private addAppendLinkSetting(): void { + const appendLinkSetting: Setting = new Setting(this.contentEl); + appendLinkSetting.setName("Append link") + .setDesc("Append link to created file to current file.") + .addToggle(toggle => { + toggle.setValue(this.choice.appendLink); + toggle.onChange(value => this.choice.appendLink = value); + }) + } + + private addIncrementFileNameSetting(): void { + const incrementFileNameSetting: Setting = new Setting(this.contentEl); + incrementFileNameSetting.setName("Increment file name") + .setDesc("If the file already exists, increment the file name.") + .addToggle(toggle => { + toggle.setValue(this.choice.incrementFileName); + toggle.onChange(value => this.choice.incrementFileName = value); + }) + } + + private addOpenFileSetting(): void { + const noOpenSetting: Setting = new Setting(this.contentEl); + noOpenSetting.setName("Open") + .setDesc("Open the created file.") + .addToggle(toggle => { + toggle.setValue(this.choice.openFile); + toggle.onChange(value => { + this.choice.openFile = value; + this.reload(); + }); + }) + } + + private addOpenFileInNewTabSetting(): void { + const newTabSetting = new Setting(this.contentEl); + newTabSetting.setName("New Tab") + .setDesc("Open created file in a new tab.") + .addToggle(toggle => { + toggle.setValue(this.choice.openFileInNewTab.enabled); + toggle.onChange(value => this.choice.openFileInNewTab.enabled = value); + }) + .addDropdown(dropdown => { + dropdown.selectEl.style.marginLeft = "10px"; + dropdown.addOption(NewTabDirection.vertical, "Vertical"); + dropdown.addOption(NewTabDirection.horizontal, "Horizontal"); + dropdown.setValue(this.choice.openFileInNewTab.direction); + dropdown.onChange(value => this.choice.openFileInNewTab.direction = value); + }); + } +} \ No newline at end of file diff --git a/src/gui/CommandList.svelte b/src/gui/CommandList.svelte new file mode 100644 index 0000000..8aed71d --- /dev/null +++ b/src/gui/CommandList.svelte @@ -0,0 +1,39 @@ + + +
+ {#each commands as command, i} +
+ {i+1}. {command.name} + deleteCommand(command)} class="clickable"> + + +
+ {/each} +
+ + diff --git a/src/gui/GenericInputPrompt/GenericInputPromptContent.svelte b/src/gui/GenericInputPrompt/GenericInputPromptContent.svelte new file mode 100644 index 0000000..fbb2d39 --- /dev/null +++ b/src/gui/GenericInputPrompt/GenericInputPromptContent.svelte @@ -0,0 +1,28 @@ + + +
+

{header}

+ +
\ No newline at end of file diff --git a/src/gui/GenericInputPrompt/genericInputPrompt.ts b/src/gui/GenericInputPrompt/genericInputPrompt.ts new file mode 100644 index 0000000..507f6c0 --- /dev/null +++ b/src/gui/GenericInputPrompt/genericInputPrompt.ts @@ -0,0 +1,61 @@ +import {App, Modal} from "obsidian"; +import GenericInputPromptContent from "./GenericInputPromptContent.svelte" + +export default class GenericInputPrompt extends Modal { + private modalContent: GenericInputPromptContent; + private resolvePromise: (input: string) => void; + private input: string; + public waitForClose: Promise; + private rejectPromise: (reason?: any) => void; + private didSubmit: boolean = false; + + public static Prompt(app: App, header: string, placeholder?: string, value?: string): Promise { + const newPromptModal = new GenericInputPrompt(app, header, placeholder, value); + return newPromptModal.waitForClose; + } + + private constructor(app: App, header: string, placeholder?: string, value?: string) { + super(app); + + this.modalContent = new GenericInputPromptContent({ + target: this.contentEl, + props: { + app, + header, + placeholder, + value, + onSubmit: (input: string) => { + this.input = input; + this.didSubmit = true; + this.close(); + } + } + }); + + this.waitForClose = new Promise( + (resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + } + ); + + this.open(); + } + + onOpen() { + super.onOpen(); + + const modalPrompt: HTMLElement = document.querySelector('.quickAddPrompt'); + const modalInput: any = modalPrompt.querySelector('.quickAddPromptInput'); + modalInput.focus(); + modalInput.select(); + } + + onClose() { + super.onClose(); + this.modalContent.$destroy(); + + if(!this.didSubmit) this.rejectPromise("No input given."); + else this.resolvePromise(this.input); + } +} \ No newline at end of file diff --git a/src/gui/GenericSuggester/genericSuggester.ts b/src/gui/GenericSuggester/genericSuggester.ts new file mode 100644 index 0000000..c052ba2 --- /dev/null +++ b/src/gui/GenericSuggester/genericSuggester.ts @@ -0,0 +1,34 @@ +import {App, FuzzySuggestModal} from "obsidian"; + +export default class GenericSuggester extends FuzzySuggestModal{ + private resolvePromise: (value: string) => void; + private promise: Promise; + + public static Suggest(app: App, displayItems: string[], items: string[]) { + const newSuggester = new GenericSuggester(app, displayItems, items); + return newSuggester.promise; + } + + private constructor(app: App, private displayItems: string[], private items: string[]) { + super(app); + + this.promise = new Promise( + (resolve) => (this.resolvePromise = resolve) + ); + + this.open(); + } + + getItemText(item: string): string { + return this.displayItems[this.items.indexOf(item)]; + } + + getItems(): string[] { + return this.items; + } + + onChooseItem(item: string, evt: MouseEvent | KeyboardEvent): void { + this.resolvePromise(item); + } + +} \ No newline at end of file diff --git a/src/gui/GenericYesNoPrompt/GenericYesNoPrompt.ts b/src/gui/GenericYesNoPrompt/GenericYesNoPrompt.ts new file mode 100644 index 0000000..74d11ec --- /dev/null +++ b/src/gui/GenericYesNoPrompt/GenericYesNoPrompt.ts @@ -0,0 +1,51 @@ +import {App, Modal} from "obsidian"; +import GenericYesNoPromptContent from "./GenericYesNoPromptContent.svelte" + +export default class GenericYesNoPrompt extends Modal { + private modalContent: GenericYesNoPromptContent ; + private resolvePromise: (input: boolean) => void; + private rejectPromise: (reason?: any) => void; + private input: boolean; + public waitForClose: Promise; + private didSubmit: boolean = false; + + public static Prompt(app: App, header: string, text?: string): Promise { + const newPromptModal = new GenericYesNoPrompt(app, header, text); + return newPromptModal.waitForClose; + } + + private constructor(app: App, header: string, text?: string) { + super(app); + + this.modalContent = new GenericYesNoPromptContent({ + target: this.contentEl, + props: { + app, + header, + text, + onSubmit: (input: boolean) => { + this.input = input; + this.didSubmit = true; + this.close(); + } + } + }); + + this.waitForClose = new Promise( + (resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + } + ); + + this.open(); + } + + onClose() { + super.onClose(); + this.modalContent.$destroy(); + + if(!this.didSubmit) this.rejectPromise("No answer given."); + else this.resolvePromise(this.input); + } +} \ No newline at end of file diff --git a/src/gui/GenericYesNoPrompt/GenericYesNoPromptContent.svelte b/src/gui/GenericYesNoPrompt/GenericYesNoPromptContent.svelte new file mode 100644 index 0000000..84f6813 --- /dev/null +++ b/src/gui/GenericYesNoPrompt/GenericYesNoPromptContent.svelte @@ -0,0 +1,39 @@ + + +
+

{header}

+ +

{text}

+ +
+ + +
+
+ + \ No newline at end of file diff --git a/src/gui/MacroBuilder.ts b/src/gui/MacroBuilder.ts new file mode 100644 index 0000000..268f35d --- /dev/null +++ b/src/gui/MacroBuilder.ts @@ -0,0 +1,133 @@ +import type {IMacro} from "../types/macros/IMacro"; +import {App, Modal, Setting, TFile} from "obsidian"; +import type {IObsidianCommand} from "../types/macros/IObsidianCommand"; +import {GenericTextSuggester} from "./genericTextSuggester"; +import {UserScript} from "../types/macros/UserScript"; +import {ObsidianCommand} from "../types/macros/ObsidianCommand"; +import {JAVASCRIPT_FILE_EXTENSION_REGEX} from "../constants"; +import type {ICommand} from "../types/macros/ICommand"; +import type {SvelteComponent} from "svelte"; +import CommandList from "./CommandList.svelte" + +export class MacroBuilder extends Modal { + public macro: IMacro; + public waitForClose: Promise; + private commands: IObsidianCommand[] = []; + private javascriptFiles: TFile[] = []; + private commandListEl: CommandList; + private svelteElements: SvelteComponent[]; + private resolvePromise: (macro: IMacro) => void; + + constructor(app: App, macro: IMacro) { + super(app); + this.macro = macro; + this.svelteElements = []; + + this.waitForClose = new Promise(resolve => (this.resolvePromise = resolve)); + + this.getObsidianCommands(); + this.getJavascriptFiles(); + + this.display(); + this.open(); + } + + protected display() { + this.addCenteredHeader(this.macro.name); + this.addCommandList(); + this.addAddObsidianCommandSetting(); + this.addAddUserScriptSetting(); + } + + protected addCenteredHeader(header: string): void { + const headerEl = this.contentEl.createEl('h2'); + headerEl.style.textAlign = "center"; + headerEl.setText(header); + } + + + private addAddObsidianCommandSetting() { + new Setting(this.contentEl) + .setName("Obsidian command") + .setDesc("Add an Obsidian command") + .addSearch(searchComponent => { + searchComponent.setPlaceholder("Obsidian command"); + new GenericTextSuggester(this.app, searchComponent.inputEl, this.commands.map(c => c.name)); + + searchComponent.inputEl.addEventListener('keypress', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + const value: string = searchComponent.getValue(); + const command: IObsidianCommand = this.commands.find(v => v.name === value); + + this.macro.commands.push(command); + this.commandListEl.updateCommandList(this.macro.commands); + + searchComponent.setValue(""); + } + }); + }); + } + + private addAddUserScriptSetting() { + new Setting(this.contentEl) + .setName("User Scripts") + .setDesc("Add user script") + .addSearch(searchComponent => { + searchComponent.setPlaceholder("User script"); + new GenericTextSuggester(this.app, searchComponent.inputEl, this.javascriptFiles.map(f => f.basename)); + + searchComponent.inputEl.addEventListener('keypress', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + const value: string = searchComponent.getValue(); + const file = this.javascriptFiles.find(f => f.basename === value); + if (!file) return; + + this.macro.commands.push(new UserScript(value, file.path)); + this.commandListEl.updateCommandList(this.macro.commands); + + searchComponent.setValue(""); + } + }) + }) + } + + private getObsidianCommands(): void { + // @ts-ignore + Object.keys(this.app.commands.commands).forEach(key => { + // @ts-ignore + const command = this.app.commands.commands[key]; + + this.commands.push(new ObsidianCommand(command.name, command.id)); + }) + } + + private getJavascriptFiles(): void { + this.javascriptFiles = this.app.vault.getFiles() + .filter(file => JAVASCRIPT_FILE_EXTENSION_REGEX.test(file.path)); + } + + private addCommandList() { + const commandList = this.contentEl.createDiv('commandList'); + + this.commandListEl = new CommandList({ + target: commandList, + props: { + commands: this.macro.commands, + deleteCommand: (command: ICommand) => { + this.macro.commands = this.macro.commands.filter(c => c.id !== command.id); + this.commandListEl.updateCommandList(this.macro.commands); + } + } + }); + + this.svelteElements.push(this.commandListEl); + } + + onClose() { + super.onClose(); + this.resolvePromise(this.macro); + this.svelteElements.forEach(el => { + if (el && el.$destroy) el.$destroy(); + }) + } +} diff --git a/src/gui/choiceList/AddChoiceBox.svelte b/src/gui/choiceList/AddChoiceBox.svelte new file mode 100644 index 0000000..85529ad --- /dev/null +++ b/src/gui/choiceList/AddChoiceBox.svelte @@ -0,0 +1,48 @@ + + +
+ + + +
+ + \ No newline at end of file diff --git a/src/gui/choiceList/ChoiceItemRightButtons.svelte b/src/gui/choiceList/ChoiceItemRightButtons.svelte new file mode 100644 index 0000000..058f14f --- /dev/null +++ b/src/gui/choiceList/ChoiceItemRightButtons.svelte @@ -0,0 +1,56 @@ + + +
+ {#if showConfigureButton} +
+ +
+ {/if} + +
+ +
+ +
+ +
+
+ + \ No newline at end of file diff --git a/src/gui/choiceList/ChoiceList.svelte b/src/gui/choiceList/ChoiceList.svelte new file mode 100644 index 0000000..5ec878c --- /dev/null +++ b/src/gui/choiceList/ChoiceList.svelte @@ -0,0 +1,82 @@ + + +
+ {#each choices.filter(c => c.id !== SHADOW_PLACEHOLDER_ITEM_ID) as choice(choice.id)} + {#if choice.type !== ChoiceType.Multi} + + {:else} + + {/if} + {/each} +
+ + \ No newline at end of file diff --git a/src/gui/choiceList/ChoiceListItem.svelte b/src/gui/choiceList/ChoiceListItem.svelte new file mode 100644 index 0000000..fb7115d --- /dev/null +++ b/src/gui/choiceList/ChoiceListItem.svelte @@ -0,0 +1,45 @@ + + +
+ {choice.name} + + +
+ + diff --git a/src/gui/choiceList/ChoiceView.svelte b/src/gui/choiceList/ChoiceView.svelte new file mode 100644 index 0000000..da9dec6 --- /dev/null +++ b/src/gui/choiceList/ChoiceView.svelte @@ -0,0 +1,140 @@ + + +
+ saveChoices(e.detail.choices)} + /> +
+ + +
+
+ + \ No newline at end of file diff --git a/src/gui/choiceList/MultiChoiceListItem.svelte b/src/gui/choiceList/MultiChoiceListItem.svelte new file mode 100644 index 0000000..de7bb5b --- /dev/null +++ b/src/gui/choiceList/MultiChoiceListItem.svelte @@ -0,0 +1,76 @@ + + +
+
+
choice.collapsed = !choice.collapsed}> + + {choice.name} +
+ + +
+ + {#if !collapseId || (collapseId && choice.id !== collapseId)} + {#if !choice.collapsed} +
+ +
+ {/if} + {/if} +
+ + diff --git a/src/gui/choiceSuggester.ts b/src/gui/choiceSuggester.ts new file mode 100644 index 0000000..988adf4 --- /dev/null +++ b/src/gui/choiceSuggester.ts @@ -0,0 +1,87 @@ +import {FuzzySuggestModal} from "obsidian"; +import type IChoice from "../types/choices/IChoice"; +import type QuickAdd from "../main"; +import {ChoiceType} from "../types/choices/choiceType"; +import type IMultiChoice from "../types/choices/IMultiChoice"; +import {MultiChoice} from "../types/choices/MultiChoice"; +import type ITemplateChoice from "../types/choices/ITemplateChoice"; +import type ICaptureChoice from "../types/choices/ICaptureChoice"; +import type IMacroChoice from "../types/choices/IMacroChoice"; +import {TemplateChoiceEngine} from "../engine/TemplateChoiceEngine"; +import {log} from "../logger/logManager"; +import {CaptureChoiceEngine} from "../engine/CaptureChoiceEngine"; +import {MacroChoiceEngine} from "../engine/MacroChoiceEngine"; + +export default class ChoiceSuggester extends FuzzySuggestModal { + public static Open(plugin: QuickAdd, choices: IChoice[]) { + new ChoiceSuggester(plugin, choices).open(); + } + + constructor(private plugin: QuickAdd, private choices: IChoice[]) { + super(plugin.app); + } + + getItemText(item: IChoice): string { + return item.name; + } + + getItems(): IChoice[] { + return this.choices; + } + + async onChooseItem(item: IChoice, evt: MouseEvent | KeyboardEvent): Promise { + switch (item.type) { + case ChoiceType.Multi: + const multiChoice: IMultiChoice = item as IMultiChoice; + this.onChooseMultiType(multiChoice); + break; + case ChoiceType.Template: + const templateChoice: ITemplateChoice = item as ITemplateChoice; + await this.onChooseTemplateType(templateChoice); + break; + case ChoiceType.Capture: + const captureChoice: ICaptureChoice = item as ICaptureChoice; + await this.onChooseCaptureType(captureChoice); + break; + case ChoiceType.Macro: + const macroChoice: IMacroChoice = item as IMacroChoice; + await this.onChooseMacroType(macroChoice); + break; + default: + break; + } + } + + private onChooseMultiType(multi: IMultiChoice) { + const choices = [...multi.choices]; + + if (multi.name != "← Back") + choices.push(new MultiChoice("← Back").addChoices(this.choices)) + + ChoiceSuggester.Open(this.plugin, choices); + } + + private async onChooseTemplateType(templateChoice: ITemplateChoice): Promise { + if (!templateChoice.templatePath) { + log.logError(`please provide a template path for ${templateChoice.name}`); + return; + } + + await new TemplateChoiceEngine(this.app, templateChoice).run(); + } + + private async onChooseCaptureType(captureChoice: ICaptureChoice) { + if (!captureChoice.captureTo) { + log.logError(`please provide a template path for ${captureChoice.name}`); + return; + } + + await new CaptureChoiceEngine(this.app, captureChoice).run(); + } + + private async onChooseMacroType(macroChoice: IMacroChoice) { + if (macroChoice.macro.commands.length === 0) return; + + await new MacroChoiceEngine(this.app, macroChoice).run(); + } +} \ No newline at end of file diff --git a/src/gui/exclusiveSuggester.ts b/src/gui/exclusiveSuggester.ts new file mode 100644 index 0000000..0cddcb0 --- /dev/null +++ b/src/gui/exclusiveSuggester.ts @@ -0,0 +1,32 @@ +import {TextInputSuggest} from "./suggest"; +import type {App} from "obsidian"; + +export class ExclusiveSuggester extends TextInputSuggest { + constructor( + public app: App, + public inputEl: HTMLInputElement | HTMLTextAreaElement, + private suggestItems: string[], + private currentItems: string[] + ) { + super(app, inputEl); + } + + updateCurrentItems(currentItems: string[]) { + this.currentItems = currentItems; + } + + getSuggestions(inputStr: string): string[] { + return this.suggestItems.filter(item => !this.currentItems.contains(item)); + } + + selectSuggestion(item: string): void { + this.inputEl.value = item; + this.inputEl.trigger("input"); + this.close(); + } + + renderSuggestion(value: string, el: HTMLElement): void { + if (value) + el.setText(value); + } +} diff --git a/src/gui/formatSyntaxSuggester.ts b/src/gui/formatSyntaxSuggester.ts new file mode 100644 index 0000000..197c552 --- /dev/null +++ b/src/gui/formatSyntaxSuggester.ts @@ -0,0 +1,23 @@ +import {TextInputSuggest} from "./suggest"; +import type {App} from "obsidian"; + +export class FormatSyntaxSuggester extends TextInputSuggest { + constructor(public app: App, public inputEl: HTMLInputElement | HTMLTextAreaElement, private items: string[]) { + super(app, inputEl); + } + + getSuggestions(inputStr: string): string[] { + return this.items; + } + + selectSuggestion(item: string): void { + this.inputEl.value += item; + this.inputEl.trigger("input"); + this.close(); + } + + renderSuggestion(value: string, el: HTMLElement): void { + if (value) + el.setText(value); + } +} \ No newline at end of file diff --git a/src/gui/genericTextSuggester.ts b/src/gui/genericTextSuggester.ts new file mode 100644 index 0000000..1e073b4 --- /dev/null +++ b/src/gui/genericTextSuggester.ts @@ -0,0 +1,30 @@ +import {TextInputSuggest} from "./suggest"; +import type {App} from "obsidian"; + +export class GenericTextSuggester extends TextInputSuggest { + constructor(public app: App, public inputEl: HTMLInputElement, private items: string[]) { + super(app, inputEl); + } + + getSuggestions(inputStr: string): string[] { + const inputLowerCase: string = inputStr.toLowerCase(); + const filtered = this.items.filter(item => { + if (item.toLowerCase().contains(inputLowerCase)) + return item; + }); + + if (!filtered) this.close(); + if (filtered?.length > 0) return filtered; + } + + selectSuggestion(item: string): void { + this.inputEl.value = item; + this.inputEl.trigger("input"); + this.close(); + } + + renderSuggestion(value: string, el: HTMLElement): void { + if (value) + el.setText(value); + } +} \ No newline at end of file diff --git a/src/gui/suggest.ts b/src/gui/suggest.ts new file mode 100644 index 0000000..072d6d0 --- /dev/null +++ b/src/gui/suggest.ts @@ -0,0 +1,198 @@ +// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes + +import { App, ISuggestOwner, Scope} from "obsidian"; +import { createPopper, Instance as PopperInstance } from "@popperjs/core"; + +const wrapAround = (value: number, size: number): number => { + return ((value % size) + size) % size; +}; + +class Suggest { + private owner: ISuggestOwner; + private values: T[]; + private suggestions: HTMLDivElement[]; + private selectedItem: number; + private containerEl: HTMLElement; + + constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope) { + this.owner = owner; + this.containerEl = containerEl; + + containerEl.on( + "click", + ".suggestion-item", + this.onSuggestionClick.bind(this) + ); + containerEl.on( + "mousemove", + ".suggestion-item", + this.onSuggestionMouseover.bind(this) + ); + + scope.register([], "ArrowUp", (event) => { + if (!event.isComposing) { + this.setSelectedItem(this.selectedItem - 1, true); + return false; + } + }); + + scope.register([], "ArrowDown", (event) => { + if (!event.isComposing) { + this.setSelectedItem(this.selectedItem + 1, true); + return false; + } + }); + + scope.register([], "Enter", (event) => { + if (!event.isComposing) { + this.useSelectedItem(event); + return false; + } + }); + } + + onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { + event.preventDefault(); + + const item = this.suggestions.indexOf(el); + this.setSelectedItem(item, false); + this.useSelectedItem(event); + } + + onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { + const item = this.suggestions.indexOf(el); + this.setSelectedItem(item, false); + } + + setSuggestions(values: T[]) { + this.containerEl.empty(); + const suggestionEls: HTMLDivElement[] = []; + + values.forEach((value) => { + const suggestionEl = this.containerEl.createDiv("suggestion-item"); + this.owner.renderSuggestion(value, suggestionEl); + suggestionEls.push(suggestionEl); + }); + + this.values = values; + this.suggestions = suggestionEls; + this.setSelectedItem(0, false); + } + + useSelectedItem(event: MouseEvent | KeyboardEvent) { + const currentValue = this.values[this.selectedItem]; + if (currentValue) { + this.owner.selectSuggestion(currentValue, event); + } + } + + setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { + const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length); + const prevSelectedSuggestion = this.suggestions[this.selectedItem]; + const selectedSuggestion = this.suggestions[normalizedIndex]; + + prevSelectedSuggestion?.removeClass("is-selected"); + selectedSuggestion?.addClass("is-selected"); + + this.selectedItem = normalizedIndex; + + if (scrollIntoView) { + selectedSuggestion.scrollIntoView(false); + } + } +} + +export abstract class TextInputSuggest implements ISuggestOwner { + protected app: App; + protected inputEl: HTMLInputElement | HTMLTextAreaElement; + + private popper: PopperInstance; + private scope: Scope; + private suggestEl: HTMLElement; + private suggest: Suggest; + + constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) { + this.app = app; + this.inputEl = inputEl; + this.scope = new Scope(); + + this.suggestEl = createDiv("suggestion-container"); + const suggestion = this.suggestEl.createDiv("suggestion"); + this.suggest = new Suggest(this, suggestion, this.scope); + + this.scope.register([], "Escape", this.close.bind(this)); + + this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); + this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); + this.inputEl.addEventListener("blur", this.close.bind(this)); + this.suggestEl.on( + "mousedown", + ".suggestion-container", + (event: MouseEvent) => { + event.preventDefault(); + } + ); + } + + onInputChanged(): void { + const inputStr = this.inputEl.value; + const suggestions = this.getSuggestions(inputStr); + + if (!suggestions) { + this.close(); + return; + } + + if (suggestions.length > 0) { + this.suggest.setSuggestions(suggestions); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.open((this.app).dom.appContainerEl, this.inputEl); + } else { + this.close() + } + } + + open(container: HTMLElement, inputEl: HTMLElement): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.app).keymap.pushScope(this.scope); + + container.appendChild(this.suggestEl); + this.popper = createPopper(inputEl, this.suggestEl, { + placement: "bottom-start", + modifiers: [ + { + name: "sameWidth", + enabled: true, + fn: ({ state, instance }) => { + // Note: positioning needs to be calculated twice - + // first pass - positioning it according to the width of the popper + // second pass - position it with the width bound to the reference element + // we need to early exit to avoid an infinite loop + const targetWidth = `${state.rects.reference.width}px`; + if (state.styles.popper.width === targetWidth) { + return; + } + state.styles.popper.width = targetWidth; + instance.update(); + }, + phase: "beforeWrite", + requires: ["computeStyles"], + }, + ], + }); + } + + close(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.app).keymap.popScope(this.scope); + + this.suggest.setSuggestions([]); + if (this.popper) + this.popper.destroy(); + this.suggestEl.detach(); + } + + abstract getSuggestions(inputStr: string): T[]; + abstract renderSuggestion(item: T, el: HTMLElement): void; + abstract selectSuggestion(item: T): void; +} \ No newline at end of file diff --git a/src/logger/consoleErrorLogger.ts b/src/logger/consoleErrorLogger.ts new file mode 100644 index 0000000..f8a4eba --- /dev/null +++ b/src/logger/consoleErrorLogger.ts @@ -0,0 +1,32 @@ +import {ErrorLevel} from "./errorLevel"; +import {QuickAddLogger} from "./quickAddLogger"; +import type {QuickAddError} from "./quickAddError"; + +export class ConsoleErrorLogger extends QuickAddLogger { + public ErrorLog: QuickAddError[] = []; + + public logError(errorMsg: string) { + const error = this.getQuickAddError(errorMsg, ErrorLevel.Error); + this.addMessageToErrorLog(error); + + console.error(this.formatOutputString(error)); + } + + public logWarning(warningMsg: string) { + const warning = this.getQuickAddError(warningMsg, ErrorLevel.Warning); + this.addMessageToErrorLog(warning); + + console.warn(this.formatOutputString(warning)); + } + + public logMessage(logMsg: string) { + const log = this.getQuickAddError(logMsg, ErrorLevel.Log); + this.addMessageToErrorLog(log); + + console.log(this.formatOutputString(log)); + } + + private addMessageToErrorLog(error: QuickAddError): void { + this.ErrorLog.push(error); + } +} \ No newline at end of file diff --git a/src/logger/errorLevel.ts b/src/logger/errorLevel.ts new file mode 100644 index 0000000..29a3e5f --- /dev/null +++ b/src/logger/errorLevel.ts @@ -0,0 +1 @@ +export enum ErrorLevel { Error = "ERROR", Warning = "WARNING", Log = "LOG"} diff --git a/src/logger/guiLogger.ts b/src/logger/guiLogger.ts new file mode 100644 index 0000000..ed2f4ad --- /dev/null +++ b/src/logger/guiLogger.ts @@ -0,0 +1,22 @@ +import {Notice} from "obsidian"; +import {QuickAddLogger} from "./quickAddLogger"; +import type QuickAdd from "../main"; +import {ErrorLevel} from "./errorLevel"; + +export class GuiLogger extends QuickAddLogger { + constructor(private plugin: QuickAdd) { + super(); + } + + logError(msg: string): void { + const error = this.getQuickAddError(msg, ErrorLevel.Error); + new Notice(this.formatOutputString(error)); + } + + logWarning(msg: string): void { + const warning = this.getQuickAddError(msg, ErrorLevel.Warning); + new Notice(this.formatOutputString(warning)); + } + + logMessage(msg: string): void {} +} \ No newline at end of file diff --git a/src/logger/ilogger.ts b/src/logger/ilogger.ts new file mode 100644 index 0000000..da13394 --- /dev/null +++ b/src/logger/ilogger.ts @@ -0,0 +1,7 @@ +export interface ILogger { + logError(msg: string): void; + + logWarning(msg: string): void; + + logMessage(msg: string): void; +} \ No newline at end of file diff --git a/src/logger/logManager.ts b/src/logger/logManager.ts new file mode 100644 index 0000000..73efaf1 --- /dev/null +++ b/src/logger/logManager.ts @@ -0,0 +1,25 @@ +import type {ILogger} from "./ilogger"; + +class LogManager { + public static loggers: ILogger[] = []; + + public register(logger: ILogger): LogManager { + LogManager.loggers.push(logger); + + return this; + } + + logError(message: string) { + LogManager.loggers.forEach(logger => logger.logError(message)); + } + + logWarning(message: string) { + LogManager.loggers.forEach(logger => logger.logError(message)); + } + + logMessage(message: string) { + LogManager.loggers.forEach(logger => logger.logMessage(message)); + } +} + +export const log = new LogManager(); \ No newline at end of file diff --git a/src/logger/quickAddError.ts b/src/logger/quickAddError.ts new file mode 100644 index 0000000..144f403 --- /dev/null +++ b/src/logger/quickAddError.ts @@ -0,0 +1,7 @@ +import type {ErrorLevel} from "./errorLevel"; + +export interface QuickAddError { + message: string, + level: ErrorLevel, + time: number +} \ No newline at end of file diff --git a/src/logger/quickAddLogger.ts b/src/logger/quickAddLogger.ts new file mode 100644 index 0000000..dda3333 --- /dev/null +++ b/src/logger/quickAddLogger.ts @@ -0,0 +1,19 @@ +import type {ILogger} from "./ilogger"; +import type {ErrorLevel} from "./errorLevel"; +import type {QuickAddError} from "./quickAddError"; + +export abstract class QuickAddLogger implements ILogger{ + abstract logError(msg: string): void; + + abstract logMessage(msg: string): void; + + abstract logWarning(msg: string): void; + + protected formatOutputString(error: QuickAddError): string { + return `QuickAdd: (${error.level}) ${error.message}`; + } + + protected getQuickAddError(message: string, level: ErrorLevel): QuickAddError { + return {message, level, time: Date.now()}; + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index f0cf621..2eb87ce 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,17 @@ import {Plugin} from 'obsidian'; -import {DEFAULT_SETTINGS, QuickAddSettingsTab} from "./quickAddSettingsTab"; - +import {DEFAULT_SETTINGS, QuickAddSettings, QuickAddSettingsTab} from "./quickAddSettingsTab"; +import {TemplateChoice} from "./types/choices/TemplateChoice"; +import {MultiChoice} from "./types/choices/MultiChoice"; +import {CaptureChoice} from "./types/choices/CaptureChoice"; +import {MacroChoice} from "./types/choices/MacroChoice"; +import ChoiceSuggester from "./gui/choiceSuggester"; +import {log} from "./logger/logManager"; +import {ConsoleErrorLogger} from "./logger/consoleErrorLogger"; +import {GuiLogger} from "./logger/guiLogger"; +import {StartupMacroEngine} from "./engine/StartupMacroEngine"; export default class QuickAdd extends Plugin { - settings: QuickAddSettingsTab; + settings: QuickAddSettings; async onload() { console.log('Loading QuickAdd'); @@ -14,7 +22,7 @@ export default class QuickAdd extends Plugin { id: 'runQuickAdd', name: 'Run QuickAdd', callback: () => { - + ChoiceSuggester.Open(this, this.settings.choices); } }) @@ -29,7 +37,38 @@ export default class QuickAdd extends Plugin { }); /*END.DEVCMD*/ + /*START.DEVCMD*/ + this.addCommand({ + id: 'giveDivChoices', + name: 'Give Dev Choices', + callback: () => { + this.settings.choices = [ + new TemplateChoice("πŸšΆβ€β™‚οΈ Journal"), + new TemplateChoice('πŸ“– Log Book to Daily Journal'), + new MultiChoice('πŸ“₯ Add...') + .addChoice(new CaptureChoice('πŸ’­ Add a Thought')) + .addChoice(new CaptureChoice('πŸ“₯ Add an Inbox Item')) + .addChoice(new TemplateChoice('πŸ“• Add Book Notes')), + new CaptureChoice("✍ Quick Capture"), + new TemplateChoice('πŸ’¬ Add Quote Page'), + new MultiChoice('πŸŒ€ Task Manager') + .addChoice(new MacroChoice('βœ” Add a Task')) + .addChoice(new CaptureChoice('βœ” Quick Capture Task')) + .addChoice(new CaptureChoice('βœ” Add MetaEdit Backlog Task')), + new CaptureChoice('πŸ’Έ Add Purchase'), + ]; + + this.saveSettings(); + } + }) + /*END.DEVCMD*/ + + log.register(new ConsoleErrorLogger()) + .register(new GuiLogger(this)); + this.addSettingTab(new QuickAddSettingsTab(this.app, this)); + + await new StartupMacroEngine(this.app, this.settings.macros).run(); } onunload() { diff --git a/src/quickAddApi.ts b/src/quickAddApi.ts new file mode 100644 index 0000000..421dfd9 --- /dev/null +++ b/src/quickAddApi.ts @@ -0,0 +1,26 @@ +import GenericInputPrompt from "./gui/GenericInputPrompt/genericInputPrompt"; +import GenericYesNoPrompt from "./gui/GenericYesNoPrompt/GenericYesNoPrompt"; +import GenericSuggester from "./gui/GenericSuggester/genericSuggester"; +import type {App} from "obsidian"; + +export class QuickAddApi { + public static GetApi(app: App) { + return { + inputPrompt: (header: string, placeholder?: string, value?: string) => {return this.inputPrompt(app, header, placeholder, value)}, + yesNoPrompt: (header: string, text?: string) => {return this.yesNoPrompt(app, header, text)}, + suggester: (displayItems: string[], actualItems: string[]) => {return this.suggester(app, displayItems, actualItems)} + }; + } + + public static async inputPrompt(app: App, header: string, placeholder?: string, value?: string) { + return await GenericInputPrompt.Prompt(app, header, placeholder, value); + } + + public static async yesNoPrompt(app: App, header: string, text?: string) { + return await GenericYesNoPrompt.Prompt(app, header, text); + } + + public static async suggester(app: App, displayItems: string[], actualItems: string[]) { + return await GenericSuggester.Suggest(app, displayItems, actualItems); + } +} \ No newline at end of file diff --git a/src/quickAddSettingsTab.ts b/src/quickAddSettingsTab.ts index 17cd35c..54f3bad 100644 --- a/src/quickAddSettingsTab.ts +++ b/src/quickAddSettingsTab.ts @@ -1,15 +1,22 @@ import {App, PluginSettingTab, Setting} from "obsidian"; import type QuickAdd from "./main"; +import type IChoice from "./types/choices/IChoice"; +import ChoiceView from "./gui/choiceList/ChoiceView.svelte" +import type {IMacro} from "./types/macros/IMacro"; export interface QuickAddSettings { - + choices: IChoice[]; + macros: IMacro[]; } export const DEFAULT_SETTINGS: QuickAddSettings = { + choices: [], + macros: [] } export class QuickAddSettingsTab extends PluginSettingTab { - plugin: QuickAdd; + public plugin: QuickAdd; + private choiceView: ChoiceView; constructor(app: App, plugin: QuickAdd) { super(app, plugin); @@ -19,7 +26,36 @@ export class QuickAddSettingsTab extends PluginSettingTab { display(): void { let {containerEl} = this; containerEl.empty(); - containerEl.createEl('h2', {text: 'QuickAdd Settings'}); + + this.addChoicesSetting(); + } + + hide(): any { + if (this.choiceView) + this.choiceView.$destroy(); + } + + private addChoicesSetting(): void { + const setting = new Setting(this.containerEl); + setting.infoEl.remove(); + setting.settingEl.style.display = "block"; + + this.choiceView = new ChoiceView({ + target: setting.settingEl, + props: { + app: this.app, + choices: this.plugin.settings.choices, + saveChoices: async (choices: IChoice[]) => { + this.plugin.settings.choices = choices; + await this.plugin.saveSettings(); + }, + macros: this.plugin.settings.macros, + saveMacros: async (macros: IMacro[]) => { + this.plugin.settings.macros = macros; + await this.plugin.saveSettings(); + } + } + }); } } \ No newline at end of file diff --git a/src/types/choices/CaptureChoice.ts b/src/types/choices/CaptureChoice.ts new file mode 100644 index 0000000..51abc69 --- /dev/null +++ b/src/types/choices/CaptureChoice.ts @@ -0,0 +1,27 @@ +import {Choice} from "./Choice"; +import {ChoiceType} from "./choiceType"; +import type ICaptureChoice from "./ICaptureChoice"; + +export class CaptureChoice extends Choice implements ICaptureChoice { + appendLink: boolean; + captureTo: string; + format: { enabled: boolean; format: string }; + insertAfter: { enabled: boolean; after: string }; + prepend: boolean; + task: boolean; + + constructor(name: string) { + super(name, ChoiceType.Capture); + + this.appendLink = false; + this.captureTo = ""; + this.format = {enabled: false, format: ""}; + this.insertAfter = {enabled: false, after: ""}; + this.prepend = false; + this.task = false; + } + + public static Load(choice: ICaptureChoice): CaptureChoice { + return choice as CaptureChoice; + } +} \ No newline at end of file diff --git a/src/types/choices/Choice.ts b/src/types/choices/Choice.ts new file mode 100644 index 0000000..7710307 --- /dev/null +++ b/src/types/choices/Choice.ts @@ -0,0 +1,15 @@ +import type {ChoiceType} from "./choiceType"; +import {v4 as uuidv4} from "uuid"; +import type IChoice from "./IChoice"; + +export abstract class Choice implements IChoice { + id: string; + name: string; + type: ChoiceType; + + protected constructor(name: string, type: ChoiceType) { + this.id = uuidv4(); + this.name = name; + this.type = type; + } +} \ No newline at end of file diff --git a/src/types/choices/ICaptureChoice.ts b/src/types/choices/ICaptureChoice.ts new file mode 100644 index 0000000..4d83bbc --- /dev/null +++ b/src/types/choices/ICaptureChoice.ts @@ -0,0 +1,11 @@ +import type IChoice from "./IChoice"; + +export default interface ICaptureChoice extends IChoice { + captureTo: string; + format: { enabled: boolean, format: string }; + prepend: boolean; + appendLink: boolean; + task: boolean; + insertAfter: { enabled: boolean, after: string }; +} + diff --git a/src/types/choices/IChoice.ts b/src/types/choices/IChoice.ts new file mode 100644 index 0000000..841562e --- /dev/null +++ b/src/types/choices/IChoice.ts @@ -0,0 +1,8 @@ +import type {ChoiceType} from "./choiceType"; + +export default interface IChoice { + name: string; + id: string; + type: ChoiceType; +} + diff --git a/src/types/choices/IMacroChoice.ts b/src/types/choices/IMacroChoice.ts new file mode 100644 index 0000000..b0be3d6 --- /dev/null +++ b/src/types/choices/IMacroChoice.ts @@ -0,0 +1,7 @@ +import type IChoice from "./IChoice"; +import type {IMacro} from "../macros/IMacro"; + +export default interface IMacroChoice extends IChoice { + macro: IMacro; +} + diff --git a/src/types/choices/IMultiChoice.ts b/src/types/choices/IMultiChoice.ts new file mode 100644 index 0000000..a4615db --- /dev/null +++ b/src/types/choices/IMultiChoice.ts @@ -0,0 +1,7 @@ +import type IChoice from "./IChoice"; + +export default interface IMultiChoice extends IChoice { + choices: IChoice[]; + collapsed: boolean; +} + diff --git a/src/types/choices/ITemplateChoice.ts b/src/types/choices/ITemplateChoice.ts new file mode 100644 index 0000000..4878d69 --- /dev/null +++ b/src/types/choices/ITemplateChoice.ts @@ -0,0 +1,12 @@ +import type IChoice from "./IChoice"; +import type {NewTabDirection} from "../newTabDirection"; + +export default interface ITemplateChoice extends IChoice { + templatePath: string; + folder: { enabled: boolean, folders: string[] } + fileNameFormat: { enabled: boolean, format: string }; + appendLink: boolean; + incrementFileName: boolean; + openFile: boolean; + openFileInNewTab: {enabled: boolean, direction: NewTabDirection}; +} \ No newline at end of file diff --git a/src/types/choices/MacroChoice.ts b/src/types/choices/MacroChoice.ts new file mode 100644 index 0000000..5eadc12 --- /dev/null +++ b/src/types/choices/MacroChoice.ts @@ -0,0 +1,14 @@ +import {Choice} from "./Choice"; +import {ChoiceType} from "./choiceType"; +import type IMacroChoice from "./IMacroChoice"; +import type {IMacro} from "../macros/IMacro"; + +export class MacroChoice extends Choice implements IMacroChoice { + macro: IMacro; + + constructor(name: string) { + super(name, ChoiceType.Macro); + + this.macro = null; + } +} \ No newline at end of file diff --git a/src/types/choices/MultiChoice.ts b/src/types/choices/MultiChoice.ts new file mode 100644 index 0000000..1a78954 --- /dev/null +++ b/src/types/choices/MultiChoice.ts @@ -0,0 +1,23 @@ +import {Choice} from "./Choice"; +import type IChoice from "./IChoice"; +import {ChoiceType} from "./choiceType"; +import type IMultiChoice from "./IMultiChoice"; + +export class MultiChoice extends Choice implements IMultiChoice { + choices: IChoice[] = []; + collapsed: boolean; + + constructor(name: string) { + super(name, ChoiceType.Multi); + } + + public addChoice(choice: IChoice): MultiChoice { + this.choices.push(choice); + return this; + } + + public addChoices(choices: IChoice[]): MultiChoice { + this.choices.push(...choices); + return this; + } +} \ No newline at end of file diff --git a/src/types/choices/TemplateChoice.ts b/src/types/choices/TemplateChoice.ts new file mode 100644 index 0000000..6e84816 --- /dev/null +++ b/src/types/choices/TemplateChoice.ts @@ -0,0 +1,30 @@ +import {ChoiceType} from "./choiceType"; +import type ITemplateChoice from "./ITemplateChoice"; +import {Choice} from "./Choice"; +import {NewTabDirection} from "../newTabDirection"; + +export class TemplateChoice extends Choice implements ITemplateChoice { + appendLink: boolean; + fileNameFormat: { enabled: boolean; format: string }; + folder: { enabled: boolean; folders: string[] }; + incrementFileName: boolean; + openFileInNewTab: { enabled: boolean; direction: NewTabDirection }; + openFile: boolean; + templatePath: string; + + constructor(name: string) { + super(name, ChoiceType.Template); + + this.templatePath = ""; + this.fileNameFormat = {enabled: false, format: ""}; + this.folder = {enabled: false, folders: []}; + this.openFileInNewTab = {enabled: false, direction: NewTabDirection.vertical}; + this.appendLink = false; + this.incrementFileName = false; + this.openFile = false; + } + + public static Load(choice: ITemplateChoice): TemplateChoice { + return choice as TemplateChoice; + } +} \ No newline at end of file diff --git a/src/types/choices/choiceType.ts b/src/types/choices/choiceType.ts new file mode 100644 index 0000000..1f29eaa --- /dev/null +++ b/src/types/choices/choiceType.ts @@ -0,0 +1,3 @@ +export enum ChoiceType { + Capture = "Capture", Macro = "Macro", Multi = "Multi", Template = "Template" +} \ No newline at end of file diff --git a/src/types/macros/Command.ts b/src/types/macros/Command.ts new file mode 100644 index 0000000..bcdf524 --- /dev/null +++ b/src/types/macros/Command.ts @@ -0,0 +1,15 @@ +import type {CommandType} from "./CommandType"; +import type {ICommand} from "./ICommand"; +import {v4 as uuidv4} from "uuid"; + +export abstract class Command implements ICommand { + name: string; + type: CommandType; + id: string; + + protected constructor(name: string, type: CommandType) { + this.name = name; + this.type = type; + this.id = uuidv4(); + } +} \ No newline at end of file diff --git a/src/types/macros/CommandType.ts b/src/types/macros/CommandType.ts new file mode 100644 index 0000000..3ade58d --- /dev/null +++ b/src/types/macros/CommandType.ts @@ -0,0 +1,3 @@ +export enum CommandType { + Obsidian = "Obsidian", UserScript = "UserScript" +} \ No newline at end of file diff --git a/src/types/macros/ICommand.ts b/src/types/macros/ICommand.ts new file mode 100644 index 0000000..962b441 --- /dev/null +++ b/src/types/macros/ICommand.ts @@ -0,0 +1,8 @@ +import type {CommandType} from "./CommandType"; + +export interface ICommand { + name: string; + type: CommandType + id: string; +} + diff --git a/src/types/macros/IMacro.ts b/src/types/macros/IMacro.ts new file mode 100644 index 0000000..8e15d14 --- /dev/null +++ b/src/types/macros/IMacro.ts @@ -0,0 +1,9 @@ +import type {ICommand} from "./ICommand"; + +export interface IMacro { + name: string; + id: string; + commands: ICommand[]; + runOnStartup: boolean; +} + diff --git a/src/types/macros/IObsidianCommand.ts b/src/types/macros/IObsidianCommand.ts new file mode 100644 index 0000000..ca81d94 --- /dev/null +++ b/src/types/macros/IObsidianCommand.ts @@ -0,0 +1,6 @@ +import type {ICommand} from "./ICommand"; + +export interface IObsidianCommand extends ICommand { + commandId: string; +} + diff --git a/src/types/macros/IUserScript.ts b/src/types/macros/IUserScript.ts new file mode 100644 index 0000000..374de49 --- /dev/null +++ b/src/types/macros/IUserScript.ts @@ -0,0 +1,6 @@ +import type {ICommand} from "./ICommand"; + +export interface IUserScript extends ICommand { + path: string; +} + diff --git a/src/types/macros/ObsidianCommand.ts b/src/types/macros/ObsidianCommand.ts new file mode 100644 index 0000000..902fc59 --- /dev/null +++ b/src/types/macros/ObsidianCommand.ts @@ -0,0 +1,15 @@ +import {Command} from "./Command"; +import {CommandType} from "./CommandType"; +import type {IObsidianCommand} from "./IObsidianCommand"; + +export class ObsidianCommand extends Command implements IObsidianCommand { + name: string; + id: string; + commandId: string; + type: CommandType; + + constructor(name: string, commandId: string) { + super(name, CommandType.Obsidian); + this.commandId = commandId; + } +} \ No newline at end of file diff --git a/src/types/macros/QuickAddMacro.ts b/src/types/macros/QuickAddMacro.ts new file mode 100644 index 0000000..aad213c --- /dev/null +++ b/src/types/macros/QuickAddMacro.ts @@ -0,0 +1,17 @@ +import type {ICommand} from "./ICommand"; +import type {IMacro} from "./IMacro"; +import {v4 as uuidv4} from "uuid"; + +export class QuickAddMacro implements IMacro { + id: string; + name: string; + commands: ICommand[]; + runOnStartup: boolean; + + constructor(name: string) { + this.name = name; + this.id = uuidv4(); + this.commands = []; + this.runOnStartup = false; + } +} \ No newline at end of file diff --git a/src/types/macros/UserScript.ts b/src/types/macros/UserScript.ts new file mode 100644 index 0000000..b262456 --- /dev/null +++ b/src/types/macros/UserScript.ts @@ -0,0 +1,14 @@ +import {Command} from "./Command"; +import {CommandType} from "./CommandType"; +import type {IUserScript} from "./IUserScript"; + +export class UserScript extends Command implements IUserScript { + name: string; + path: string; + type: CommandType; + + constructor(name: string, path: string) { + super(name, CommandType.UserScript); + this.path = path; + } +} \ No newline at end of file diff --git a/src/types/newTabDirection.ts b/src/types/newTabDirection.ts new file mode 100644 index 0000000..62367b4 --- /dev/null +++ b/src/types/newTabDirection.ts @@ -0,0 +1,3 @@ +export enum NewTabDirection { + vertical = "vertical", horizontal = "horizontal" +} \ No newline at end of file diff --git a/src/utility.ts b/src/utility.ts new file mode 100644 index 0000000..83d718d --- /dev/null +++ b/src/utility.ts @@ -0,0 +1,38 @@ +import type {App} from "obsidian"; +import {Notice} from "obsidian"; +import {log} from "./logger/logManager"; + +export function getTemplater(app: App) { + // @ts-ignore + return app.plugins.plugins["templater-obsidian"] +} + +export function getNaturalLanguageDates(app: App) { + // @ts-ignore + return app.plugins.plugins["nldates-obsidian"]; +} + +export function getDate(input?: {format?: string, offset?: number|string}) { + let duration; + + if (input.offset) { + if(typeof input.offset === "string") + duration = window.moment.duration(input.offset); + else if (typeof input.offset === "number") + duration = window.moment.duration(input.offset, "days"); + } + + return input.format ? window.moment().add(duration).format(input.format) : window.moment().add(duration).format("YYYY-MM-DD"); +} + +export function appendToCurrentLine(toAppend: string, app: App) { + try { + // @ts-ignore + const editor = app.workspace.activeLeaf.view.editor; + const selected = editor.getSelection(); + + editor.replaceSelection(`${selected}${toAppend}`); + } catch { + log.logError(`unable to append '${toAppend}' to current line.`); + } +} \ No newline at end of file diff --git a/styles.css b/styles.css index e69de29..9ca72bd 100644 --- a/styles.css +++ b/styles.css @@ -0,0 +1,44 @@ +.configureMacroDiv { + display: grid; + grid-template-rows: 1fr; + min-width: 12rem; +} + +.configureMacroDivItem { + display: flex; + align-content: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.configureMacroDivItemButton { + display: flex; + align-content: center; + justify-content: center; + margin-bottom: 10px; +} + +.macroContainer { + display: grid; + grid-template-rows: repeat(auto-fill, 120px); + grid-gap: 40px; +} + +.macroContainer1 { + grid-template-columns: repeat(1, 1fr); +} + +.macroContainer2 { + grid-template-columns: repeat(2, 1fr); +} + +.macroContainer3 { + grid-template-columns: repeat(3, 1fr); +} + +.addMacroBarContainer { + display: flex; + align-content: center; + justify-content: space-around; + margin-top: 20px; +} \ No newline at end of file diff --git a/versions.json b/versions.json index 01436ec..9ccc0e8 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "0.0.1": "0.12.4" + "0.1.0": "0.12.4" }