From 003d6fe5f7c37d281911eefb517a4bde56d5134e Mon Sep 17 00:00:00 2001
From: Philipp Stracker
Date: Tue, 18 Mar 2025 21:11:52 +0100
Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20the=20plugin=20?=
=?UTF-8?q?into=20modular=20typescript?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/constants.ts | 31 +++
src/editor/suggestion.ts | 162 ++++++++++++
src/main.ts | 467 +++++++--------------------------
src/mention/file-indexer.ts | 100 +++++++
src/mention/link-utils.ts | 64 +++++
src/mention/mention-manager.ts | 61 +++++
src/settings/settings-tab.ts | 143 ++++++++++
src/types.ts | 52 ++++
8 files changed, 704 insertions(+), 376 deletions(-)
create mode 100644 src/constants.ts
create mode 100644 src/editor/suggestion.ts
create mode 100644 src/mention/file-indexer.ts
create mode 100644 src/mention/link-utils.ts
create mode 100644 src/mention/mention-manager.ts
create mode 100644 src/settings/settings-tab.ts
create mode 100644 src/types.ts
diff --git a/src/constants.ts b/src/constants.ts
new file mode 100644
index 0000000..073dd85
--- /dev/null
+++ b/src/constants.ts
@@ -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 = [
+ '@',
+ '%',
+ '&',
+ '!',
+ '?',
+ '+',
+ ';',
+ '.',
+ '=',
+ '^',
+ '§',
+ '$',
+ '-',
+ '_',
+ '(',
+ '{',
+];
diff --git a/src/editor/suggestion.ts b/src/editor/suggestion.ts
new file mode 100644
index 0000000..9985e24
--- /dev/null
+++ b/src/editor/suggestion.ts
@@ -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 {
+ 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;
+}
diff --git a/src/main.ts b/src/main.ts
index d6b4752..7f5d9b5 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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()
- })
- )
- }
-}
diff --git a/src/mention/file-indexer.ts b/src/mention/file-indexer.ts
new file mode 100644
index 0000000..4cf7f51
--- /dev/null
+++ b/src/mention/file-indexer.ts
@@ -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];
+ }
+}
diff --git a/src/mention/link-utils.ts b/src/mention/link-utils.ts
new file mode 100644
index 0000000..84aebff
--- /dev/null
+++ b/src/mention/link-utils.ts
@@ -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}]]`;
+}
diff --git a/src/mention/mention-manager.ts b/src/mention/mention-manager.ts
new file mode 100644
index 0000000..d816645
--- /dev/null
+++ b/src/mention/mention-manager.ts
@@ -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();
+ }
+}
diff --git a/src/settings/settings-tab.ts b/src/settings/settings-tab.ts
new file mode 100644
index 0000000..98239a1
--- /dev/null
+++ b/src/settings/settings-tab.ts
@@ -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;
+ }
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..55a2020
--- /dev/null
+++ b/src/types.ts
@@ -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;
+}