Initial POC

POC for a plugin that allows you to write templates with LiquidJS tags
This commit is contained in:
Diomede 2021-05-18 21:39:08 +02:00
parent a90dc017a1
commit b490f21770
7 changed files with 488 additions and 98 deletions

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[**.{ts,json,js}]
end_of_line = crlf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 2

130
main.ts
View file

@ -1,112 +1,46 @@
import { App, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import { Plugin } from "obsidian";
interface MyPluginSettings {
mySetting: string;
}
import { LiquidTemplatesSettings, DEFAULT_SETTINGS, LiquidTemplatesSettingsTab } from "./settings";
import TemplateSuggest from "./suggest/template-suggest";
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
}
export default class LiquidTemplates extends Plugin {
private autosuggest: TemplateSuggest;
public settings: LiquidTemplatesSettings;
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload(): Promise<void> {
console.log('loading liquid templates plugin');
await this.loadSettings();
async onload() {
console.log('loading plugin');
this.setupAutosuggest();
await this.loadSettings();
this.addSettingTab(new LiquidTemplatesSettingsTab(this.app, this))
}
this.addRibbonIcon('dice', 'Sample Plugin', () => {
new Notice('This is a notice!');
});
onunload(): void {
console.log('unloading liquid templates plugin');
}
this.addStatusBarItem().setText('Status Bar Text');
this.addCommand({
id: 'open-sample-modal',
name: 'Open Sample Modal',
// callback: () => {
// console.log('Simple Callback');
// },
checkCallback: (checking: boolean) => {
let leaf = this.app.workspace.activeLeaf;
if (leaf) {
if (!checking) {
new SampleModal(this.app).open();
}
return true;
}
return false;
}
});
this.addSettingTab(new SampleSettingTab(this.app, this));
setupAutosuggest(): void {
this.autosuggest = new TemplateSuggest(this.app, this);
this.registerCodeMirror((cm: CodeMirror.Editor) => {
console.log('codemirror', cm);
cm.on("change", this.autosuggestHandler);
});
}
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
autosuggestHandler = (
cmEditor: CodeMirror.Editor,
changeObj: CodeMirror.EditorChange
): boolean => {
return this.autosuggest?.update(cmEditor, changeObj);
};
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
}
async loadSettings(): Promise<void> {
this.settings = Object.assign(DEFAULT_SETTINGS, await this.loadData());
}
onunload() {
console.log('unloading plugin');
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
let {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
let {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
let {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'});
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue('')
.onChange(async (value) => {
console.log('Secret: ' + value);
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
}
async saveSettings(): Promise<void> {
this.setupAutosuggest();
await this.saveData(this.settings);
}
}

87
settings.ts Normal file
View file

@ -0,0 +1,87 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import LiquidTemplates from "./main";
export interface LiquidTemplatesSettings {
templatesFolder: string;
dateFormat: string;
timeFormat: string;
autocompleteTrigger: string;
}
export const DEFAULT_SETTINGS: LiquidTemplatesSettings = {
templatesFolder: 'templates',
dateFormat: '%Y-%m-%d',
timeFormat: '%H:%M',
autocompleteTrigger: ';;'
};
export class LiquidTemplatesSettingsTab extends PluginSettingTab {
plugin: LiquidTemplates;
constructor(app: App, plugin: LiquidTemplates) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', {
text: 'Liquid Templates',
});
new Setting(containerEl)
.setName('Templates folder')
.setDesc('Folder where to find your templates, it will be used also as root folder for the liquid tags, like the include ones')
.addMomentFormat((text) =>
text
.setDefaultFormat(DEFAULT_SETTINGS.templatesFolder)
.setValue(this.plugin.settings.templatesFolder)
.onChange(async (value) => {
this.plugin.settings.templatesFolder = value || DEFAULT_SETTINGS.templatesFolder;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Date format')
.setDesc('Output format for render dates')
.addMomentFormat((text) =>
text
.setDefaultFormat(DEFAULT_SETTINGS.dateFormat)
.setValue(this.plugin.settings.dateFormat)
.onChange(async (value) => {
this.plugin.settings.dateFormat = value || DEFAULT_SETTINGS.dateFormat;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Time format')
.setDesc('Output format for render time')
.addMomentFormat((text) =>
text
.setDefaultFormat(DEFAULT_SETTINGS.timeFormat)
.setValue(this.plugin.settings.timeFormat)
.onChange(async (value) => {
this.plugin.settings.timeFormat = value || DEFAULT_SETTINGS.timeFormat;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Autocomplete trigger')
.setDesc('Character/s that trigger the autocomplete menu')
.addMomentFormat((text) =>
text
.setPlaceholder(DEFAULT_SETTINGS.autocompleteTrigger)
.setValue(this.plugin.settings.autocompleteTrigger || DEFAULT_SETTINGS.autocompleteTrigger)
.onChange(async (value) => {
this.plugin.settings.autocompleteTrigger = value.trim();
await this.plugin.saveSettings();
})
);
}
}

View file

@ -0,0 +1,129 @@
import { App, ISuggestOwner, Scope } from "obsidian";
import Suggest from "./suggest";
function checkForInputPhrase(
cmEditor: CodeMirror.Editor,
pos: CodeMirror.Position,
phrase: string
): boolean {
const from = {
line: pos.line,
ch: pos.ch - phrase.length,
};
return cmEditor.getRange(from, pos) === phrase;
}
function isCursorBeforePos(
pos: CodeMirror.Position,
cursor: CodeMirror.Position
): boolean {
if (pos.line === cursor.line) {
return cursor.ch < pos.ch;
}
return cursor.line < pos.line;
}
export default abstract class CodeMirrorSuggest<T> implements ISuggestOwner<T> {
protected app: App;
protected cmEditor: CodeMirror.Editor;
private scope: Scope;
private suggestEl: HTMLElement;
private instructionsEl: HTMLElement;
private suggest: Suggest<T>;
private startPos: CodeMirror.Position;
private triggerPhrase: string;
constructor(app: App, triggerPhrase: string) {
this.triggerPhrase = triggerPhrase;
this.app = app;
this.scope = new Scope();
this.suggestEl = createDiv("suggestion-container");
const suggestion = this.suggestEl.createDiv("suggestion");
this.instructionsEl = this.suggestEl.createDiv("prompt-instructions");
this.suggest = new Suggest(this, suggestion, this.scope);
this.scope.register([], "Escape", this.close.bind(this));
}
public setInstructions(
createInstructionsFn: (containerEl: HTMLElement) => void
): void {
this.instructionsEl.empty();
createInstructionsFn(this.instructionsEl);
}
public update(
cmEditor: CodeMirror.Editor,
changeObj: CodeMirror.EditorChange
): boolean {
if (this.cmEditor !== cmEditor) {
this.suggestEl?.detach();
}
this.cmEditor = cmEditor;
const cursorPos = cmEditor.getCursor();
// autosuggest is open
if (this.suggestEl.parentNode) {
if (isCursorBeforePos(this.startPos, cursorPos)) {
this.close();
return false;
}
this.attachAtCursor();
} else {
if (
changeObj.text.length === 1 && // ignore multi-cursors
checkForInputPhrase(this.cmEditor, cursorPos, this.triggerPhrase) &&
!document.querySelector(".suggestion-container") // don't trigger multiple autosuggests
) {
this.startPos = cursorPos;
this.open();
this.attachAtCursor();
}
}
return false;
}
protected getStartPos(): CodeMirror.Position {
return {
line: this.startPos.line,
ch: this.startPos.ch - this.triggerPhrase.length,
};
}
protected getInputStr(): string {
// return string from / to cursor
const cursor = this.cmEditor.getCursor();
const line = this.cmEditor.getLine(cursor.line);
return line.substring(this.startPos.ch, cursor.ch);
}
private attachAtCursor() {
const inputStr = this.getInputStr();
const suggestions = this.getSuggestions(inputStr);
this.suggest.setSuggestions(suggestions);
this.cmEditor.addWidget(this.cmEditor.getCursor(), this.suggestEl, true);
}
open(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(<any>this.app).keymap.pushScope(this.scope);
}
close(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(<any>this.app).keymap.popScope(this.scope);
this.startPos = null;
this.suggest.setSuggestions([]);
this.suggestEl.detach();
}
abstract getSuggestions(inputStr: string): T[];
abstract renderSuggestion(item: T, el: HTMLElement): void;
abstract selectSuggestion(item: T, evt: MouseEvent | KeyboardEvent): void;
}

102
suggest/suggest.ts Normal file
View file

@ -0,0 +1,102 @@
import { ISuggestOwner, Scope } from "obsidian";
export const wrapAround = (value: number, size: number): number => {
return ((value % size) + size) % size;
};
export default class Suggest<T> {
private owner: ISuggestOwner<T>;
private values: T[];
private suggestions: HTMLDivElement[];
private selectedItem: number;
private containerEl: HTMLElement;
constructor(owner: ISuggestOwner<T>, 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;
}
});
const selectItem = (event: KeyboardEvent) => {
if (!event.isComposing) {
this.useSelectedItem(event);
return false;
}
};
scope.register([], "Enter", selectItem);
scope.register(["Shift"], "Enter", selectItem);
}
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[]): void {
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): void {
const currentValue = this.values[this.selectedItem];
if (currentValue) {
this.owner.selectSuggestion(currentValue, event);
}
}
setSelectedItem(selectedIndex: number, scrollIntoView: boolean): void {
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);
}
}
}

108
suggest/template-suggest.ts Normal file
View file

@ -0,0 +1,108 @@
import { App, TFile } from "obsidian";
import { Liquid } from "liquidjs";
import type PoweredTemplates from "../main";
import CodeMirrorSuggest from "./codemirror-suggest";
import { getTFilesFromFolder } from "utils";
interface ITemplateCompletion {
label: string;
file?: TFile;
}
// TODO: create a class for the suggestion that implements the ITemplateCompletion and have some
// utility method to render the template
// TODO: generate a proper context and use the defined settings for the date and time format
export default class TemplateSuggest extends CodeMirrorSuggest<ITemplateCompletion> {
plugin: PoweredTemplates;
engine: Liquid;
constructor(app: App, plugin: PoweredTemplates) {
super(app, plugin.settings.autocompleteTrigger);
this.plugin = plugin;
// TODO: move me in a separate class
this.engine = new Liquid({
fs: {
readFileSync: (file) => {
// TODO: implement me
return "";
},
readFile: async (file) => {
const { templatesFolder } = this.plugin.settings;
// TODO: find a better way to do this
const fileObj = getTFilesFromFolder(app, templatesFolder).find(f => f.basename === file);
return app.vault.read(fileObj);
},
existsSync: () => {
return true
},
exists: async () => {
return true
},
resolve: (_root, file, _ext) => {
return file
}
},
extname: '.md',
greedy: true,
});
}
open(): void {
super.open();
}
getSuggestions(inputStr: string): ITemplateCompletion[] {
// handle no matches
const suggestions = this.getTemplateSuggestions(inputStr);
if (suggestions.length) {
return suggestions;
} else {
return [{ label: inputStr }];
}
}
getTemplateSuggestions(inputStr: string): ITemplateCompletion[] {
// find the list of files
// TODO: filter before returning all the files
const templates = getTFilesFromFolder(this.app, this.plugin.settings.templatesFolder);
return templates
.map(file => ({ label: file.basename, file: file }))
.filter((items) => items.label.toLowerCase().startsWith(inputStr));
}
renderSuggestion(suggestion: ITemplateCompletion, el: HTMLElement): void {
el.setText(suggestion.label);
}
async generateContext() {
return {
// TODO: improve me
date: await this.engine.parseAndRender(`{{ "now" | date: "${this.plugin.settings.dateFormat}"}}`),
time: await this.engine.parseAndRender(`{{ "now" | date: "${this.plugin.settings.timeFormat}"}}`),
title: await this.app.workspace.getActiveFile().basename,
default_date_format: this.plugin.settings.dateFormat,
default_time_format: this.plugin.settings.timeFormat
}
}
async selectSuggestion(
suggestion: ITemplateCompletion,
_event: KeyboardEvent | MouseEvent
): Promise<void> {
const head = this.getStartPos();
const anchor = this.cmEditor.getCursor();
if (suggestion.file) {
const templateString = await this.app.vault.read(suggestion.file)
const rendered = await this.engine.parseAndRender(
templateString,
await this.generateContext()
);
this.cmEditor.replaceRange(rendered.trim(), head, anchor);
}
this.close()
}
}

21
utils.ts Normal file
View file

@ -0,0 +1,21 @@
import { App, normalizePath, TAbstractFile, TFile, TFolder, Vault } from "obsidian";
export function getTFilesFromFolder(app: App, folderName: string): Array<TFile> {
folderName = normalizePath(folderName);
let folder = app.vault.getAbstractFileByPath(folderName);
if (!folder) throw new Error(`${folderName} folder doesn't exist`);
if (!(folder instanceof TFolder)) throw new Error(`${folderName} is a file, not a folder`);
let files: Array<TFile> = [];
Vault.recurseChildren(folder, (file: TAbstractFile) => {
if (file instanceof TFile) {
files.push(file);
}
});
files.sort((a, b) => {
return a.basename.localeCompare(b.basename);
});
return files;
}