mirror of
				https://github.com/zadam/trilium.git
				synced 2025-11-03 20:06:08 +01:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			v0.98.1
			...
			feat/add-c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					5332f015ef | ||
| 
						 | 
					a3d77421fd | 
							
								
								
									
										223
									
								
								apps/client/src/services/ckeditor_plugin_config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								apps/client/src/services/ckeditor_plugin_config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,223 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @module CKEditor Plugin Configuration Service
 | 
			
		||||
 * 
 | 
			
		||||
 * This service manages the dynamic configuration of CKEditor plugins based on user preferences.
 | 
			
		||||
 * It handles plugin enablement, dependency resolution, and toolbar configuration.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import server from "./server.js";
 | 
			
		||||
import type { 
 | 
			
		||||
    PluginConfiguration, 
 | 
			
		||||
    PluginMetadata, 
 | 
			
		||||
    PluginRegistry,
 | 
			
		||||
    PluginValidationResult
 | 
			
		||||
} from "@triliumnext/commons";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Cache for plugin registry and user configuration
 | 
			
		||||
 */
 | 
			
		||||
let pluginRegistryCache: PluginRegistry | null = null;
 | 
			
		||||
let userConfigCache: PluginConfiguration[] | null = null;
 | 
			
		||||
let cacheTimestamp = 0;
 | 
			
		||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get the plugin registry from server
 | 
			
		||||
 */
 | 
			
		||||
export async function getPluginRegistry(): Promise<PluginRegistry> {
 | 
			
		||||
    const now = Date.now();
 | 
			
		||||
    if (pluginRegistryCache && (now - cacheTimestamp) < CACHE_DURATION) {
 | 
			
		||||
        return pluginRegistryCache;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
        pluginRegistryCache = await server.get<PluginRegistry>('ckeditor-plugins/registry');
 | 
			
		||||
        cacheTimestamp = now;
 | 
			
		||||
        return pluginRegistryCache;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Failed to load CKEditor plugin registry:', error);
 | 
			
		||||
        throw error;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get the user's plugin configuration from server
 | 
			
		||||
 */
 | 
			
		||||
export async function getUserPluginConfig(): Promise<PluginConfiguration[]> {
 | 
			
		||||
    const now = Date.now();
 | 
			
		||||
    if (userConfigCache && (now - cacheTimestamp) < CACHE_DURATION) {
 | 
			
		||||
        return userConfigCache;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
        userConfigCache = await server.get<PluginConfiguration[]>('ckeditor-plugins/config');
 | 
			
		||||
        cacheTimestamp = now;
 | 
			
		||||
        return userConfigCache;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Failed to load user plugin configuration:', error);
 | 
			
		||||
        throw error;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Clear the cache (call when configuration is updated)
 | 
			
		||||
 */
 | 
			
		||||
export function clearCache(): void {
 | 
			
		||||
    pluginRegistryCache = null;
 | 
			
		||||
    userConfigCache = null;
 | 
			
		||||
    cacheTimestamp = 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get the enabled plugins for the current user
 | 
			
		||||
 */
 | 
			
		||||
export async function getEnabledPlugins(): Promise<Set<string>> {
 | 
			
		||||
    const userConfig = await getUserPluginConfig();
 | 
			
		||||
    const enabledPlugins = new Set<string>();
 | 
			
		||||
    
 | 
			
		||||
    // Add all enabled user plugins
 | 
			
		||||
    userConfig.forEach(config => {
 | 
			
		||||
        if (config.enabled) {
 | 
			
		||||
            enabledPlugins.add(config.id);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Always include core plugins
 | 
			
		||||
    const registry = await getPluginRegistry();
 | 
			
		||||
    Object.values(registry.plugins).forEach(plugin => {
 | 
			
		||||
        if (plugin.isCore) {
 | 
			
		||||
            enabledPlugins.add(plugin.id);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    return enabledPlugins;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get disabled plugin names for CKEditor config
 | 
			
		||||
 */
 | 
			
		||||
export async function getDisabledPlugins(): Promise<string[]> {
 | 
			
		||||
    try {
 | 
			
		||||
        const registry = await getPluginRegistry();
 | 
			
		||||
        const enabledPlugins = await getEnabledPlugins();
 | 
			
		||||
        const disabledPlugins: string[] = [];
 | 
			
		||||
        
 | 
			
		||||
        // Find plugins that are disabled
 | 
			
		||||
        Object.values(registry.plugins).forEach(plugin => {
 | 
			
		||||
            if (!plugin.isCore && !enabledPlugins.has(plugin.id)) {
 | 
			
		||||
                // Map plugin ID to actual CKEditor plugin names if needed
 | 
			
		||||
                const pluginNames = getPluginNames(plugin.id);
 | 
			
		||||
                disabledPlugins.push(...pluginNames);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        return disabledPlugins;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.warn("Failed to get disabled plugins, returning empty list:", error);
 | 
			
		||||
        return [];
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Map plugin ID to actual CKEditor plugin names
 | 
			
		||||
 * Some plugins might have multiple names or different names than their ID
 | 
			
		||||
 */
 | 
			
		||||
function getPluginNames(pluginId: string): string[] {
 | 
			
		||||
    const nameMap: Record<string, string[]> = {
 | 
			
		||||
        "emoji": ["EmojiMention", "EmojiPicker"],
 | 
			
		||||
        "math": ["Math", "AutoformatMath"],
 | 
			
		||||
        "image": ["Image", "ImageCaption", "ImageInline", "ImageResize", "ImageStyle", "ImageToolbar", "ImageUpload"],
 | 
			
		||||
        "table": ["Table", "TableToolbar", "TableProperties", "TableCellProperties", "TableSelection", "TableCaption", "TableColumnResize"],
 | 
			
		||||
        "font": ["Font", "FontColor", "FontBackgroundColor"],
 | 
			
		||||
        "list": ["List", "ListProperties"],
 | 
			
		||||
        "specialcharacters": ["SpecialCharacters", "SpecialCharactersEssentials"],
 | 
			
		||||
        "findandreplace": ["FindAndReplace"],
 | 
			
		||||
        "horizontalline": ["HorizontalLine"],
 | 
			
		||||
        "pagebreak": ["PageBreak"],
 | 
			
		||||
        "removeformat": ["RemoveFormat"],
 | 
			
		||||
        "alignment": ["Alignment"],
 | 
			
		||||
        "indent": ["Indent", "IndentBlock"],
 | 
			
		||||
        "codeblock": ["CodeBlock"],
 | 
			
		||||
        "blockquote": ["BlockQuote"],
 | 
			
		||||
        "todolist": ["TodoList"],
 | 
			
		||||
        "heading": ["Heading", "HeadingButtonsUI"],
 | 
			
		||||
        "paragraph": ["ParagraphButtonUI"],
 | 
			
		||||
        // Add more mappings as needed
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    return nameMap[pluginId] || [pluginId.charAt(0).toUpperCase() + pluginId.slice(1)];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validate the current plugin configuration
 | 
			
		||||
 */
 | 
			
		||||
export async function validatePluginConfiguration(): Promise<PluginValidationResult> {
 | 
			
		||||
    try {
 | 
			
		||||
        const userConfig = await getUserPluginConfig();
 | 
			
		||||
        return await server.post<PluginValidationResult>('ckeditor-plugins/validate', {
 | 
			
		||||
            plugins: userConfig
 | 
			
		||||
        });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Failed to validate plugin configuration:', error);
 | 
			
		||||
        return {
 | 
			
		||||
            valid: false,
 | 
			
		||||
            errors: [{
 | 
			
		||||
                type: "missing_dependency",
 | 
			
		||||
                pluginId: "unknown",
 | 
			
		||||
                message: `Validation failed: ${error}`
 | 
			
		||||
            }],
 | 
			
		||||
            warnings: [],
 | 
			
		||||
            resolvedPlugins: []
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get toolbar items that should be hidden based on disabled plugins
 | 
			
		||||
 */
 | 
			
		||||
export async function getHiddenToolbarItems(): Promise<string[]> {
 | 
			
		||||
    const registry = await getPluginRegistry();
 | 
			
		||||
    const enabledPlugins = await getEnabledPlugins();
 | 
			
		||||
    const hiddenItems: string[] = [];
 | 
			
		||||
    
 | 
			
		||||
    Object.values(registry.plugins).forEach(plugin => {
 | 
			
		||||
        if (!enabledPlugins.has(plugin.id) && plugin.toolbarItems) {
 | 
			
		||||
            hiddenItems.push(...plugin.toolbarItems);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    return hiddenItems;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Update user plugin configuration
 | 
			
		||||
 */
 | 
			
		||||
export async function updatePluginConfiguration(plugins: PluginConfiguration[]): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
        const response = await server.put('ckeditor-plugins/config', {
 | 
			
		||||
            plugins,
 | 
			
		||||
            validate: true
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        if (!response.success) {
 | 
			
		||||
            throw new Error(response.errors?.join(", ") || "Update failed");
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Clear cache so next requests fetch fresh data
 | 
			
		||||
        clearCache();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Failed to update plugin configuration:', error);
 | 
			
		||||
        throw error;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    getPluginRegistry,
 | 
			
		||||
    getUserPluginConfig,
 | 
			
		||||
    getEnabledPlugins,
 | 
			
		||||
    getDisabledPlugins,
 | 
			
		||||
    getHiddenToolbarItems,
 | 
			
		||||
    validatePluginConfiguration,
 | 
			
		||||
    updatePluginConfiguration,
 | 
			
		||||
    clearCache
 | 
			
		||||
};
 | 
			
		||||
@@ -1814,6 +1814,43 @@
 | 
			
		||||
      "multiline-toolbar": "Display the toolbar on multiple lines if it doesn't fit."
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "ckeditor_plugins": {
 | 
			
		||||
    "title": "Editor Plugins",
 | 
			
		||||
    "description": "Configure which CKEditor plugins are enabled. Changes take effect when the editor is reloaded.",
 | 
			
		||||
    "loading": "Loading plugin configuration...",
 | 
			
		||||
    "load_failed": "Failed to load plugin configuration.",
 | 
			
		||||
    "load_error": "Error loading plugins",
 | 
			
		||||
    "retry": "Retry",
 | 
			
		||||
    "category_formatting": "Text Formatting",
 | 
			
		||||
    "category_structure": "Document Structure",
 | 
			
		||||
    "category_media": "Media & Files",
 | 
			
		||||
    "category_tables": "Tables",
 | 
			
		||||
    "category_advanced": "Advanced Features",
 | 
			
		||||
    "category_trilium": "Trilium Features",
 | 
			
		||||
    "category_external": "External Plugins",
 | 
			
		||||
    "stats_enabled": "Enabled",
 | 
			
		||||
    "stats_total": "Total",
 | 
			
		||||
    "stats_core": "Core",
 | 
			
		||||
    "stats_premium": "Premium",
 | 
			
		||||
    "no_license": "no license",
 | 
			
		||||
    "premium": "Premium",
 | 
			
		||||
    "premium_required": "Requires premium CKEditor license",
 | 
			
		||||
    "has_dependencies": "Dependencies",
 | 
			
		||||
    "depends_on": "Depends on",
 | 
			
		||||
    "toolbar_items": "Toolbar items",
 | 
			
		||||
    "validate": "Validate",
 | 
			
		||||
    "validation_error": "Validation failed",
 | 
			
		||||
    "validation_errors": "Configuration Errors:",
 | 
			
		||||
    "validation_warnings": "Configuration Warnings:",
 | 
			
		||||
    "save": "Save Changes",
 | 
			
		||||
    "save_success": "Plugin configuration saved successfully",
 | 
			
		||||
    "save_error": "Failed to save configuration",
 | 
			
		||||
    "reload_editor_notice": "Please reload any open text notes to apply changes",
 | 
			
		||||
    "reset_defaults": "Reset to Defaults",
 | 
			
		||||
    "reset_confirm": "Are you sure you want to reset all plugin settings to their default values?",
 | 
			
		||||
    "reset_success": "Plugin configuration reset to defaults",
 | 
			
		||||
    "reset_error": "Failed to reset configuration"
 | 
			
		||||
  },
 | 
			
		||||
  "electron_context_menu": {
 | 
			
		||||
    "add-term-to-dictionary": "Add \"{{term}}\" to dictionary",
 | 
			
		||||
    "cut": "Cut",
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ import noteAutocompleteService, { type Suggestion } from "../../../services/note
 | 
			
		||||
import mimeTypesService from "../../../services/mime_types.js";
 | 
			
		||||
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
 | 
			
		||||
import { buildToolbarConfig } from "./toolbar.js";
 | 
			
		||||
import ckeditorPluginConfigService from "../../../services/ckeditor_plugin_config.js";
 | 
			
		||||
 | 
			
		||||
export const OPEN_SOURCE_LICENSE_KEY = "GPL";
 | 
			
		||||
 | 
			
		||||
@@ -164,7 +165,7 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
 | 
			
		||||
        },
 | 
			
		||||
        // This value must be kept in sync with the language defined in webpack.config.js.
 | 
			
		||||
        language: "en",
 | 
			
		||||
        removePlugins: getDisabledPlugins()
 | 
			
		||||
        removePlugins: await getDisabledPlugins()
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Set up content language.
 | 
			
		||||
@@ -203,9 +204,11 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const toolbarConfig = await buildToolbarConfig(opts.isClassicEditor);
 | 
			
		||||
    
 | 
			
		||||
    return {
 | 
			
		||||
        ...config,
 | 
			
		||||
        ...buildToolbarConfig(opts.isClassicEditor)
 | 
			
		||||
        ...toolbarConfig
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -237,9 +240,18 @@ function getLicenseKey() {
 | 
			
		||||
    return premiumLicenseKey;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getDisabledPlugins() {
 | 
			
		||||
async function getDisabledPlugins() {
 | 
			
		||||
    let disabledPlugins: string[] = [];
 | 
			
		||||
 | 
			
		||||
    // Check user's plugin configuration
 | 
			
		||||
    try {
 | 
			
		||||
        const userDisabledPlugins = await ckeditorPluginConfigService.getDisabledPlugins();
 | 
			
		||||
        disabledPlugins.push(...userDisabledPlugins);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.warn("Failed to load user plugin configuration, using defaults:", error);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Legacy emoji setting override
 | 
			
		||||
    if (options.get("textNoteEmojiCompletionEnabled") !== "true") {
 | 
			
		||||
        disabledPlugins.push("EmojiMention");
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,33 +1,73 @@
 | 
			
		||||
import utils from "../../../services/utils.js";
 | 
			
		||||
import options from "../../../services/options.js";
 | 
			
		||||
import ckeditorPluginConfigService from "../../../services/ckeditor_plugin_config.js";
 | 
			
		||||
 | 
			
		||||
const TEXT_FORMATTING_GROUP = {
 | 
			
		||||
    label: "Text formatting",
 | 
			
		||||
    icon: "text"
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function buildToolbarConfig(isClassicToolbar: boolean) {
 | 
			
		||||
export async function buildToolbarConfig(isClassicToolbar: boolean) {
 | 
			
		||||
    const hiddenItems = await getHiddenToolbarItems();
 | 
			
		||||
    
 | 
			
		||||
    if (utils.isMobile()) {
 | 
			
		||||
        return buildMobileToolbar();
 | 
			
		||||
        return buildMobileToolbar(hiddenItems);
 | 
			
		||||
    } else if (isClassicToolbar) {
 | 
			
		||||
        const multilineToolbar = utils.isDesktop() && options.get("textNoteEditorMultilineToolbar") === "true";
 | 
			
		||||
        return buildClassicToolbar(multilineToolbar);
 | 
			
		||||
        return buildClassicToolbar(multilineToolbar, hiddenItems);
 | 
			
		||||
    } else {
 | 
			
		||||
        return buildFloatingToolbar();
 | 
			
		||||
        return buildFloatingToolbar(hiddenItems);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function buildMobileToolbar() {
 | 
			
		||||
    const classicConfig = buildClassicToolbar(false);
 | 
			
		||||
async function getHiddenToolbarItems(): Promise<Set<string>> {
 | 
			
		||||
    try {
 | 
			
		||||
        const hiddenItems = await ckeditorPluginConfigService.getHiddenToolbarItems();
 | 
			
		||||
        return new Set(hiddenItems);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.warn("Failed to get hidden toolbar items, using empty set:", error);
 | 
			
		||||
        return new Set();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Filter toolbar items based on disabled plugins
 | 
			
		||||
 */
 | 
			
		||||
function filterToolbarItems(items: (string | object)[], hiddenItems: Set<string>): (string | object)[] {
 | 
			
		||||
    return items.filter(item => {
 | 
			
		||||
        if (typeof item === 'string') {
 | 
			
		||||
            // Don't hide separators
 | 
			
		||||
            if (item === '|') return true;
 | 
			
		||||
            // Check if this item should be hidden
 | 
			
		||||
            return !hiddenItems.has(item);
 | 
			
		||||
        } else if (typeof item === 'object' && item !== null && 'items' in item) {
 | 
			
		||||
            // Filter nested items recursively
 | 
			
		||||
            const nestedItem = item as { items: (string | object)[] };
 | 
			
		||||
            const filteredNested = filterToolbarItems(nestedItem.items, hiddenItems);
 | 
			
		||||
            // Only keep the group if it has at least one non-separator item
 | 
			
		||||
            const hasNonSeparatorItems = filteredNested.some(subItem => 
 | 
			
		||||
                typeof subItem === 'string' ? subItem !== '|' : true
 | 
			
		||||
            );
 | 
			
		||||
            if (hasNonSeparatorItems) {
 | 
			
		||||
                return { ...item, items: filteredNested };
 | 
			
		||||
            }
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
        return true;
 | 
			
		||||
    }).filter(item => item !== null) as (string | object)[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function buildMobileToolbar(hiddenItems: Set<string>) {
 | 
			
		||||
    const classicConfig = buildClassicToolbar(false, hiddenItems);
 | 
			
		||||
    const items: string[] = [];
 | 
			
		||||
 | 
			
		||||
    for (const item of classicConfig.toolbar.items) {
 | 
			
		||||
        if (typeof item === "object" && "items" in item) {
 | 
			
		||||
            for (const subitem of item.items) {
 | 
			
		||||
            for (const subitem of (item as any).items) {
 | 
			
		||||
                items.push(subitem);
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            items.push(item);
 | 
			
		||||
            items.push(item as string);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -40,11 +80,9 @@ export function buildMobileToolbar() {
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function buildClassicToolbar(multilineToolbar: boolean) {
 | 
			
		||||
export function buildClassicToolbar(multilineToolbar: boolean, hiddenItems: Set<string>) {
 | 
			
		||||
    // For nested toolbars, refer to https://ckeditor.com/docs/ckeditor5/latest/getting-started/setup/toolbar.html#grouping-toolbar-items-in-dropdowns-nested-toolbars.
 | 
			
		||||
    return {
 | 
			
		||||
        toolbar: {
 | 
			
		||||
            items: [
 | 
			
		||||
    const items = [
 | 
			
		||||
        "heading",
 | 
			
		||||
        "fontSize",
 | 
			
		||||
        "|",
 | 
			
		||||
@@ -85,16 +123,18 @@ export function buildClassicToolbar(multilineToolbar: boolean) {
 | 
			
		||||
        "markdownImport",
 | 
			
		||||
        "cuttonote",
 | 
			
		||||
        "findAndReplace"
 | 
			
		||||
            ],
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        toolbar: {
 | 
			
		||||
            items: filterToolbarItems(items, hiddenItems),
 | 
			
		||||
            shouldNotGroupWhenFull: multilineToolbar
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function buildFloatingToolbar() {
 | 
			
		||||
    return {
 | 
			
		||||
        toolbar: {
 | 
			
		||||
            items: [
 | 
			
		||||
export function buildFloatingToolbar(hiddenItems: Set<string>) {
 | 
			
		||||
    const toolbarItems = [
 | 
			
		||||
        "fontSize",
 | 
			
		||||
        "bold",
 | 
			
		||||
        "italic",
 | 
			
		||||
@@ -113,10 +153,9 @@ export function buildFloatingToolbar() {
 | 
			
		||||
        "removeFormat",
 | 
			
		||||
        "internallink",
 | 
			
		||||
        "cuttonote"
 | 
			
		||||
            ]
 | 
			
		||||
        },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
        blockToolbar: [
 | 
			
		||||
    const blockToolbarItems = [
 | 
			
		||||
        "heading",
 | 
			
		||||
        "|",
 | 
			
		||||
        "bulletedList",
 | 
			
		||||
@@ -144,6 +183,12 @@ export function buildFloatingToolbar() {
 | 
			
		||||
        "specialCharacters",
 | 
			
		||||
        "emoji",
 | 
			
		||||
        "findAndReplace"
 | 
			
		||||
        ]
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        toolbar: {
 | 
			
		||||
            items: filterToolbarItems(toolbarItems, hiddenItems)
 | 
			
		||||
        },
 | 
			
		||||
        blockToolbar: filterToolbarItems(blockToolbarItems, hiddenItems)
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ import PasswordSettings from "./options/password.jsx";
 | 
			
		||||
import ShortcutSettings from "./options/shortcuts.js";
 | 
			
		||||
import TextNoteSettings from "./options/text_notes.jsx";
 | 
			
		||||
import CodeNoteSettings from "./options/code_notes.jsx";
 | 
			
		||||
import CKEditorPluginSettings from "./options/ckeditor_plugins.jsx";
 | 
			
		||||
import OtherSettings from "./options/other.jsx";
 | 
			
		||||
import BackendLogWidget from "./content/backend_log.js";
 | 
			
		||||
import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication.js";
 | 
			
		||||
@@ -45,13 +46,14 @@ const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printabl
 | 
			
		||||
    <div class="note-detail-content-widget-content"></div>
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
 | 
			
		||||
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsCKEditorPlugins" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
 | 
			
		||||
 | 
			
		||||
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", ((typeof NoteContextAwareWidget)[] | JSX.Element)> = {
 | 
			
		||||
    _optionsAppearance: <AppearanceSettings />,
 | 
			
		||||
    _optionsShortcuts: <ShortcutSettings />,
 | 
			
		||||
    _optionsTextNotes: <TextNoteSettings />,
 | 
			
		||||
    _optionsCodeNotes: <CodeNoteSettings />,
 | 
			
		||||
    _optionsCKEditorPlugins: <CKEditorPluginSettings />,
 | 
			
		||||
    _optionsImages: <ImageSettings />,
 | 
			
		||||
    _optionsSpellcheck: <SpellcheckSettings />,
 | 
			
		||||
    _optionsPassword: <PasswordSettings />,
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,397 @@
 | 
			
		||||
import { useEffect, useState, useCallback, useMemo } from "preact/hooks";
 | 
			
		||||
import { t } from "../../../services/i18n";
 | 
			
		||||
import server from "../../../services/server";
 | 
			
		||||
import FormCheckbox from "../../react/FormCheckbox";
 | 
			
		||||
import FormGroup from "../../react/FormGroup";
 | 
			
		||||
import FormText from "../../react/FormText";
 | 
			
		||||
import OptionsSection from "./components/OptionsSection";
 | 
			
		||||
import Button from "../../react/Button";
 | 
			
		||||
import toast from "../../../services/toast";
 | 
			
		||||
import type { 
 | 
			
		||||
    PluginMetadata, 
 | 
			
		||||
    PluginConfiguration, 
 | 
			
		||||
    PluginRegistry,
 | 
			
		||||
    PluginValidationResult,
 | 
			
		||||
    UpdatePluginConfigRequest,
 | 
			
		||||
    UpdatePluginConfigResponse,
 | 
			
		||||
    QueryPluginsResult,
 | 
			
		||||
    PluginCategory
 | 
			
		||||
} from "@triliumnext/commons";
 | 
			
		||||
 | 
			
		||||
interface PluginStats {
 | 
			
		||||
    enabled: number;
 | 
			
		||||
    total: number;
 | 
			
		||||
    core: number;
 | 
			
		||||
    premium: number;
 | 
			
		||||
    configurable: number;
 | 
			
		||||
    categories: Record<string, number>;
 | 
			
		||||
    hasPremiumLicense: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const CATEGORY_DISPLAY_NAMES: Record<PluginCategory, string> = {
 | 
			
		||||
    formatting: t("ckeditor_plugins.category_formatting"),
 | 
			
		||||
    structure: t("ckeditor_plugins.category_structure"),
 | 
			
		||||
    media: t("ckeditor_plugins.category_media"),
 | 
			
		||||
    tables: t("ckeditor_plugins.category_tables"),
 | 
			
		||||
    advanced: t("ckeditor_plugins.category_advanced"),
 | 
			
		||||
    trilium: t("ckeditor_plugins.category_trilium"),
 | 
			
		||||
    external: t("ckeditor_plugins.category_external")
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function CKEditorPluginSettings() {
 | 
			
		||||
    const [pluginRegistry, setPluginRegistry] = useState<PluginRegistry | null>(null);
 | 
			
		||||
    const [userConfig, setUserConfig] = useState<PluginConfiguration[]>([]);
 | 
			
		||||
    const [stats, setStats] = useState<PluginStats | null>(null);
 | 
			
		||||
    const [loading, setLoading] = useState(true);
 | 
			
		||||
    const [saving, setSaving] = useState(false);
 | 
			
		||||
    const [validationResult, setValidationResult] = useState<PluginValidationResult | null>(null);
 | 
			
		||||
    const [showValidation, setShowValidation] = useState(false);
 | 
			
		||||
 | 
			
		||||
    // Load initial data
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        loadData();
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const loadData = useCallback(async () => {
 | 
			
		||||
        setLoading(true);
 | 
			
		||||
        try {
 | 
			
		||||
            const [registry, config, statsData] = await Promise.all([
 | 
			
		||||
                server.get<PluginRegistry>('ckeditor-plugins/registry'),
 | 
			
		||||
                server.get<PluginConfiguration[]>('ckeditor-plugins/config'),
 | 
			
		||||
                server.get<PluginStats>('ckeditor-plugins/stats')
 | 
			
		||||
            ]);
 | 
			
		||||
            
 | 
			
		||||
            setPluginRegistry(registry);
 | 
			
		||||
            setUserConfig(config);
 | 
			
		||||
            setStats(statsData);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            toast.showError(`${t("ckeditor_plugins.load_error")}: ${error}`);
 | 
			
		||||
        } finally {
 | 
			
		||||
            setLoading(false);
 | 
			
		||||
        }
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    // Organize plugins by category
 | 
			
		||||
    const pluginsByCategory = useMemo(() => {
 | 
			
		||||
        if (!pluginRegistry) return {};
 | 
			
		||||
        
 | 
			
		||||
        const categories: Record<PluginCategory, PluginMetadata[]> = {
 | 
			
		||||
            formatting: [],
 | 
			
		||||
            structure: [],
 | 
			
		||||
            media: [],
 | 
			
		||||
            tables: [],
 | 
			
		||||
            advanced: [],
 | 
			
		||||
            trilium: [],
 | 
			
		||||
            external: []
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        Object.values(pluginRegistry.plugins).forEach(plugin => {
 | 
			
		||||
            if (!plugin.isCore) { // Don't show core plugins in settings
 | 
			
		||||
                categories[plugin.category].push(plugin);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Sort plugins within each category by name
 | 
			
		||||
        Object.keys(categories).forEach(category => {
 | 
			
		||||
            categories[category as PluginCategory].sort((a, b) => a.name.localeCompare(b.name));
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return categories;
 | 
			
		||||
    }, [pluginRegistry]);
 | 
			
		||||
 | 
			
		||||
    // Get enabled status for a plugin
 | 
			
		||||
    const isPluginEnabled = useCallback((pluginId: string): boolean => {
 | 
			
		||||
        return userConfig.find(config => config.id === pluginId)?.enabled ?? false;
 | 
			
		||||
    }, [userConfig]);
 | 
			
		||||
 | 
			
		||||
    // Toggle plugin enabled state
 | 
			
		||||
    const togglePlugin = useCallback((pluginId: string) => {
 | 
			
		||||
        setUserConfig(prev => prev.map(config => 
 | 
			
		||||
            config.id === pluginId 
 | 
			
		||||
                ? { ...config, enabled: !config.enabled }
 | 
			
		||||
                : config
 | 
			
		||||
        ));
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    // Validate current configuration
 | 
			
		||||
    const validateConfig = useCallback(async () => {
 | 
			
		||||
        if (!userConfig.length) return;
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            const result = await server.post<PluginValidationResult>('ckeditor-plugins/validate', {
 | 
			
		||||
                plugins: userConfig
 | 
			
		||||
            });
 | 
			
		||||
            setValidationResult(result);
 | 
			
		||||
            setShowValidation(true);
 | 
			
		||||
            return result;
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            toast.showError(`${t("ckeditor_plugins.validation_error")}: ${error}`);
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
    }, [userConfig]);
 | 
			
		||||
 | 
			
		||||
    // Save configuration
 | 
			
		||||
    const saveConfiguration = useCallback(async () => {
 | 
			
		||||
        setSaving(true);
 | 
			
		||||
        setShowValidation(false);
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            const request: UpdatePluginConfigRequest = {
 | 
			
		||||
                plugins: userConfig,
 | 
			
		||||
                validate: true
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            const response = await server.put<UpdatePluginConfigResponse>('ckeditor-plugins/config', request);
 | 
			
		||||
            
 | 
			
		||||
            if (response.success) {
 | 
			
		||||
                toast.showMessage(t("ckeditor_plugins.save_success"));
 | 
			
		||||
                await loadData(); // Reload stats
 | 
			
		||||
                
 | 
			
		||||
                // Notify user that editor reload might be needed
 | 
			
		||||
                toast.showMessage(t("ckeditor_plugins.reload_editor_notice"), {
 | 
			
		||||
                    timeout: 5000
 | 
			
		||||
                });
 | 
			
		||||
            } else {
 | 
			
		||||
                setValidationResult(response.validation);
 | 
			
		||||
                setShowValidation(true);
 | 
			
		||||
                toast.showError(`${t("ckeditor_plugins.save_error")}: ${response.errors?.join(", ")}`);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            toast.showError(`${t("ckeditor_plugins.save_error")}: ${error}`);
 | 
			
		||||
        } finally {
 | 
			
		||||
            setSaving(false);
 | 
			
		||||
        }
 | 
			
		||||
    }, [userConfig, loadData]);
 | 
			
		||||
 | 
			
		||||
    // Reset to defaults
 | 
			
		||||
    const resetToDefaults = useCallback(async () => {
 | 
			
		||||
        if (!confirm(t("ckeditor_plugins.reset_confirm"))) return;
 | 
			
		||||
        
 | 
			
		||||
        setSaving(true);
 | 
			
		||||
        try {
 | 
			
		||||
            const response = await server.post<UpdatePluginConfigResponse>('ckeditor-plugins/reset');
 | 
			
		||||
            if (response.success) {
 | 
			
		||||
                setUserConfig(response.plugins);
 | 
			
		||||
                toast.showMessage(t("ckeditor_plugins.reset_success"));
 | 
			
		||||
                await loadData();
 | 
			
		||||
            } else {
 | 
			
		||||
                toast.showError(`${t("ckeditor_plugins.reset_error")}: ${response.errors?.join(", ")}`);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            toast.showError(`${t("ckeditor_plugins.reset_error")}: ${error}`);
 | 
			
		||||
        } finally {
 | 
			
		||||
            setSaving(false);
 | 
			
		||||
        }
 | 
			
		||||
    }, [loadData]);
 | 
			
		||||
 | 
			
		||||
    if (loading) {
 | 
			
		||||
        return (
 | 
			
		||||
            <OptionsSection title={t("ckeditor_plugins.title")}>
 | 
			
		||||
                <FormText>{t("ckeditor_plugins.loading")}</FormText>
 | 
			
		||||
            </OptionsSection>
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!pluginRegistry || !stats) {
 | 
			
		||||
        return (
 | 
			
		||||
            <OptionsSection title={t("ckeditor_plugins.title")}>
 | 
			
		||||
                <FormText>{t("ckeditor_plugins.load_failed")}</FormText>
 | 
			
		||||
                <Button text={t("ckeditor_plugins.retry")} onClick={loadData} />
 | 
			
		||||
            </OptionsSection>
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <div>
 | 
			
		||||
            <OptionsSection title={t("ckeditor_plugins.title")}>
 | 
			
		||||
                <FormText>{t("ckeditor_plugins.description")}</FormText>
 | 
			
		||||
                
 | 
			
		||||
                {/* Stats overview */}
 | 
			
		||||
                <div className="plugin-stats" style={{ 
 | 
			
		||||
                    backgroundColor: 'var(--accented-background-color)', 
 | 
			
		||||
                    padding: '12px', 
 | 
			
		||||
                    borderRadius: '4px',
 | 
			
		||||
                    marginBottom: '20px'
 | 
			
		||||
                }}>
 | 
			
		||||
                    <div className="row">
 | 
			
		||||
                        <div className="col-md-3">
 | 
			
		||||
                            <strong>{t("ckeditor_plugins.stats_enabled")}</strong><br />
 | 
			
		||||
                            <span style={{ fontSize: '1.2em', color: 'var(--main-text-color)' }}>
 | 
			
		||||
                                {stats.enabled}/{stats.configurable}
 | 
			
		||||
                            </span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div className="col-md-3">
 | 
			
		||||
                            <strong>{t("ckeditor_plugins.stats_total")}</strong><br />
 | 
			
		||||
                            <span style={{ fontSize: '1.2em', color: 'var(--main-text-color)' }}>
 | 
			
		||||
                                {stats.total}
 | 
			
		||||
                            </span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div className="col-md-3">
 | 
			
		||||
                            <strong>{t("ckeditor_plugins.stats_core")}</strong><br />
 | 
			
		||||
                            <span style={{ fontSize: '1.2em', color: 'var(--main-text-color)' }}>
 | 
			
		||||
                                {stats.core}
 | 
			
		||||
                            </span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div className="col-md-3">
 | 
			
		||||
                            <strong>{t("ckeditor_plugins.stats_premium")}</strong><br />
 | 
			
		||||
                            <span style={{ fontSize: '1.2em', color: stats.hasPremiumLicense ? 'var(--success-color)' : 'var(--muted-text-color)' }}>
 | 
			
		||||
                                {stats.premium} {!stats.hasPremiumLicense && `(${t("ckeditor_plugins.no_license")})`}
 | 
			
		||||
                            </span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                {/* Validation results */}
 | 
			
		||||
                {showValidation && validationResult && (
 | 
			
		||||
                    <div className="validation-results" style={{ marginBottom: '20px' }}>
 | 
			
		||||
                        {!validationResult.valid && (
 | 
			
		||||
                            <div className="alert alert-danger">
 | 
			
		||||
                                <strong>{t("ckeditor_plugins.validation_errors")}</strong>
 | 
			
		||||
                                <ul>
 | 
			
		||||
                                    {validationResult.errors.map((error, index) => (
 | 
			
		||||
                                        <li key={index}>{error.message}</li>
 | 
			
		||||
                                    ))}
 | 
			
		||||
                                </ul>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        )}
 | 
			
		||||
                        {validationResult.warnings.length > 0 && (
 | 
			
		||||
                            <div className="alert alert-warning">
 | 
			
		||||
                                <strong>{t("ckeditor_plugins.validation_warnings")}</strong>
 | 
			
		||||
                                <ul>
 | 
			
		||||
                                    {validationResult.warnings.map((warning, index) => (
 | 
			
		||||
                                        <li key={index}>{warning.message}</li>
 | 
			
		||||
                                    ))}
 | 
			
		||||
                                </ul>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        )}
 | 
			
		||||
                    </div>
 | 
			
		||||
                )}
 | 
			
		||||
 | 
			
		||||
                {/* Action buttons */}
 | 
			
		||||
                <div className="plugin-actions" style={{ marginBottom: '20px' }}>
 | 
			
		||||
                    <Button 
 | 
			
		||||
                        text={t("ckeditor_plugins.validate")} 
 | 
			
		||||
                        onClick={validateConfig}
 | 
			
		||||
                        disabled={saving}
 | 
			
		||||
                        className="btn-secondary"
 | 
			
		||||
                        size="small"
 | 
			
		||||
                    />
 | 
			
		||||
                    <Button 
 | 
			
		||||
                        text={t("ckeditor_plugins.save")} 
 | 
			
		||||
                        onClick={saveConfiguration}
 | 
			
		||||
                        disabled={saving}
 | 
			
		||||
                        className="btn-primary"
 | 
			
		||||
                        size="small"
 | 
			
		||||
                        style={{ marginLeft: '10px' }}
 | 
			
		||||
                    />
 | 
			
		||||
                    <Button 
 | 
			
		||||
                        text={t("ckeditor_plugins.reset_defaults")} 
 | 
			
		||||
                        onClick={resetToDefaults}
 | 
			
		||||
                        disabled={saving}
 | 
			
		||||
                        className="btn-secondary"
 | 
			
		||||
                        size="small"
 | 
			
		||||
                        style={{ marginLeft: '10px' }}
 | 
			
		||||
                    />
 | 
			
		||||
                </div>
 | 
			
		||||
            </OptionsSection>
 | 
			
		||||
 | 
			
		||||
            {/* Plugin categories */}
 | 
			
		||||
            {Object.entries(pluginsByCategory).map(([categoryKey, plugins]) => {
 | 
			
		||||
                if (plugins.length === 0) return null;
 | 
			
		||||
                
 | 
			
		||||
                const category = categoryKey as PluginCategory;
 | 
			
		||||
                return (
 | 
			
		||||
                    <OptionsSection key={category} title={CATEGORY_DISPLAY_NAMES[category]} level={2}>
 | 
			
		||||
                        <div className="plugin-category">
 | 
			
		||||
                            {plugins.map(plugin => (
 | 
			
		||||
                                <PluginConfigItem 
 | 
			
		||||
                                    key={plugin.id}
 | 
			
		||||
                                    plugin={plugin}
 | 
			
		||||
                                    enabled={isPluginEnabled(plugin.id)}
 | 
			
		||||
                                    onToggle={() => togglePlugin(plugin.id)}
 | 
			
		||||
                                    hasPremiumLicense={stats.hasPremiumLicense}
 | 
			
		||||
                                />
 | 
			
		||||
                            ))}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </OptionsSection>
 | 
			
		||||
                );
 | 
			
		||||
            })}
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface PluginConfigItemProps {
 | 
			
		||||
    plugin: PluginMetadata;
 | 
			
		||||
    enabled: boolean;
 | 
			
		||||
    onToggle: () => void;
 | 
			
		||||
    hasPremiumLicense: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function PluginConfigItem({ plugin, enabled, onToggle, hasPremiumLicense }: PluginConfigItemProps) {
 | 
			
		||||
    const canEnable = !plugin.requiresPremium || hasPremiumLicense;
 | 
			
		||||
    
 | 
			
		||||
    return (
 | 
			
		||||
        <FormGroup name={`plugin-${plugin.id}`} style={{ marginBottom: '15px' }}>
 | 
			
		||||
            <div className="plugin-item" style={{
 | 
			
		||||
                display: 'flex',
 | 
			
		||||
                alignItems: 'flex-start',
 | 
			
		||||
                opacity: canEnable ? 1 : 0.6
 | 
			
		||||
            }}>
 | 
			
		||||
                <FormCheckbox
 | 
			
		||||
                    label=""
 | 
			
		||||
                    currentValue={enabled && canEnable}
 | 
			
		||||
                    onChange={canEnable ? onToggle : undefined}
 | 
			
		||||
                    disabled={!canEnable}
 | 
			
		||||
                    containerStyle={{ marginRight: '10px', marginTop: '2px' }}
 | 
			
		||||
                />
 | 
			
		||||
                <div style={{ flex: 1 }}>
 | 
			
		||||
                    <div style={{ 
 | 
			
		||||
                        fontWeight: 'bold',
 | 
			
		||||
                        marginBottom: '4px',
 | 
			
		||||
                        display: 'flex',
 | 
			
		||||
                        alignItems: 'center',
 | 
			
		||||
                        gap: '8px'
 | 
			
		||||
                    }}>
 | 
			
		||||
                        <span>{plugin.name}</span>
 | 
			
		||||
                        {plugin.requiresPremium && (
 | 
			
		||||
                            <span className="badge badge-warning" style={{ fontSize: '0.75em' }}>
 | 
			
		||||
                                {t("ckeditor_plugins.premium")}
 | 
			
		||||
                            </span>
 | 
			
		||||
                        )}
 | 
			
		||||
                        {plugin.dependencies.length > 0 && (
 | 
			
		||||
                            <span className="badge badge-info" style={{ fontSize: '0.75em' }}>
 | 
			
		||||
                                {t("ckeditor_plugins.has_dependencies")}
 | 
			
		||||
                            </span>
 | 
			
		||||
                        )}
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div style={{ 
 | 
			
		||||
                        fontSize: '0.9em', 
 | 
			
		||||
                        color: 'var(--muted-text-color)',
 | 
			
		||||
                        marginBottom: '4px'
 | 
			
		||||
                    }}>
 | 
			
		||||
                        {plugin.description}
 | 
			
		||||
                    </div>
 | 
			
		||||
                    {plugin.dependencies.length > 0 && (
 | 
			
		||||
                        <div style={{ fontSize: '0.8em', color: 'var(--muted-text-color)' }}>
 | 
			
		||||
                            {t("ckeditor_plugins.depends_on")}: {plugin.dependencies.join(', ')}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    )}
 | 
			
		||||
                    {plugin.toolbarItems && plugin.toolbarItems.length > 0 && (
 | 
			
		||||
                        <div style={{ fontSize: '0.8em', color: 'var(--muted-text-color)' }}>
 | 
			
		||||
                            {t("ckeditor_plugins.toolbar_items")}: {plugin.toolbarItems.join(', ')}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    )}
 | 
			
		||||
                    {!canEnable && (
 | 
			
		||||
                        <div style={{ 
 | 
			
		||||
                            fontSize: '0.8em', 
 | 
			
		||||
                            color: 'var(--error-color)',
 | 
			
		||||
                            fontStyle: 'italic'
 | 
			
		||||
                        }}>
 | 
			
		||||
                            {t("ckeditor_plugins.premium_required")}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    )}
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </FormGroup>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										388
									
								
								apps/server/src/routes/api/ckeditor_plugins.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										388
									
								
								apps/server/src/routes/api/ckeditor_plugins.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,388 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @module CKEditor Plugins API
 | 
			
		||||
 * 
 | 
			
		||||
 * This module provides REST endpoints for managing CKEditor plugin configuration
 | 
			
		||||
 * in Trilium Notes. It handles plugin enablement, validation, and user preferences.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import options from "../../services/options.js";
 | 
			
		||||
import type { Request, Response } from "express";
 | 
			
		||||
import type { 
 | 
			
		||||
    PluginConfiguration, 
 | 
			
		||||
    PluginRegistry, 
 | 
			
		||||
    PluginValidationResult, 
 | 
			
		||||
    UpdatePluginConfigRequest,
 | 
			
		||||
    UpdatePluginConfigResponse,
 | 
			
		||||
    QueryPluginsOptions,
 | 
			
		||||
    QueryPluginsResult,
 | 
			
		||||
    PluginMetadata
 | 
			
		||||
} from "@triliumnext/commons";
 | 
			
		||||
import { PLUGIN_REGISTRY, getPluginMetadata, getPluginsByCategory, getConfigurablePlugins } from "@triliumnext/ckeditor5";
 | 
			
		||||
import log from "../../services/log.js";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get the complete plugin registry with metadata
 | 
			
		||||
 */
 | 
			
		||||
export function getPluginRegistry(): PluginRegistry {
 | 
			
		||||
    return PLUGIN_REGISTRY;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get current user's plugin configuration
 | 
			
		||||
 */
 | 
			
		||||
export function getUserPluginConfig(): PluginConfiguration[] {
 | 
			
		||||
    const enabledPluginsJson = options.getOptionOrNull("ckeditorEnabledPlugins");
 | 
			
		||||
    
 | 
			
		||||
    if (!enabledPluginsJson) {
 | 
			
		||||
        // Return default configuration if none exists
 | 
			
		||||
        return getDefaultPluginConfiguration();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        const enabledPlugins = JSON.parse(enabledPluginsJson) as string[];
 | 
			
		||||
        
 | 
			
		||||
        // Convert to PluginConfiguration array
 | 
			
		||||
        return Object.keys(PLUGIN_REGISTRY.plugins).map(pluginId => ({
 | 
			
		||||
            id: pluginId,
 | 
			
		||||
            enabled: enabledPlugins.includes(pluginId)
 | 
			
		||||
        }));
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        log.error(`Failed to parse CKEditor plugin configuration: ${error}`);
 | 
			
		||||
        return getDefaultPluginConfiguration();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get default plugin configuration (all non-premium plugins enabled)
 | 
			
		||||
 */
 | 
			
		||||
function getDefaultPluginConfiguration(): PluginConfiguration[] {
 | 
			
		||||
    return Object.values(PLUGIN_REGISTRY.plugins).map(plugin => ({
 | 
			
		||||
        id: plugin.id,
 | 
			
		||||
        enabled: plugin.defaultEnabled && !plugin.requiresPremium
 | 
			
		||||
    }));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Update user's plugin configuration
 | 
			
		||||
 */
 | 
			
		||||
export function updateUserPluginConfig(request: UpdatePluginConfigRequest): UpdatePluginConfigResponse {
 | 
			
		||||
    const { plugins, validate = true } = request;
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
        // Validate if requested
 | 
			
		||||
        let validation: PluginValidationResult | undefined;
 | 
			
		||||
        if (validate) {
 | 
			
		||||
            validation = validatePluginConfiguration(plugins);
 | 
			
		||||
            if (!validation.valid) {
 | 
			
		||||
                return {
 | 
			
		||||
                    success: false,
 | 
			
		||||
                    validation,
 | 
			
		||||
                    plugins: [],
 | 
			
		||||
                    errors: validation.errors.map(err => err.message)
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Save configuration
 | 
			
		||||
        const enabledPluginIds = plugins
 | 
			
		||||
            .filter(plugin => plugin.enabled)
 | 
			
		||||
            .map(plugin => plugin.id);
 | 
			
		||||
 | 
			
		||||
        options.setOption("ckeditorEnabledPlugins", JSON.stringify(enabledPluginIds));
 | 
			
		||||
 | 
			
		||||
        log.info(`Updated CKEditor plugin configuration: ${enabledPluginIds.length} plugins enabled`);
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            success: true,
 | 
			
		||||
            validation,
 | 
			
		||||
            plugins: plugins,
 | 
			
		||||
            errors: []
 | 
			
		||||
        };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        log.error(`Failed to update CKEditor plugin configuration: ${error}`);
 | 
			
		||||
        return {
 | 
			
		||||
            success: false,
 | 
			
		||||
            plugins: [],
 | 
			
		||||
            errors: [`Failed to update configuration: ${error}`]
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Query plugins with filtering options
 | 
			
		||||
 */
 | 
			
		||||
export function queryPlugins(options: QueryPluginsOptions = {}): QueryPluginsResult {
 | 
			
		||||
    const { category, enabled, coreOnly, includeConfig } = options;
 | 
			
		||||
    
 | 
			
		||||
    let plugins = Object.values(PLUGIN_REGISTRY.plugins);
 | 
			
		||||
 | 
			
		||||
    // Apply filters
 | 
			
		||||
    if (category) {
 | 
			
		||||
        plugins = plugins.filter(plugin => plugin.category === category);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (coreOnly === true) {
 | 
			
		||||
        plugins = plugins.filter(plugin => plugin.isCore);
 | 
			
		||||
    } else if (coreOnly === false) {
 | 
			
		||||
        plugins = plugins.filter(plugin => !plugin.isCore);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Get user configuration if requested or filtering by enabled status
 | 
			
		||||
    let userConfig: PluginConfiguration[] = [];
 | 
			
		||||
    if (includeConfig || enabled !== undefined) {
 | 
			
		||||
        userConfig = getUserPluginConfig();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Filter by enabled status
 | 
			
		||||
    if (enabled !== undefined) {
 | 
			
		||||
        const enabledPluginIds = new Set(
 | 
			
		||||
            userConfig.filter(config => config.enabled).map(config => config.id)
 | 
			
		||||
        );
 | 
			
		||||
        plugins = plugins.filter(plugin => 
 | 
			
		||||
            enabled ? enabledPluginIds.has(plugin.id) : !enabledPluginIds.has(plugin.id)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Add user configuration if requested
 | 
			
		||||
    const result = plugins.map(plugin => {
 | 
			
		||||
        if (includeConfig) {
 | 
			
		||||
            const config = userConfig.find(config => config.id === plugin.id);
 | 
			
		||||
            return {
 | 
			
		||||
                ...plugin,
 | 
			
		||||
                enabled: config?.enabled ?? false,
 | 
			
		||||
                config: config?.config
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        return plugin;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Get available categories
 | 
			
		||||
    const categories = [...new Set(Object.values(PLUGIN_REGISTRY.plugins).map(plugin => plugin.category))];
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        plugins: result,
 | 
			
		||||
        totalCount: Object.keys(PLUGIN_REGISTRY.plugins).length,
 | 
			
		||||
        categories: categories as any[]
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validate plugin configuration for dependencies and conflicts
 | 
			
		||||
 */
 | 
			
		||||
export function validatePluginConfiguration(plugins: PluginConfiguration[]): PluginValidationResult {
 | 
			
		||||
    const errors: any[] = [];
 | 
			
		||||
    const warnings: any[] = [];
 | 
			
		||||
    const enabledPlugins = new Set(plugins.filter(p => p.enabled).map(p => p.id));
 | 
			
		||||
    const resolvedPlugins = new Set<string>();
 | 
			
		||||
 | 
			
		||||
    // Check each enabled plugin
 | 
			
		||||
    for (const plugin of plugins.filter(p => p.enabled)) {
 | 
			
		||||
        const metadata = getPluginMetadata(plugin.id);
 | 
			
		||||
        
 | 
			
		||||
        if (!metadata) {
 | 
			
		||||
            errors.push({
 | 
			
		||||
                type: "missing_dependency",
 | 
			
		||||
                pluginId: plugin.id,
 | 
			
		||||
                message: `Plugin '${plugin.id}' not found in registry`
 | 
			
		||||
            });
 | 
			
		||||
            continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check premium requirements
 | 
			
		||||
        if (metadata.requiresPremium && !hasPremiumLicense()) {
 | 
			
		||||
            errors.push({
 | 
			
		||||
                type: "premium_required",
 | 
			
		||||
                pluginId: plugin.id,
 | 
			
		||||
                message: `Plugin '${metadata.name}' requires a premium CKEditor license`
 | 
			
		||||
            });
 | 
			
		||||
            continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check dependencies
 | 
			
		||||
        for (const depId of metadata.dependencies) {
 | 
			
		||||
            if (!enabledPlugins.has(depId)) {
 | 
			
		||||
                const depMetadata = getPluginMetadata(depId);
 | 
			
		||||
                errors.push({
 | 
			
		||||
                    type: "missing_dependency",
 | 
			
		||||
                    pluginId: plugin.id,
 | 
			
		||||
                    message: `Plugin '${metadata.name}' requires '${depMetadata?.name || depId}' to be enabled`,
 | 
			
		||||
                    details: { dependency: depId }
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check conflicts
 | 
			
		||||
        for (const conflictId of metadata.conflicts) {
 | 
			
		||||
            if (enabledPlugins.has(conflictId)) {
 | 
			
		||||
                const conflictMetadata = getPluginMetadata(conflictId);
 | 
			
		||||
                errors.push({
 | 
			
		||||
                    type: "plugin_conflict",
 | 
			
		||||
                    pluginId: plugin.id,
 | 
			
		||||
                    message: `Plugin '${metadata.name}' conflicts with '${conflictMetadata?.name || conflictId}'`,
 | 
			
		||||
                    details: { conflict: conflictId }
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        resolvedPlugins.add(plugin.id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Add core plugins to resolved list (they're always enabled)
 | 
			
		||||
    for (const plugin of Object.values(PLUGIN_REGISTRY.plugins)) {
 | 
			
		||||
        if (plugin.isCore) {
 | 
			
		||||
            resolvedPlugins.add(plugin.id);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check for circular dependencies (simplified check)
 | 
			
		||||
    const visited = new Set<string>();
 | 
			
		||||
    const visiting = new Set<string>();
 | 
			
		||||
 | 
			
		||||
    function hasCircularDependency(pluginId: string): boolean {
 | 
			
		||||
        if (visiting.has(pluginId)) {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        if (visited.has(pluginId)) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        visiting.add(pluginId);
 | 
			
		||||
        const metadata = getPluginMetadata(pluginId);
 | 
			
		||||
        
 | 
			
		||||
        if (metadata) {
 | 
			
		||||
            for (const depId of metadata.dependencies) {
 | 
			
		||||
                if (enabledPlugins.has(depId) && hasCircularDependency(depId)) {
 | 
			
		||||
                    return true;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        visiting.delete(pluginId);
 | 
			
		||||
        visited.add(pluginId);
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (const pluginId of enabledPlugins) {
 | 
			
		||||
        if (hasCircularDependency(pluginId)) {
 | 
			
		||||
            errors.push({
 | 
			
		||||
                type: "circular_dependency",
 | 
			
		||||
                pluginId: pluginId,
 | 
			
		||||
                message: `Circular dependency detected for plugin '${getPluginMetadata(pluginId)?.name || pluginId}'`
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        valid: errors.length === 0,
 | 
			
		||||
        errors,
 | 
			
		||||
        warnings,
 | 
			
		||||
        resolvedPlugins: Array.from(resolvedPlugins)
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if user has premium CKEditor license
 | 
			
		||||
 */
 | 
			
		||||
function hasPremiumLicense(): boolean {
 | 
			
		||||
    // This would check the actual license key
 | 
			
		||||
    // For now, assume no premium license
 | 
			
		||||
    return process.env.VITE_CKEDITOR_KEY !== undefined && process.env.VITE_CKEDITOR_KEY !== "";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Reset plugin configuration to defaults
 | 
			
		||||
 */
 | 
			
		||||
export function resetPluginConfigToDefaults(): UpdatePluginConfigResponse {
 | 
			
		||||
    const defaultConfig = getDefaultPluginConfiguration();
 | 
			
		||||
    
 | 
			
		||||
    return updateUserPluginConfig({
 | 
			
		||||
        plugins: defaultConfig,
 | 
			
		||||
        validate: false
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get plugin statistics
 | 
			
		||||
 */
 | 
			
		||||
export function getPluginStats() {
 | 
			
		||||
    const userConfig = getUserPluginConfig();
 | 
			
		||||
    const enabledCount = userConfig.filter(p => p.enabled).length;
 | 
			
		||||
    const totalCount = Object.keys(PLUGIN_REGISTRY.plugins).length;
 | 
			
		||||
    const coreCount = Object.values(PLUGIN_REGISTRY.plugins).filter(p => p.isCore).length;
 | 
			
		||||
    const premiumCount = Object.values(PLUGIN_REGISTRY.plugins).filter(p => p.requiresPremium).length;
 | 
			
		||||
 | 
			
		||||
    const categoryCounts = Object.values(PLUGIN_REGISTRY.plugins).reduce((acc, plugin) => {
 | 
			
		||||
        acc[plugin.category] = (acc[plugin.category] || 0) + 1;
 | 
			
		||||
        return acc;
 | 
			
		||||
    }, {} as Record<string, number>);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        enabled: enabledCount,
 | 
			
		||||
        total: totalCount,
 | 
			
		||||
        core: coreCount,
 | 
			
		||||
        premium: premiumCount,
 | 
			
		||||
        configurable: totalCount - coreCount,
 | 
			
		||||
        categories: categoryCounts,
 | 
			
		||||
        hasPremiumLicense: hasPremiumLicense()
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Express route handlers
 | 
			
		||||
function getPluginRegistryHandler(req: Request, res: Response) {
 | 
			
		||||
    res.json(getPluginRegistry());
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getUserPluginConfigHandler(req: Request, res: Response) {
 | 
			
		||||
    res.json(getUserPluginConfig());
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function updateUserPluginConfigHandler(req: Request, res: Response) {
 | 
			
		||||
    const updateRequest: UpdatePluginConfigRequest = req.body;
 | 
			
		||||
    const result = updateUserPluginConfig(updateRequest);
 | 
			
		||||
    
 | 
			
		||||
    if (!result.success) {
 | 
			
		||||
        res.status(400).json(result);
 | 
			
		||||
    } else {
 | 
			
		||||
        res.json(result);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function queryPluginsHandler(req: Request, res: Response) {
 | 
			
		||||
    const queryOptions: QueryPluginsOptions = {
 | 
			
		||||
        category: req.query.category as string,
 | 
			
		||||
        enabled: req.query.enabled === 'true' ? true : req.query.enabled === 'false' ? false : undefined,
 | 
			
		||||
        coreOnly: req.query.coreOnly === 'true' ? true : req.query.coreOnly === 'false' ? false : undefined,
 | 
			
		||||
        includeConfig: req.query.includeConfig === 'true'
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    res.json(queryPlugins(queryOptions));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function validatePluginConfigurationHandler(req: Request, res: Response) {
 | 
			
		||||
    const plugins: PluginConfiguration[] = req.body.plugins || [];
 | 
			
		||||
    res.json(validatePluginConfiguration(plugins));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function resetPluginConfigToDefaultsHandler(req: Request, res: Response) {
 | 
			
		||||
    const result = resetPluginConfigToDefaults();
 | 
			
		||||
    
 | 
			
		||||
    if (!result.success) {
 | 
			
		||||
        res.status(400).json(result);
 | 
			
		||||
    } else {
 | 
			
		||||
        res.json(result);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getPluginStatsHandler(req: Request, res: Response) {
 | 
			
		||||
    res.json(getPluginStats());
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    getPluginRegistry: getPluginRegistryHandler,
 | 
			
		||||
    getUserPluginConfig: getUserPluginConfigHandler,
 | 
			
		||||
    updateUserPluginConfig: updateUserPluginConfigHandler,
 | 
			
		||||
    queryPlugins: queryPluginsHandler,
 | 
			
		||||
    validatePluginConfiguration: validatePluginConfigurationHandler,
 | 
			
		||||
    resetPluginConfigToDefaults: resetPluginConfigToDefaultsHandler,
 | 
			
		||||
    getPluginStats: getPluginStatsHandler
 | 
			
		||||
};
 | 
			
		||||
@@ -97,6 +97,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
 | 
			
		||||
    "showLoginInShareTheme",
 | 
			
		||||
    "splitEditorOrientation",
 | 
			
		||||
    "seenCallToActions",
 | 
			
		||||
    "ckeditorEnabledPlugins",
 | 
			
		||||
 | 
			
		||||
    // AI/LLM integration options
 | 
			
		||||
    "aiEnabled",
 | 
			
		||||
 
 | 
			
		||||
@@ -59,6 +59,7 @@ import openaiRoute from "./api/openai.js";
 | 
			
		||||
import anthropicRoute from "./api/anthropic.js";
 | 
			
		||||
import llmRoute from "./api/llm.js";
 | 
			
		||||
import systemInfoRoute from "./api/system_info.js";
 | 
			
		||||
import ckeditorPluginsRoute from "./api/ckeditor_plugins.js";
 | 
			
		||||
 | 
			
		||||
import etapiAuthRoutes from "../etapi/auth.js";
 | 
			
		||||
import etapiAppInfoRoutes from "../etapi/app_info.js";
 | 
			
		||||
@@ -221,6 +222,15 @@ function register(app: express.Application) {
 | 
			
		||||
    apiRoute(GET, "/api/options/user-themes", optionsApiRoute.getUserThemes);
 | 
			
		||||
    apiRoute(GET, "/api/options/locales", optionsApiRoute.getSupportedLocales);
 | 
			
		||||
    
 | 
			
		||||
    // CKEditor plugins management
 | 
			
		||||
    apiRoute(GET, "/api/ckeditor-plugins/registry", ckeditorPluginsRoute.getPluginRegistry);
 | 
			
		||||
    apiRoute(GET, "/api/ckeditor-plugins/config", ckeditorPluginsRoute.getUserPluginConfig);
 | 
			
		||||
    apiRoute(PUT, "/api/ckeditor-plugins/config", ckeditorPluginsRoute.updateUserPluginConfig);
 | 
			
		||||
    apiRoute(GET, "/api/ckeditor-plugins/query", ckeditorPluginsRoute.queryPlugins);
 | 
			
		||||
    apiRoute(PST, "/api/ckeditor-plugins/validate", ckeditorPluginsRoute.validatePluginConfiguration);
 | 
			
		||||
    apiRoute(PST, "/api/ckeditor-plugins/reset", ckeditorPluginsRoute.resetPluginConfigToDefaults);
 | 
			
		||||
    apiRoute(GET, "/api/ckeditor-plugins/stats", ckeditorPluginsRoute.getPluginStats);
 | 
			
		||||
 | 
			
		||||
    apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword);
 | 
			
		||||
    apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ import log from "./log.js";
 | 
			
		||||
import dateUtils from "./date_utils.js";
 | 
			
		||||
import keyboardActions from "./keyboard_actions.js";
 | 
			
		||||
import { SANITIZER_DEFAULT_ALLOWED_TAGS, type KeyboardShortcutWithRequiredActionName, type OptionMap, type OptionNames } from "@triliumnext/commons";
 | 
			
		||||
import { getDefaultPluginConfiguration } from "@triliumnext/ckeditor5";
 | 
			
		||||
 | 
			
		||||
function initDocumentOptions() {
 | 
			
		||||
    optionService.createOption("documentId", randomSecureToken(16), false);
 | 
			
		||||
@@ -185,6 +186,9 @@ const defaultOptions: DefaultOption[] = [
 | 
			
		||||
    { name: "textNoteEmojiCompletionEnabled", value: "true", isSynced: true },
 | 
			
		||||
    { name: "textNoteCompletionEnabled", value: "true", isSynced: true },
 | 
			
		||||
    
 | 
			
		||||
    // CKEditor plugin configuration
 | 
			
		||||
    { name: "ckeditorEnabledPlugins", value: getDefaultPluginConfiguration, isSynced: true },
 | 
			
		||||
 | 
			
		||||
    // HTML import configuration
 | 
			
		||||
    { name: "layoutOrientation", value: "vertical", isSynced: false },
 | 
			
		||||
    { name: "backgroundEffects", value: "true", isSynced: false },
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ import { COMMON_PLUGINS, CORE_PLUGINS, POPUP_EDITOR_PLUGINS } from "./plugins.js
 | 
			
		||||
import { BalloonEditor, DecoupledEditor, FindAndReplaceEditing, FindCommand } from "ckeditor5";
 | 
			
		||||
import "./translation_overrides.js";
 | 
			
		||||
export { EditorWatchdog } from "ckeditor5";
 | 
			
		||||
export { PREMIUM_PLUGINS } from "./plugins.js";
 | 
			
		||||
export { PREMIUM_PLUGINS, PLUGIN_REGISTRY, getPluginMetadata, getPluginsByCategory, getConfigurablePlugins, getDefaultPluginConfiguration } from "./plugins.js";
 | 
			
		||||
export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, ModelPosition, ModelElement, WatchdogConfig } from "ckeditor5";
 | 
			
		||||
export type { TemplateDefinition } from "ckeditor5-premium-features";
 | 
			
		||||
export { default as buildExtraCommands } from "./extra_slash_commands.js";
 | 
			
		||||
 
 | 
			
		||||
@@ -30,6 +30,8 @@ import CodeBlockLanguageDropdown from "./plugins/code_block_language_dropdown.js
 | 
			
		||||
import MoveBlockUpDownPlugin from "./plugins/move_block_updown.js";
 | 
			
		||||
import ScrollOnUndoRedoPlugin from "./plugins/scroll_on_undo_redo.js"
 | 
			
		||||
 | 
			
		||||
import type { PluginMetadata, PluginRegistry } from "@triliumnext/commons";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Plugins that are specific to Trilium and not part of the CKEditor 5 core, included in both text editors but not in the attribute editor.
 | 
			
		||||
 */
 | 
			
		||||
@@ -159,3 +161,613 @@ export const POPUP_EDITOR_PLUGINS: typeof Plugin[] = [
 | 
			
		||||
    ...COMMON_PLUGINS,
 | 
			
		||||
    BlockToolbar,
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Plugin metadata registry for CKEditor plugins in Trilium.
 | 
			
		||||
 * This defines the configurable plugins with their metadata, dependencies, and categorization.
 | 
			
		||||
 */
 | 
			
		||||
export const PLUGIN_REGISTRY: PluginRegistry = {
 | 
			
		||||
    plugins: {
 | 
			
		||||
        // Core plugins (cannot be disabled)
 | 
			
		||||
        "clipboard": {
 | 
			
		||||
            id: "clipboard",
 | 
			
		||||
            name: "Clipboard",
 | 
			
		||||
            description: "Basic clipboard operations (copy, paste, cut)",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: true,
 | 
			
		||||
            commands: ["copy", "paste", "cut"]
 | 
			
		||||
        },
 | 
			
		||||
        "enter": {
 | 
			
		||||
            id: "enter",
 | 
			
		||||
            name: "Enter Key",
 | 
			
		||||
            description: "Enter key handling for line breaks and paragraphs",
 | 
			
		||||
            category: "structure",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: true,
 | 
			
		||||
        },
 | 
			
		||||
        "typing": {
 | 
			
		||||
            id: "typing",
 | 
			
		||||
            name: "Typing",
 | 
			
		||||
            description: "Basic text input and keyboard handling",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: true,
 | 
			
		||||
        },
 | 
			
		||||
        "undo": {
 | 
			
		||||
            id: "undo",
 | 
			
		||||
            name: "Undo/Redo",
 | 
			
		||||
            description: "Undo and redo functionality",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: true,
 | 
			
		||||
            commands: ["undo", "redo"],
 | 
			
		||||
            toolbarItems: ["undo", "redo"]
 | 
			
		||||
        },
 | 
			
		||||
        "paragraph": {
 | 
			
		||||
            id: "paragraph",
 | 
			
		||||
            name: "Paragraph",
 | 
			
		||||
            description: "Basic paragraph formatting",
 | 
			
		||||
            category: "structure",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: true,
 | 
			
		||||
            commands: ["paragraph"]
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        // Formatting plugins
 | 
			
		||||
        "bold": {
 | 
			
		||||
            id: "bold",
 | 
			
		||||
            name: "Bold",
 | 
			
		||||
            description: "Bold text formatting",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["bold"],
 | 
			
		||||
            toolbarItems: ["bold"]
 | 
			
		||||
        },
 | 
			
		||||
        "italic": {
 | 
			
		||||
            id: "italic",
 | 
			
		||||
            name: "Italic",
 | 
			
		||||
            description: "Italic text formatting",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["italic"],
 | 
			
		||||
            toolbarItems: ["italic"]
 | 
			
		||||
        },
 | 
			
		||||
        "underline": {
 | 
			
		||||
            id: "underline",
 | 
			
		||||
            name: "Underline",
 | 
			
		||||
            description: "Underline text formatting",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["underline"],
 | 
			
		||||
            toolbarItems: ["underline"]
 | 
			
		||||
        },
 | 
			
		||||
        "strikethrough": {
 | 
			
		||||
            id: "strikethrough",
 | 
			
		||||
            name: "Strikethrough",
 | 
			
		||||
            description: "Strikethrough text formatting",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["strikethrough"],
 | 
			
		||||
            toolbarItems: ["strikethrough"]
 | 
			
		||||
        },
 | 
			
		||||
        "code": {
 | 
			
		||||
            id: "code",
 | 
			
		||||
            name: "Inline Code",
 | 
			
		||||
            description: "Inline code formatting",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["code"],
 | 
			
		||||
            toolbarItems: ["code"]
 | 
			
		||||
        },
 | 
			
		||||
        "subscript": {
 | 
			
		||||
            id: "subscript",
 | 
			
		||||
            name: "Subscript",
 | 
			
		||||
            description: "Subscript text formatting",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["subscript"],
 | 
			
		||||
            toolbarItems: ["subscript"]
 | 
			
		||||
        },
 | 
			
		||||
        "superscript": {
 | 
			
		||||
            id: "superscript",
 | 
			
		||||
            name: "Superscript",
 | 
			
		||||
            description: "Superscript text formatting",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["superscript"],
 | 
			
		||||
            toolbarItems: ["superscript"]
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        // Structure plugins
 | 
			
		||||
        "heading": {
 | 
			
		||||
            id: "heading",
 | 
			
		||||
            name: "Headings",
 | 
			
		||||
            description: "Heading levels (H2-H6)",
 | 
			
		||||
            category: "structure",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["heading"],
 | 
			
		||||
            toolbarItems: ["heading"]
 | 
			
		||||
        },
 | 
			
		||||
        "blockquote": {
 | 
			
		||||
            id: "blockquote",
 | 
			
		||||
            name: "Block Quote",
 | 
			
		||||
            description: "Block quote formatting",
 | 
			
		||||
            category: "structure",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["blockQuote"],
 | 
			
		||||
            toolbarItems: ["blockQuote"]
 | 
			
		||||
        },
 | 
			
		||||
        "list": {
 | 
			
		||||
            id: "list",
 | 
			
		||||
            name: "Lists",
 | 
			
		||||
            description: "Bulleted and numbered lists",
 | 
			
		||||
            category: "structure",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["bulletedList", "numberedList"],
 | 
			
		||||
            toolbarItems: ["bulletedList", "numberedList"]
 | 
			
		||||
        },
 | 
			
		||||
        "todolist": {
 | 
			
		||||
            id: "todolist",
 | 
			
		||||
            name: "Todo List",
 | 
			
		||||
            description: "Checkable todo list items",
 | 
			
		||||
            category: "structure",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: ["list"],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["todoList"],
 | 
			
		||||
            toolbarItems: ["todoList"]
 | 
			
		||||
        },
 | 
			
		||||
        "alignment": {
 | 
			
		||||
            id: "alignment",
 | 
			
		||||
            name: "Text Alignment",
 | 
			
		||||
            description: "Text alignment (left, center, right, justify)",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["alignment"],
 | 
			
		||||
            toolbarItems: ["alignment"]
 | 
			
		||||
        },
 | 
			
		||||
        "indent": {
 | 
			
		||||
            id: "indent",
 | 
			
		||||
            name: "Indentation",
 | 
			
		||||
            description: "Text and block indentation",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["indent", "outdent"],
 | 
			
		||||
            toolbarItems: ["indent", "outdent"]
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        // Media plugins
 | 
			
		||||
        "image": {
 | 
			
		||||
            id: "image",
 | 
			
		||||
            name: "Images",
 | 
			
		||||
            description: "Image insertion, resizing and styling",
 | 
			
		||||
            category: "media",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["insertImage"],
 | 
			
		||||
            toolbarItems: ["insertImage"]
 | 
			
		||||
        },
 | 
			
		||||
        "link": {
 | 
			
		||||
            id: "link",
 | 
			
		||||
            name: "Links",
 | 
			
		||||
            description: "Hyperlinks and internal note links",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["link", "unlink"],
 | 
			
		||||
            toolbarItems: ["link", "unlink"]
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        // Table plugins
 | 
			
		||||
        "table": {
 | 
			
		||||
            id: "table",
 | 
			
		||||
            name: "Tables",
 | 
			
		||||
            description: "Table creation and editing",
 | 
			
		||||
            category: "tables",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["insertTable"],
 | 
			
		||||
            toolbarItems: ["insertTable"]
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        // Advanced plugins
 | 
			
		||||
        "codeblock": {
 | 
			
		||||
            id: "codeblock",
 | 
			
		||||
            name: "Code Blocks",
 | 
			
		||||
            description: "Syntax-highlighted code blocks",
 | 
			
		||||
            category: "advanced",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["codeBlock"],
 | 
			
		||||
            toolbarItems: ["codeBlock"]
 | 
			
		||||
        },
 | 
			
		||||
        "math": {
 | 
			
		||||
            id: "math",
 | 
			
		||||
            name: "Math Formulas",
 | 
			
		||||
            description: "Mathematical formulas using KaTeX",
 | 
			
		||||
            category: "advanced",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["math"],
 | 
			
		||||
            toolbarItems: ["math"]
 | 
			
		||||
        },
 | 
			
		||||
        "mermaid": {
 | 
			
		||||
            id: "mermaid",
 | 
			
		||||
            name: "Mermaid Diagrams",
 | 
			
		||||
            description: "Diagram creation using Mermaid syntax",
 | 
			
		||||
            category: "advanced",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["mermaid"],
 | 
			
		||||
            toolbarItems: ["mermaid"]
 | 
			
		||||
        },
 | 
			
		||||
        "admonition": {
 | 
			
		||||
            id: "admonition",
 | 
			
		||||
            name: "Admonitions",
 | 
			
		||||
            description: "Callout boxes and admonition blocks",
 | 
			
		||||
            category: "advanced",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["admonition"],
 | 
			
		||||
            toolbarItems: ["admonition"]
 | 
			
		||||
        },
 | 
			
		||||
        "footnotes": {
 | 
			
		||||
            id: "footnotes",
 | 
			
		||||
            name: "Footnotes",
 | 
			
		||||
            description: "Footnote references and definitions",
 | 
			
		||||
            category: "advanced",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["footnote"],
 | 
			
		||||
            toolbarItems: ["footnote"]
 | 
			
		||||
        },
 | 
			
		||||
        "keyboard": {
 | 
			
		||||
            id: "keyboard",
 | 
			
		||||
            name: "Keyboard Shortcuts",
 | 
			
		||||
            description: "Visual keyboard shortcut markers",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["kbd"],
 | 
			
		||||
            toolbarItems: ["kbd"]
 | 
			
		||||
        },
 | 
			
		||||
        "horizontalline": {
 | 
			
		||||
            id: "horizontalline",
 | 
			
		||||
            name: "Horizontal Line",
 | 
			
		||||
            description: "Horizontal rule/divider line",
 | 
			
		||||
            category: "structure",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["horizontalLine"],
 | 
			
		||||
            toolbarItems: ["horizontalLine"]
 | 
			
		||||
        },
 | 
			
		||||
        "pagebreak": {
 | 
			
		||||
            id: "pagebreak",
 | 
			
		||||
            name: "Page Break",
 | 
			
		||||
            description: "Page break for printing",
 | 
			
		||||
            category: "structure",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["pageBreak"],
 | 
			
		||||
            toolbarItems: ["pageBreak"]
 | 
			
		||||
        },
 | 
			
		||||
        "removeformat": {
 | 
			
		||||
            id: "removeformat",
 | 
			
		||||
            name: "Remove Formatting",
 | 
			
		||||
            description: "Remove text formatting",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["removeFormat"],
 | 
			
		||||
            toolbarItems: ["removeFormat"]
 | 
			
		||||
        },
 | 
			
		||||
        "findandreplace": {
 | 
			
		||||
            id: "findandreplace",
 | 
			
		||||
            name: "Find and Replace",
 | 
			
		||||
            description: "Text search and replace functionality",
 | 
			
		||||
            category: "advanced",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["findAndReplace"],
 | 
			
		||||
            toolbarItems: ["findAndReplace"]
 | 
			
		||||
        },
 | 
			
		||||
        "font": {
 | 
			
		||||
            id: "font",
 | 
			
		||||
            name: "Font Styling",
 | 
			
		||||
            description: "Font family, size, color, and background color",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["fontFamily", "fontSize", "fontColor", "fontBackgroundColor"],
 | 
			
		||||
            toolbarItems: ["fontFamily", "fontSize", "fontColor", "fontBackgroundColor"]
 | 
			
		||||
        },
 | 
			
		||||
        "specialcharacters": {
 | 
			
		||||
            id: "specialcharacters",
 | 
			
		||||
            name: "Special Characters",
 | 
			
		||||
            description: "Insert special characters and symbols",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["specialCharacters"],
 | 
			
		||||
            toolbarItems: ["specialCharacters"]
 | 
			
		||||
        },
 | 
			
		||||
        "emoji": {
 | 
			
		||||
            id: "emoji",
 | 
			
		||||
            name: "Emoji Support",
 | 
			
		||||
            description: "Emoji insertion and autocomplete",
 | 
			
		||||
            category: "formatting",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["emoji"],
 | 
			
		||||
            toolbarItems: ["emoji"]
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        // Premium plugins
 | 
			
		||||
        "slashcommand": {
 | 
			
		||||
            id: "slashcommand",
 | 
			
		||||
            name: "Slash Commands",
 | 
			
		||||
            description: "Quick command insertion with / key",
 | 
			
		||||
            category: "advanced",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: true,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["slashCommand"]
 | 
			
		||||
        },
 | 
			
		||||
        "template": {
 | 
			
		||||
            id: "template",
 | 
			
		||||
            name: "Templates",
 | 
			
		||||
            description: "Text templates and snippets",
 | 
			
		||||
            category: "advanced",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: true,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["template"],
 | 
			
		||||
            toolbarItems: ["template"]
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        // Trilium-specific plugins
 | 
			
		||||
        "uploadimage": {
 | 
			
		||||
            id: "uploadimage",
 | 
			
		||||
            name: "Image Upload",
 | 
			
		||||
            description: "Trilium-specific image upload handling",
 | 
			
		||||
            category: "trilium",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: ["image"],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
        },
 | 
			
		||||
        "cuttonote": {
 | 
			
		||||
            id: "cuttonote",
 | 
			
		||||
            name: "Cut to Note",
 | 
			
		||||
            description: "Cut selected text to create a new note",
 | 
			
		||||
            category: "trilium",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["cutToNote"]
 | 
			
		||||
        },
 | 
			
		||||
        "internallink": {
 | 
			
		||||
            id: "internallink",
 | 
			
		||||
            name: "Internal Links",
 | 
			
		||||
            description: "Trilium-specific internal note linking",
 | 
			
		||||
            category: "trilium",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: ["link"],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
        },
 | 
			
		||||
        "insertdatetime": {
 | 
			
		||||
            id: "insertdatetime",
 | 
			
		||||
            name: "Insert Date/Time",
 | 
			
		||||
            description: "Insert current date and time",
 | 
			
		||||
            category: "trilium",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["insertDateTime"]
 | 
			
		||||
        },
 | 
			
		||||
        "includenote": {
 | 
			
		||||
            id: "includenote",
 | 
			
		||||
            name: "Include Note",
 | 
			
		||||
            description: "Include content from other notes",
 | 
			
		||||
            category: "trilium",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["includeNote"]
 | 
			
		||||
        },
 | 
			
		||||
        "uploadfile": {
 | 
			
		||||
            id: "uploadfile",
 | 
			
		||||
            name: "File Upload",
 | 
			
		||||
            description: "Upload and attach files",
 | 
			
		||||
            category: "trilium",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["uploadFile"]
 | 
			
		||||
        },
 | 
			
		||||
        "markdownimport": {
 | 
			
		||||
            id: "markdownimport",
 | 
			
		||||
            name: "Markdown Import",
 | 
			
		||||
            description: "Import markdown content",
 | 
			
		||||
            category: "trilium",
 | 
			
		||||
            defaultEnabled: true,
 | 
			
		||||
            dependencies: [],
 | 
			
		||||
            conflicts: [],
 | 
			
		||||
            requiresPremium: false,
 | 
			
		||||
            isCore: false,
 | 
			
		||||
            commands: ["markdownImport"]
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    version: "1.0.0",
 | 
			
		||||
    lastModified: new Date().toISOString()
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get plugin metadata by ID
 | 
			
		||||
 */
 | 
			
		||||
export function getPluginMetadata(pluginId: string): PluginMetadata | undefined {
 | 
			
		||||
    return PLUGIN_REGISTRY.plugins[pluginId];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get all plugins in a category
 | 
			
		||||
 */
 | 
			
		||||
export function getPluginsByCategory(category: string): PluginMetadata[] {
 | 
			
		||||
    return Object.values(PLUGIN_REGISTRY.plugins).filter(plugin => plugin.category === category);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get core plugins (cannot be disabled)
 | 
			
		||||
 */
 | 
			
		||||
export function getCorePlugins(): PluginMetadata[] {
 | 
			
		||||
    return Object.values(PLUGIN_REGISTRY.plugins).filter(plugin => plugin.isCore);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get configurable plugins (can be enabled/disabled)
 | 
			
		||||
 */
 | 
			
		||||
export function getConfigurablePlugins(): PluginMetadata[] {
 | 
			
		||||
    return Object.values(PLUGIN_REGISTRY.plugins).filter(plugin => !plugin.isCore);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get default enabled plugins configuration as JSON string
 | 
			
		||||
 */
 | 
			
		||||
export function getDefaultPluginConfiguration(): string {
 | 
			
		||||
    const defaultConfig: Record<string, boolean> = {};
 | 
			
		||||
    Object.values(PLUGIN_REGISTRY.plugins).forEach(plugin => {
 | 
			
		||||
        if (!plugin.isCore) {
 | 
			
		||||
            defaultConfig[plugin.id] = plugin.defaultEnabled;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    return JSON.stringify(defaultConfig);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,20 +4,23 @@
 | 
			
		||||
  "include": [],
 | 
			
		||||
  "references": [
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-footnotes"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-math"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-admonition"
 | 
			
		||||
      "path": "../commons"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-mermaid"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-math"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-keyboard-marker"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-footnotes"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-admonition"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "./tsconfig.lib.json"
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -20,19 +20,22 @@
 | 
			
		||||
  ],
 | 
			
		||||
  "references": [
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-footnotes"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-math"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-admonition"
 | 
			
		||||
      "path": "../commons/tsconfig.lib.json"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-mermaid"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-math"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-keyboard-marker"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-footnotes"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "../ckeditor5-admonition"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
export * from "./lib/i18n.js";
 | 
			
		||||
export * from "./lib/options_interface.js";
 | 
			
		||||
export * from "./lib/ckeditor_plugin_interface.js";
 | 
			
		||||
export * from "./lib/keyboard_actions_interface.js";
 | 
			
		||||
export * from "./lib/hidden_subtree.js";
 | 
			
		||||
export * from "./lib/rows.js";
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										163
									
								
								packages/commons/src/lib/ckeditor_plugin_interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								packages/commons/src/lib/ckeditor_plugin_interface.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,163 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @module CKEditor Plugin Interface
 | 
			
		||||
 * 
 | 
			
		||||
 * This module defines the TypeScript interfaces and types for managing
 | 
			
		||||
 * CKEditor plugins in Trilium Notes. It provides type-safe configuration
 | 
			
		||||
 * for plugin enablement, metadata, and dependency management.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Defines the categories of CKEditor plugins available in Trilium.
 | 
			
		||||
 */
 | 
			
		||||
export type PluginCategory = 
 | 
			
		||||
    | "formatting"    // Text formatting (bold, italic, etc.)
 | 
			
		||||
    | "structure"     // Document structure (headings, lists, etc.)
 | 
			
		||||
    | "media"        // Images, files, embeds
 | 
			
		||||
    | "tables"       // Table-related functionality
 | 
			
		||||
    | "advanced"     // Advanced features (math, mermaid, etc.)
 | 
			
		||||
    | "trilium"      // Trilium-specific plugins
 | 
			
		||||
    | "external";    // Third-party plugins
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Represents the metadata for a CKEditor plugin.
 | 
			
		||||
 */
 | 
			
		||||
export interface PluginMetadata {
 | 
			
		||||
    /** Unique identifier for the plugin */
 | 
			
		||||
    id: string;
 | 
			
		||||
    /** Human-readable display name */
 | 
			
		||||
    name: string;
 | 
			
		||||
    /** Brief description of the plugin's functionality */
 | 
			
		||||
    description: string;
 | 
			
		||||
    /** Category this plugin belongs to */
 | 
			
		||||
    category: PluginCategory;
 | 
			
		||||
    /** Whether this plugin is enabled by default for new users */
 | 
			
		||||
    defaultEnabled: boolean;
 | 
			
		||||
    /** Array of plugin IDs that this plugin depends on */
 | 
			
		||||
    dependencies: string[];
 | 
			
		||||
    /** Array of plugin IDs that conflict with this plugin */
 | 
			
		||||
    conflicts: string[];
 | 
			
		||||
    /** Whether this plugin requires a premium CKEditor license */
 | 
			
		||||
    requiresPremium: boolean;
 | 
			
		||||
    /** Whether this plugin is part of the core editor functionality (cannot be disabled) */
 | 
			
		||||
    isCore: boolean;
 | 
			
		||||
    /** Toolbar items/commands provided by this plugin */
 | 
			
		||||
    toolbarItems?: string[];
 | 
			
		||||
    /** Commands provided by this plugin */
 | 
			
		||||
    commands?: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Configuration for a user's CKEditor plugin preferences.
 | 
			
		||||
 */
 | 
			
		||||
export interface PluginConfiguration {
 | 
			
		||||
    /** Plugin ID */
 | 
			
		||||
    id: string;
 | 
			
		||||
    /** Whether the plugin is enabled for this user */
 | 
			
		||||
    enabled: boolean;
 | 
			
		||||
    /** User-specific configuration for the plugin (if any) */
 | 
			
		||||
    config?: Record<string, unknown>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The complete registry of available CKEditor plugins.
 | 
			
		||||
 */
 | 
			
		||||
export interface PluginRegistry {
 | 
			
		||||
    /** Map of plugin ID to plugin metadata */
 | 
			
		||||
    plugins: Record<string, PluginMetadata>;
 | 
			
		||||
    /** Version of the plugin registry (for cache invalidation) */
 | 
			
		||||
    version: string;
 | 
			
		||||
    /** Last modified timestamp */
 | 
			
		||||
    lastModified: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Result of plugin dependency validation.
 | 
			
		||||
 */
 | 
			
		||||
export interface PluginValidationResult {
 | 
			
		||||
    /** Whether the configuration is valid */
 | 
			
		||||
    valid: boolean;
 | 
			
		||||
    /** Array of validation errors */
 | 
			
		||||
    errors: PluginValidationError[];
 | 
			
		||||
    /** Array of warnings (non-blocking issues) */
 | 
			
		||||
    warnings: PluginValidationWarning[];
 | 
			
		||||
    /** Resolved list of plugins that should be enabled */
 | 
			
		||||
    resolvedPlugins: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validation error for plugin configuration.
 | 
			
		||||
 */
 | 
			
		||||
export interface PluginValidationError {
 | 
			
		||||
    /** Type of error */
 | 
			
		||||
    type: "missing_dependency" | "circular_dependency" | "plugin_conflict" | "premium_required";
 | 
			
		||||
    /** Plugin ID that caused the error */
 | 
			
		||||
    pluginId: string;
 | 
			
		||||
    /** Human-readable error message */
 | 
			
		||||
    message: string;
 | 
			
		||||
    /** Additional context about the error */
 | 
			
		||||
    details?: Record<string, unknown>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validation warning for plugin configuration.
 | 
			
		||||
 */
 | 
			
		||||
export interface PluginValidationWarning {
 | 
			
		||||
    /** Type of warning */
 | 
			
		||||
    type: "dependency_disabled" | "unused_dependency" | "performance_impact";
 | 
			
		||||
    /** Plugin ID that caused the warning */
 | 
			
		||||
    pluginId: string;
 | 
			
		||||
    /** Human-readable warning message */
 | 
			
		||||
    message: string;
 | 
			
		||||
    /** Additional context about the warning */
 | 
			
		||||
    details?: Record<string, unknown>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Request to update plugin configuration.
 | 
			
		||||
 */
 | 
			
		||||
export interface UpdatePluginConfigRequest {
 | 
			
		||||
    /** Array of plugin configurations to update */
 | 
			
		||||
    plugins: PluginConfiguration[];
 | 
			
		||||
    /** Whether to validate dependencies before saving */
 | 
			
		||||
    validate?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Response from updating plugin configuration.
 | 
			
		||||
 */
 | 
			
		||||
export interface UpdatePluginConfigResponse {
 | 
			
		||||
    /** Whether the update was successful */
 | 
			
		||||
    success: boolean;
 | 
			
		||||
    /** Validation result (if validation was requested) */
 | 
			
		||||
    validation?: PluginValidationResult;
 | 
			
		||||
    /** Updated plugin configurations */
 | 
			
		||||
    plugins: PluginConfiguration[];
 | 
			
		||||
    /** Any errors that occurred during the update */
 | 
			
		||||
    errors?: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Options for querying the plugin registry.
 | 
			
		||||
 */
 | 
			
		||||
export interface QueryPluginsOptions {
 | 
			
		||||
    /** Filter by category */
 | 
			
		||||
    category?: PluginCategory;
 | 
			
		||||
    /** Filter by enabled status */
 | 
			
		||||
    enabled?: boolean;
 | 
			
		||||
    /** Filter by core status */
 | 
			
		||||
    coreOnly?: boolean;
 | 
			
		||||
    /** Include user configuration in results */
 | 
			
		||||
    includeConfig?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Result of querying plugins.
 | 
			
		||||
 */
 | 
			
		||||
export interface QueryPluginsResult {
 | 
			
		||||
    /** Array of plugin metadata */
 | 
			
		||||
    plugins: (PluginMetadata & { enabled?: boolean; config?: Record<string, unknown> })[];
 | 
			
		||||
    /** Total count of plugins (before filtering) */
 | 
			
		||||
    totalCount: number;
 | 
			
		||||
    /** Categories available in the registry */
 | 
			
		||||
    categories: PluginCategory[];
 | 
			
		||||
}
 | 
			
		||||
@@ -150,6 +150,9 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
 | 
			
		||||
    codeOpenAiModel: string;
 | 
			
		||||
    aiSelectedProvider: string;
 | 
			
		||||
    seenCallToActions: string;
 | 
			
		||||
    
 | 
			
		||||
    // CKEditor plugin options
 | 
			
		||||
    ckeditorEnabledPlugins: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type OptionNames = keyof OptionDefinitions;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user