♻️ Refactor the plugin into modular typescript

This commit is contained in:
Philipp Stracker 2025-03-18 21:11:52 +01:00
parent 4fec59e532
commit 003d6fe5f7
No known key found for this signature in database
8 changed files with 704 additions and 376 deletions

31
src/constants.ts Normal file
View file

@ -0,0 +1,31 @@
import { MentionSettings } from './types';
/**
* Default plugin settings
*/
export const DEFAULT_SETTINGS: MentionSettings = {
mentionTypes: [],
matchStart: true,
};
/**
* List of allowed signs for mentions
*/
export const ALLOWED_SIGNS = [
'@',
'%',
'&',
'!',
'?',
'+',
';',
'.',
'=',
'^',
'§',
'$',
'-',
'_',
'(',
'{',
];

162
src/editor/suggestion.ts Normal file
View file

@ -0,0 +1,162 @@
import { App, EditorSuggest, Editor, EditorPosition, TFile } from 'obsidian';
import { MentionSettings, MentionSuggestion } from '../types';
import { MentionManager } from '../mention/mention-manager';
import { getTypeDef, createMentionLink } from '../mention/link-utils';
/**
* Provides suggestion functionality for mentions in the editor
*/
export class SuggestionProvider extends EditorSuggest<MentionSuggestion> {
private settings: MentionSettings;
private mentionManager: MentionManager;
private currSign: string = '';
private fileMaps: any;
constructor(app: App, settings: MentionSettings, mentionManager: MentionManager) {
super(app);
this.settings = settings;
this.mentionManager = mentionManager;
this.fileMaps = mentionManager.getFileMaps();
}
/**
* Update the suggestions map
*/
setSuggestionsMap(maps: any): void {
this.fileMaps = maps;
}
/**
* Determine if suggestions should be triggered
*/
onTrigger(cursor: EditorPosition, editor: Editor, file: TFile): EditorSuggestTriggerInfo | null {
const usedSigns = this.mentionManager.getUsedSigns();
let charsLeftOfCursor = editor.getLine(cursor.line).substring(0, cursor.ch);
if (!charsLeftOfCursor) {
return null;
}
let signIndex = -1;
for (let sign of usedSigns) {
let index = charsLeftOfCursor.lastIndexOf(sign);
if (index !== -1 && index > signIndex) {
signIndex = index;
this.currSign = sign;
}
}
if (signIndex < 0) {
return null;
}
let query = signIndex >= 0 && charsLeftOfCursor.substring(signIndex + 1);
if (
query
&& !query.includes(']]')
&& (
// if it's a sign at the start of a line
signIndex === 0
// or if there's a space character before it
|| charsLeftOfCursor[signIndex - 1] === ' '
)
) {
return {
start: { line: cursor.line, ch: signIndex },
end: { line: cursor.line, ch: cursor.ch },
query,
};
}
return null;
}
/**
* Get suggestions based on the query
*/
getSuggestions(context: EditorSuggestContext): MentionSuggestion[] {
let suggestions: MentionSuggestion[] = [];
let map = this.fileMaps[this.currSign] || {};
for (let key in map) {
let isMatch;
if (!key) {
continue;
}
const term = context.query.toLowerCase();
if (this.settings.matchStart) {
isMatch = key.startsWith(term);
} else {
isMatch = key.includes(term);
}
if (isMatch) {
suggestions.push({
suggestionType: 'set',
displayText: map[key].name.trim(),
linkName: map[key].name,
context,
});
}
}
// Always add option to create new item
suggestions.push({
suggestionType: 'create',
displayText: context.query,
linkName: context.query,
context,
});
return suggestions;
}
/**
* Render a suggestion in the dropdown
*/
renderSuggestion(value: MentionSuggestion, el: HTMLElement): void {
if (value.suggestionType === 'create') {
const type = getTypeDef(this.settings.mentionTypes, this.currSign);
const label = type?.label || 'Item';
el.setText(`Create ${label}: ${value.displayText}`);
} else {
el.setText(value.displayText);
}
}
/**
* Handle selection of a suggestion
*/
selectSuggestion(value: MentionSuggestion, evt: MouseEvent | KeyboardEvent): void {
const link = createMentionLink(this.currSign, value.linkName);
value.context.editor.replaceRange(
link,
value.context.start,
value.context.end,
);
}
}
// Type definition for EditorSuggest trigger info
interface EditorSuggestTriggerInfo {
start: EditorPosition;
end: EditorPosition;
query: string;
}
// Type definition for EditorSuggest context
interface EditorSuggestContext {
start: EditorPosition;
end: EditorPosition;
query: string;
editor: Editor;
}

View file

@ -1,394 +1,109 @@
const {
EditorSuggest,
Plugin,
PluginSettingTab,
Setting
} = require('obsidian')
import { Plugin } from 'obsidian';
import { MentionSettings, FileMaps } from './types';
import { DEFAULT_SETTINGS } from './constants';
import { MentionManager } from './mention/mention-manager';
import { SuggestionProvider } from './editor/suggestion';
import { SettingsTab } from './settings/settings-tab';
const DEFAULT_SETTINGS = {
mentionTypes: [],
export default class MentionThingsPlugin extends Plugin {
settings: MentionSettings;
mentionManager: MentionManager;
suggestionProvider: SuggestionProvider;
// matchStart: undefined,
}
const getLink = (path, settings) => {
if (!path.endsWith('.md')) {
return null;
}
for (let i = 0; i < settings.mentionTypes.length; i++) {
const type = settings.mentionTypes[i];
if (!type?.sign) {
continue;
}
if (!path.includes('/' + type.sign)) {
continue;
}
const regex = new RegExp(`/${type.sign}([^/]+)\\.md$`);
let result = regex.exec(path)
if (result?.[1]) {
return {
sign: type.sign,
name: result[1],
path
}
}
}
return null;
}
function getTypeDef(types, sign) {
for (let i = 0; i < types.length; i++) {
if (sign === types[i]?.sign) {
return types[i];
}
}
return null;
}
module.exports = class AtPeople extends Plugin {
async onload() {
await this.loadSettings()
// Load settings
await this.loadSettings();
this.registerEvent(this.app.vault.on('delete', async event => {
await this.update(event)
}))
this.registerEvent(this.app.vault.on('create', async event => {
await this.update(event)
}))
this.registerEvent(this.app.vault.on('rename', async (event, originalFilepath) => {
await this.update(event, originalFilepath)
}))
this.addSettingTab(new AtPeopleSettingTab(this.app, this))
this.suggestor = new AtPeopleSuggestor(this.app, this.settings)
this.registerEditorSuggest(this.suggestor)
this.app.workspace.onLayoutReady(this.initialize)
// Initialize the mention manager
this.mentionManager = new MentionManager(this.app, this.settings);
// Initialize the suggestion provider
this.suggestionProvider = new SuggestionProvider(
this.app,
this.settings,
this.mentionManager
);
// Register the suggestion provider with the editor
this.registerEditorSuggest(this.suggestionProvider);
// Add the settings tab
this.addSettingTab(new SettingsTab(this.app, this));
// Register file events
this.registerFileEvents();
// Initialize when the layout is ready
this.app.workspace.onLayoutReady(() => {
const fileMaps = this.mentionManager.initialize();
this.suggestionProvider.setSuggestionsMap(fileMaps);
});
}
/**
* Register file event handlers
*/
private registerFileEvents(): void {
// Handle file deletion
this.registerEvent(
this.app.vault.on('delete', async (file) => {
const updated = this.mentionManager.handleFileEvent(file);
if (updated) {
this.refreshSuggestions();
}
})
);
// Handle file creation
this.registerEvent(
this.app.vault.on('create', async (file) => {
const updated = this.mentionManager.handleFileEvent(file);
if (updated) {
this.refreshSuggestions();
}
})
);
// Handle file renaming
this.registerEvent(
this.app.vault.on('rename', async (file, oldPath) => {
const updated = this.mentionManager.handleFileEvent(file, oldPath);
if (updated) {
this.refreshSuggestions();
}
})
);
}
/**
* Refresh the suggestions list in the editor
*/
private refreshSuggestions(): void {
const fileMaps = this.mentionManager.getFileMaps();
this.suggestionProvider.setSuggestionsMap(fileMaps);
}
/**
* Load settings from disk
*/
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
)
);
}
/**
* Save settings to disk
*/
async saveSettings() {
await this.saveData(this.settings || DEFAULT_SETTINGS)
}
refreshFileList = () => {
this.suggestor.setSuggestionsMap(this.fileMaps)
}
update = async ({path, deleted, ...remaining}, originalFilepath) => {
let needsUpdated = false;
this.fileMaps = this.fileMaps || {}
const addItem = getLink(path, this.settings)
if (addItem) {
this.addFileToMap(addItem)
needsUpdated = true
}
const removeItem = originalFilepath && getLink(originalFilepath, this.settings)
if (removeItem) {
this.removeFileFromMap(removeItem)
needsUpdated = true
}
if (needsUpdated) {
this.refreshFileList()
}
}
initialize = () => {
this.fileMaps = {}
for (const filename in this.app.vault.fileMap) {
const addItem = getLink(filename, this.settings)
if (addItem) {
this.addFileToMap(addItem)
}
}
window.setTimeout(this.refreshFileList, 0, this)
}
addFileToMap(item) {
const sign = item?.sign;
await this.saveData(this.settings);
if (!sign) {
return;
}
const key = item.name.toLowerCase();
this.fileMaps[sign] = this.fileMaps[sign] || {};
this.fileMaps[sign][key] = item;
}
removeFileFromMap(item) {
const sign = item?.sign;
if (!sign) {
return;
}
if (this.fileMaps[sign]) {
const key = item.name.toLowerCase();
delete this.fileMaps[sign][key]
// Update the mention manager with new settings
if (this.mentionManager) {
this.mentionManager.updateSettings(this.settings);
this.refreshSuggestions();
}
}
}
class AtPeopleSuggestor extends EditorSuggest {
constructor(app, settings) {
super(app)
this.settings = settings
this.currSign = '';
}
setSuggestionsMap(maps) {
this.maps = maps;
}
onTrigger(cursor, editor, tFile) {
const usedSigns = this.settings.mentionTypes.map(object => object.sign);
let charsLeftOfCursor = editor.getLine(cursor.line).substring(0, cursor.ch)
if (!charsLeftOfCursor) {
return null;
}
let signIndex = -1;
for (let sign of usedSigns) {
let index = charsLeftOfCursor.lastIndexOf(sign);
if (index !== -1 && index > signIndex) {
signIndex = index;
this.currSign = sign;
}
}
if (signIndex < 0) {
return null;
}
let query = signIndex >= 0 && charsLeftOfCursor.substring(signIndex + 1)
if (
query
&& !query.includes(']]')
&& (
// if it's an @ at the start of a line
signIndex === 0
// or if there's a space character before it
|| charsLeftOfCursor[signIndex - 1] === ' '
)
) {
return {
start: {line: cursor.line, ch: signIndex},
end: {line: cursor.line, ch: cursor.ch},
query,
}
}
return null
}
getSuggestions(context) {
let suggestions = []
let map = this.maps[this.currSign] || {};
for (let key in map) {
let isMatch;
if (!key) {
continue;
}
const term = context.query.toLowerCase()
if (this.settings.matchStart) {
isMatch = key.startsWith(term)
} else {
isMatch = key.includes(term);
}
if (isMatch) {
suggestions.push({
suggestionType: 'set',
displayText: map[key].name.trim(),
linkName: map[key].name,
context,
})
}
}
suggestions.push({
suggestionType: 'create',
displayText: context.query,
linkName: context.query,
context,
})
return suggestions
}
renderSuggestion(value, elem) {
if (value.suggestionType === 'create') {
const type = getTypeDef(this.settings.mentionTypes, this.currSign);
const label = type?.label || 'Item';
elem.setText(`Create ${label}: ${value.displayText}`)
} else {
elem.setText(value.displayText)
}
}
selectSuggestion(value) {
let link
console.log(value);
link = `[[${this.currSign}${value.linkName}]]`
value.context.editor.replaceRange(
link,
value.context.start,
value.context.end,
)
}
}
// ----------------------------------------------------------------------------
// Settings
/**
* @param {string[]} usedSigns
* @param {string} currentSign
* @returns {object}
*/
function getAvailableSigns(usedSigns, currentSign) {
const allowedSigns = [
'@',
'%',
'&',
'!',
'?',
'+',
';',
'.',
'=',
'^',
'§',
'$',
'-',
'_',
'(',
'{',
];
const availableSigns = {};
allowedSigns.forEach(sign => {
if (currentSign === sign || !usedSigns.includes(sign)) {
availableSigns[sign] = `${sign}Name`
}
})
return availableSigns;
}
class AtPeopleSettingTab extends PluginSettingTab {
constructor(app, plugin) {
super(app, plugin)
this.plugin = plugin
}
display() {
const {containerEl} = this
let usedSigns = this.plugin.settings.mentionTypes.map(object => object.sign);
containerEl.empty()
containerEl.createEl("h2", {text: "Mention Things"});
this.plugin.settings.mentionTypes.forEach((value, index) => {
const setting = new Setting(containerEl)
const availableSigns = getAvailableSigns(usedSigns, value?.sign);
setting.addDropdown(
list => list
.addOptions(availableSigns)
.setValue(value?.sign || '')
.onChange(async (value) => {
this.plugin.settings.mentionTypes[index].sign = value
await this.plugin.saveSettings()
this.display()
})
)
setting.addText(
text => text
.setPlaceholder('Label. Example "Person"')
.setValue(value?.label || '')
.onChange(async (value) => {
this.plugin.settings.mentionTypes[index].label = value
await this.plugin.saveSettings()
})
.inputEl.addClass('type_label')
)
setting.addExtraButton(
button => button
.setIcon("cross")
.setTooltip("Delete")
.onClick(async () => {
this.plugin.settings.mentionTypes.splice(index, 1);
await this.plugin.saveSettings();
this.display();
})
);
setting.infoEl.remove();
});
new Setting(containerEl)
.addButton(cb => {
cb.setButtonText("New Type")
.setCta()
.onClick(async () => {
this.plugin.settings.mentionTypes.push({});
await this.plugin.saveSettings();
this.display();
});
});
new Setting(containerEl)
.setName('Match from start')
.setDesc('Whether to suggest only items that start with the search term. When disabled, any part of the name can match the search term')
.addToggle(
toggle => toggle.setValue(this.plugin.settings.matchStart)
.onChange(async (value) => {
this.plugin.settings.matchStart = value
await this.plugin.saveSettings()
})
)
}
}

100
src/mention/file-indexer.ts Normal file
View file

@ -0,0 +1,100 @@
import { App, TFile } from 'obsidian';
import { MentionSettings, FileMaps, MentionLink } from '../types';
import { getLinkFromPath } from './link-utils';
/**
* Handles indexing and tracking mentionable files
*/
export class FileIndexer {
private app: App;
private settings: MentionSettings;
private fileMaps: FileMaps = {};
constructor(app: App, settings: MentionSettings) {
this.app = app;
this.settings = settings;
}
/**
* Initialize the file index by scanning all files in the vault
*/
initialize(): FileMaps {
this.fileMaps = {};
// Get all files in the vault using the proper Obsidian API
const files = this.app.vault.getAllLoadedFiles();
// Process each file
files.forEach(file => {
if (file instanceof TFile && file.extension === 'md') {
const mentionLink = getLinkFromPath(file.path, this.settings);
if (mentionLink) {
this.addFileToMap(mentionLink);
}
}
});
return this.fileMaps;
}
/**
* Get the current file maps
*/
getFileMaps(): FileMaps {
return this.fileMaps;
}
/**
* Update the file index when a file is created, deleted, or renamed
*/
updateIndex(path: string, originalPath?: string): boolean {
let needsUpdate = false;
// Handle new or updated file
const addItem = getLinkFromPath(path, this.settings);
if (addItem) {
this.addFileToMap(addItem);
needsUpdate = true;
}
// Handle renamed or deleted file
if (originalPath) {
const removeItem = getLinkFromPath(originalPath, this.settings);
if (removeItem) {
this.removeFileFromMap(removeItem);
needsUpdate = true;
}
}
return needsUpdate;
}
/**
* Add a file to the appropriate map
*/
private addFileToMap(item: MentionLink): void {
const sign = item.sign;
if (!sign) {
return;
}
const key = item.name.toLowerCase();
this.fileMaps[sign] = this.fileMaps[sign] || {};
this.fileMaps[sign][key] = item;
}
/**
* Remove a file from the map
*/
private removeFileFromMap(item: MentionLink): void {
const sign = item.sign;
if (!sign || !this.fileMaps[sign]) {
return;
}
const key = item.name.toLowerCase();
delete this.fileMaps[sign][key];
}
}

64
src/mention/link-utils.ts Normal file
View file

@ -0,0 +1,64 @@
import { MentionSettings, MentionType, MentionLink } from '../types';
/**
* Extract mention information from a file path
* @param path File path to analyze
* @param settings Plugin settings
* @returns MentionLink object if the path matches a mention pattern, null otherwise
*/
export function getLinkFromPath(path: string, settings: MentionSettings): MentionLink | null {
if (!path.endsWith('.md')) {
return null;
}
for (let i = 0; i < settings.mentionTypes.length; i++) {
const type = settings.mentionTypes[i];
if (!type?.sign) {
continue;
}
if (!path.includes('/' + type.sign)) {
continue;
}
const regex = new RegExp(`/${type.sign}([^/]+)\\.md$`);
let result = regex.exec(path);
if (result?.[1]) {
return {
sign: type.sign,
name: result[1],
path,
};
}
}
return null;
}
/**
* Find a mention type definition by its sign
* @param types Array of mention types
* @param sign Sign character to look for
* @returns MentionType if found, null otherwise
*/
export function getTypeDef(types: MentionType[], sign: string): MentionType | null {
for (let i = 0; i < types.length; i++) {
if (sign === types[i]?.sign) {
return types[i];
}
}
return null;
}
/**
* Create a link string for a mention
* @param sign The mention sign
* @param name The name to link to
* @returns Formatted link string
*/
export function createMentionLink(sign: string, name: string): string {
return `[[${sign}${name}]]`;
}

View file

@ -0,0 +1,61 @@
import { App } from 'obsidian';
import { MentionSettings, FileMaps } from '../types';
import { FileIndexer } from './file-indexer';
/**
* Core mention functionality manager
*/
export class MentionManager {
private settings: MentionSettings;
private fileIndexer: FileIndexer;
constructor(app: App, settings: MentionSettings) {
this.settings = settings;
this.fileIndexer = new FileIndexer(app, settings);
}
/**
* Initialize the mention manager
*/
initialize(): FileMaps {
return this.fileIndexer.initialize();
}
/**
* Get the current file maps
*/
getFileMaps(): FileMaps {
return this.fileIndexer.getFileMaps();
}
/**
* Handle file events (create, delete, rename)
* @param fileEvent File event data
* @param originalPath Original path for rename events
* @returns Whether the file maps were updated
*/
handleFileEvent(fileEvent: {
path: string,
deleted?: boolean
}, originalPath?: string): boolean {
return this.fileIndexer.updateIndex(fileEvent.path, originalPath);
}
/**
* Get all used signs from the settings
*/
getUsedSigns(): string[] {
return this.settings.mentionTypes.map(object => object.sign).filter(Boolean) as string[];
}
/**
* Update settings with new values
* @param settings New settings
*/
updateSettings(settings: MentionSettings): void {
this.settings = settings;
// Re-initialize file maps with new settings
this.initialize();
}
}

View file

@ -0,0 +1,143 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import { AvailableSigns } from '../types';
import { ALLOWED_SIGNS } from '../constants';
import MentionThingsPlugin from '../main';
/**
* Settings tab for the Mention Things plugin
*/
export class SettingsTab extends PluginSettingTab {
private plugin: MentionThingsPlugin;
constructor(app: App, plugin: MentionThingsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
/**
* Display the settings tab
*/
display(): void {
const { containerEl } = this;
let usedSigns = this.plugin.settings.mentionTypes
.map(object => object.sign)
.filter((sign): sign is string => sign !== undefined);
containerEl.empty();
containerEl.createEl('h2', { text: 'Mention Things' });
// Render mention types section
this.renderMentionTypesSection(containerEl, usedSigns);
// Render general settings section
this.renderGeneralSettings(containerEl);
}
/**
* Render the mention types section of the settings
*/
private renderMentionTypesSection(containerEl: HTMLElement, usedSigns: string[]): void {
containerEl.createEl('h3', { text: 'Mention Types' });
// Render each mention type
this.plugin.settings.mentionTypes.forEach((value, index) => {
this.renderMentionTypeRow(containerEl, value, index, usedSigns);
});
// Add button for new type
new Setting(containerEl)
.addButton(cb => {
cb.setButtonText('New Type')
.setCta()
.onClick(async () => {
this.plugin.settings.mentionTypes.push({});
await this.plugin.saveSettings();
this.display();
});
});
}
/**
* Render a single mention type row
*/
private renderMentionTypeRow(containerEl: HTMLElement, value: any, index: number, usedSigns: string[]): void {
const setting = new Setting(containerEl);
const availableSigns = this.getAvailableSigns(usedSigns, value?.sign);
// Sign dropdown
setting.addDropdown(
list => list
.addOptions(availableSigns)
.setValue(value?.sign || '')
.onChange(async (value) => {
this.plugin.settings.mentionTypes[index].sign = value;
await this.plugin.saveSettings();
this.display();
}),
);
// Label text field
setting.addText(
text => text
.setPlaceholder('Label. Example "Person"')
.setValue(value?.label || '')
.onChange(async (value) => {
this.plugin.settings.mentionTypes[index].label = value;
await this.plugin.saveSettings();
})
.inputEl.addClass('type_label'),
);
// Delete button
setting.addExtraButton(
button => button
.setIcon('cross')
.setTooltip('Delete')
.onClick(async () => {
this.plugin.settings.mentionTypes.splice(index, 1);
await this.plugin.saveSettings();
this.display();
}),
);
setting.infoEl.remove();
}
/**
* Render general settings section
*/
private renderGeneralSettings(containerEl: HTMLElement): void {
containerEl.createEl('h3', { text: 'General Settings' });
new Setting(containerEl)
.setName('Match from start')
.setDesc('Whether to suggest only items that start with the search term. When disabled, any part of the name can match the search term')
.addToggle(
toggle => toggle
.setValue(this.plugin.settings.matchStart ?? false)
.onChange(async (value) => {
this.plugin.settings.matchStart = value;
await this.plugin.saveSettings();
}),
);
}
/**
* Get available signs for the dropdown
* @param usedSigns Array of signs already in use
* @param currentSign Current sign for this type (to allow keeping the same sign)
* @returns Object mapping signs to display values
*/
private getAvailableSigns(usedSigns: string[], currentSign?: string): AvailableSigns {
const availableSigns: AvailableSigns = {};
ALLOWED_SIGNS.forEach(sign => {
if (currentSign === sign || !usedSigns.includes(sign)) {
availableSigns[sign] = `${sign}Name`;
}
});
return availableSigns;
}
}

52
src/types.ts Normal file
View file

@ -0,0 +1,52 @@
import { App, TFile } from 'obsidian';
/**
* Plugin settings interface
*/
export interface MentionSettings {
mentionTypes: MentionType[];
matchStart: boolean;
}
/**
* Represents a type of mention with its sign and label
*/
export interface MentionType {
sign?: string;
label?: string;
}
/**
* Represents a link to a mentioned item
*/
export interface MentionLink {
sign: string;
name: string;
path: string;
}
/**
* Structure for storing mentionable files
*/
export interface FileMaps {
[sign: string]: {
[key: string]: MentionLink;
};
}
/**
* Available signs for dropdown selection
*/
export interface AvailableSigns {
[sign: string]: string;
}
/**
* Suggestion item structure
*/
export interface MentionSuggestion {
suggestionType: 'set' | 'create';
displayText: string;
linkName: string;
context: any;
}