mirror of
https://github.com/zadam/trilium.git
synced 2025-10-26 07:46:30 +01:00
Compare commits
2 Commits
react/type
...
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,110 +80,115 @@ 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.
|
||||
const items = [
|
||||
"heading",
|
||||
"fontSize",
|
||||
"|",
|
||||
"bold",
|
||||
"italic",
|
||||
{
|
||||
...TEXT_FORMATTING_GROUP,
|
||||
items: ["underline", "strikethrough", "|", "superscript", "subscript", "|", "kbd"]
|
||||
},
|
||||
"|",
|
||||
"fontColor",
|
||||
"fontBackgroundColor",
|
||||
"removeFormat",
|
||||
"|",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"todoList",
|
||||
"|",
|
||||
"blockQuote",
|
||||
"admonition",
|
||||
"insertTable",
|
||||
"|",
|
||||
"code",
|
||||
"codeBlock",
|
||||
"|",
|
||||
"footnote",
|
||||
{
|
||||
label: "Insert",
|
||||
icon: "plus",
|
||||
items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
|
||||
},
|
||||
"|",
|
||||
"alignment",
|
||||
"outdent",
|
||||
"indent",
|
||||
"|",
|
||||
"insertTemplate",
|
||||
"markdownImport",
|
||||
"cuttonote",
|
||||
"findAndReplace"
|
||||
];
|
||||
|
||||
return {
|
||||
toolbar: {
|
||||
items: [
|
||||
"heading",
|
||||
"fontSize",
|
||||
"|",
|
||||
"bold",
|
||||
"italic",
|
||||
{
|
||||
...TEXT_FORMATTING_GROUP,
|
||||
items: ["underline", "strikethrough", "|", "superscript", "subscript", "|", "kbd"]
|
||||
},
|
||||
"|",
|
||||
"fontColor",
|
||||
"fontBackgroundColor",
|
||||
"removeFormat",
|
||||
"|",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"todoList",
|
||||
"|",
|
||||
"blockQuote",
|
||||
"admonition",
|
||||
"insertTable",
|
||||
"|",
|
||||
"code",
|
||||
"codeBlock",
|
||||
"|",
|
||||
"footnote",
|
||||
{
|
||||
label: "Insert",
|
||||
icon: "plus",
|
||||
items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
|
||||
},
|
||||
"|",
|
||||
"alignment",
|
||||
"outdent",
|
||||
"indent",
|
||||
"|",
|
||||
"insertTemplate",
|
||||
"markdownImport",
|
||||
"cuttonote",
|
||||
"findAndReplace"
|
||||
],
|
||||
items: filterToolbarItems(items, hiddenItems),
|
||||
shouldNotGroupWhenFull: multilineToolbar
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFloatingToolbar() {
|
||||
export function buildFloatingToolbar(hiddenItems: Set<string>) {
|
||||
const toolbarItems = [
|
||||
"fontSize",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
{
|
||||
...TEXT_FORMATTING_GROUP,
|
||||
items: [ "strikethrough", "|", "superscript", "subscript", "|", "kbd" ]
|
||||
},
|
||||
"|",
|
||||
"fontColor",
|
||||
"fontBackgroundColor",
|
||||
"|",
|
||||
"code",
|
||||
"link",
|
||||
"bookmark",
|
||||
"removeFormat",
|
||||
"internallink",
|
||||
"cuttonote"
|
||||
];
|
||||
|
||||
const blockToolbarItems = [
|
||||
"heading",
|
||||
"|",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"todoList",
|
||||
"|",
|
||||
"blockQuote",
|
||||
"admonition",
|
||||
"codeBlock",
|
||||
"insertTable",
|
||||
"footnote",
|
||||
{
|
||||
label: "Insert",
|
||||
icon: "plus",
|
||||
items: ["link", "bookmark", "internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
|
||||
},
|
||||
"|",
|
||||
"alignment",
|
||||
"outdent",
|
||||
"indent",
|
||||
"|",
|
||||
"insertTemplate",
|
||||
"imageUpload",
|
||||
"markdownImport",
|
||||
"specialCharacters",
|
||||
"emoji",
|
||||
"findAndReplace"
|
||||
];
|
||||
|
||||
return {
|
||||
toolbar: {
|
||||
items: [
|
||||
"fontSize",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
{
|
||||
...TEXT_FORMATTING_GROUP,
|
||||
items: [ "strikethrough", "|", "superscript", "subscript", "|", "kbd" ]
|
||||
},
|
||||
"|",
|
||||
"fontColor",
|
||||
"fontBackgroundColor",
|
||||
"|",
|
||||
"code",
|
||||
"link",
|
||||
"bookmark",
|
||||
"removeFormat",
|
||||
"internallink",
|
||||
"cuttonote"
|
||||
]
|
||||
items: filterToolbarItems(toolbarItems, hiddenItems)
|
||||
},
|
||||
|
||||
blockToolbar: [
|
||||
"heading",
|
||||
"|",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"todoList",
|
||||
"|",
|
||||
"blockQuote",
|
||||
"admonition",
|
||||
"codeBlock",
|
||||
"insertTable",
|
||||
"footnote",
|
||||
{
|
||||
label: "Insert",
|
||||
icon: "plus",
|
||||
items: ["link", "bookmark", "internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
|
||||
},
|
||||
"|",
|
||||
"alignment",
|
||||
"outdent",
|
||||
"indent",
|
||||
"|",
|
||||
"insertTemplate",
|
||||
"imageUpload",
|
||||
"markdownImport",
|
||||
"specialCharacters",
|
||||
"emoji",
|
||||
"findAndReplace"
|
||||
]
|
||||
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";
|
||||
@@ -220,6 +221,15 @@ function register(app: express.Application) {
|
||||
apiRoute(PUT, "/api/options", optionsApiRoute.updateOptions);
|
||||
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);
|
||||
@@ -184,6 +185,9 @@ const defaultOptions: DefaultOption[] = [
|
||||
{ name: "textNoteEditorMultilineToolbar", value: "false", isSynced: true },
|
||||
{ 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 },
|
||||
|
||||
@@ -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