diff --git a/README.md b/README.md index 985c4e3..4eefba0 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ BPM settings are split into focused pages: | Page | What you can configure | |------|------------------------| -| **Basic** | Language, persistent filters, delayed startup, auto takeover, startup update checks, source update checks, source auto-update, BPM tag visibility, ribbon order, commands, debug mode, and GitHub token | +| **Basic** | Language, persistent filters, delayed startup, auto takeover, startup update checks, source update checks, source auto-update, BPM tag visibility, ribbon order, command registration, debug mode, and GitHub token | | **Main Page Actions** | Choose which plugin actions appear directly on plugin cards and which stay in the right-click menu | | **Style** | Plugin list layout, item display style, group/tag styles, and disabled-plugin fading | | **Groups** | Create, rename, recolor, and delete plugin groups | @@ -278,9 +278,14 @@ BPM settings are split into focused pages: | Command | Availability | Description | |---------|--------------|-------------| | **Open the plugin manager** | Always available | Opens the BPM main interface | +| **Control a plugin** | Always available | Search a plugin, then enable, disable, single-start, restart, open settings, open folder, open repository, or copy ID | +| **Save current plugin state as profile** | Always available | Saves the current enabled/disabled state as a reusable command profile | +| **Restore previous plugin state** | Available after a BPM command changes plugin state | Restores the snapshot captured before the last command-driven state change | | **Troubleshoot plugin conflicts** | Always available | Starts the conflict diagnosis workflow | | **Enable/Disable selected plugin** | Optional setting | Registers one command per plugin for direct toggling | | **One-click Enable/Disable selected group** | Optional setting | Registers group-level commands for batch toggling | +| **One-click Enable/Disable selected tag** | Optional setting | Registers tag-level commands for batch toggling | +| **Apply selected profile** | Optional setting | Registers one command per saved plugin profile | --- diff --git a/docs/README_CN.md b/docs/README_CN.md index 45b7d4c..93e0d20 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -262,7 +262,7 @@ BPM 设置按功能拆分为多个页面: | 页面 | 可配置内容 | |------|------------| -| **Basic** | 语言、筛选持久化、延迟启动、自动接管、启动检查更新、来源检查更新、来源自动更新、BPM 标签显示、Ribbon 编排、命令、调试模式和 GitHub Token | +| **Basic** | 语言、筛选持久化、延迟启动、自动接管、启动检查更新、来源检查更新、来源自动更新、BPM 标签显示、Ribbon 编排、命令注册、调试模式和 GitHub Token | | **Main Page Actions** | 选择哪些插件操作直接显示在插件卡片上,哪些收纳到右键菜单 | | **Style** | 插件列表布局、项目显示样式、分组/标签样式和禁用插件淡化 | | **Groups** | 创建、重命名、重新着色和删除插件分组 | @@ -276,9 +276,14 @@ BPM 设置按功能拆分为多个页面: | 命令 | 可用性 | 说明 | |------|--------|------| | **Open the plugin manager** | 始终可用 | 打开 BPM 主界面 | +| **Control a plugin** | 始终可用 | 搜索插件后执行启用、禁用、单次启动、重启、打开设置、打开目录、打开仓库或复制 ID | +| **Save current plugin state as profile** | 始终可用 | 将当前插件启用/禁用状态保存为可复用 Profile | +| **Restore previous plugin state** | BPM 命令改变插件状态后可用 | 恢复最近一次命令操作前保存的状态快照 | | **Troubleshoot plugin conflicts** | 始终可用 | 启动冲突诊断流程 | | **Enable/Disable [Plugin Name]** | 可选设置 | 为每个插件注册独立启用/禁用命令 | | **One-click Enable/Disable [Group Name]** | 可选设置 | 为分组注册批量切换命令 | +| **One-click Enable/Disable [Tag Name]** | 可选设置 | 为标签注册批量切换命令 | +| **Apply [Profile Name]** | 可选设置 | 为已保存的插件 Profile 注册一键应用命令 | --- diff --git a/package-lock.json b/package-lock.json index b944a68..5a99724 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "better-plugins-manager", - "version": "1.0.12", + "version": "1.0.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "better-plugins-manager", - "version": "1.0.12", + "version": "1.0.13", "license": "MIT", "devDependencies": { "@types/node": "^16.11.6", diff --git a/package.json b/package.json index d60d999..03fe28e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", - "version": "node version-bump.mjs && git add manifest.json versions.json" + "version": "node version-bump.mjs && git add manifest.json versions.json package-lock.json" }, "keywords": [], "author": "zero", diff --git a/src/command.ts b/src/command.ts index 73f638b..ebb8552 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,146 +1,704 @@ -import { App, PluginManifest } from "obsidian"; -import Manager from "./main"; -import { ManagerModal } from "./modal/manager-modal"; -import { TroubleshootModal } from "./troubleshoot/troubleshoot-modal"; - -const Commands = (app: App, manager: Manager) => { - manager.addCommand({ - id: 'manager-view', - name: manager.translator.t('命令_管理面板_描述'), - callback: () => { new ManagerModal(app, manager).open() } - }); - - // 排查冲突命令 - manager.addCommand({ - id: 'troubleshoot-conflicts', - name: manager.translator.t('排查_按钮_描述'), - callback: () => { new TroubleshootModal(app, manager).open() } - }); - - if (manager.settings.DELAY) { - // 单行命令 - if (manager.settings.COMMAND_ITEM) { - const plugins: PluginManifest[] = Object.values(manager.appPlugins.manifests).filter((pm: PluginManifest) => pm.id !== manager.manifest.id); - plugins.forEach(plugin => { - const mp = manager.settings.Plugins.find(mp => mp.id === plugin.id); - if (mp) { - manager.addCommand({ - id: `manager-${mp.id}`, - name: `${mp.enabled ? manager.translator.t('通用_关闭_文本') : manager.translator.t('通用_开启_文本')} ${mp.name} `, - callback: async () => { - if (mp.enabled) { - mp.enabled = false; - await manager.savePluginAndExport(mp.id); - await manager.appPlugins.disablePlugin(plugin.id); - Commands(app, manager); - } else { - mp.enabled = true; - await manager.savePluginAndExport(mp.id); - await manager.appPlugins.enablePlugin(plugin.id); - Commands(app, manager); - } - } - }); - } - }); - } - // 分组命令 - if (manager.settings.COMMAND_GROUP) { - manager.settings.GROUPS.forEach((group) => { - manager.addCommand({ - id: `manager-${group.id}-enabled`, - name: `${manager.translator.t('命令行_一键启用_文本')} ${group.name}`, - callback: async () => { - const filteredPlugins = manager.settings.Plugins.filter(plugin => plugin.group === group.id); - for (const plugin of filteredPlugins) { - if (plugin && !plugin.enabled) { - await manager.appPlugins.enablePlugin(plugin.id); - plugin.enabled = true; - await manager.savePluginAndExport(plugin.id); - } - } - Commands(app, manager); - } - }); - manager.addCommand({ - id: `manager-${group.id}-disable`, - name: `${manager.translator.t('命令行_一键禁用_文本')} ${group.name}`, - callback: async () => { - const filteredPlugins = manager.settings.Plugins.filter(plugin => plugin.group === group.id); - for (const plugin of filteredPlugins) { - if (plugin && plugin.enabled) { - await manager.appPlugins.disablePlugin(plugin.id); - plugin.enabled = false; - await manager.savePluginAndExport(plugin.id); - } - } - Commands(app, manager); - } - }); - }); - } - } else { - // 单行命令 - if (manager.settings.COMMAND_ITEM) { - const plugins: PluginManifest[] = Object.values(manager.appPlugins.manifests).filter((pm: PluginManifest) => pm.id !== manager.manifest.id); - plugins.forEach(plugin => { - const enabled = manager.appPlugins.enabledPlugins.has(plugin.id); - manager.addCommand({ - id: `manager-${plugin.id}`, - name: `${enabled ? manager.translator.t('命令行_禁用_文本') : manager.translator.t('命令行_启用_文本')} ${plugin.name} `, - callback: async () => { - if (enabled) { - await manager.appPlugins.disablePluginAndSave(plugin.id); - const mp = manager.settings.Plugins.find(p => p.id === plugin.id); - if (mp) mp.enabled = false; - await manager.savePluginAndExport(plugin.id); - Commands(app, manager); - } else { - await manager.appPlugins.enablePluginAndSave(plugin.id); - const mp = manager.settings.Plugins.find(p => p.id === plugin.id); - if (mp) mp.enabled = true; - await manager.savePluginAndExport(plugin.id); - Commands(app, manager); - } - } - }); - - }); - } - // 分组命令 - if (manager.settings.COMMAND_GROUP) { - manager.settings.GROUPS.forEach((group) => { - manager.addCommand({ - id: `manager-${group.id}-enabled`, - name: `${manager.translator.t('命令行_一键启用_文本')} ${group.name} ${manager.translator.t('命令行_分组_文本')}`, - callback: async () => { - const filteredPlugins = manager.settings.Plugins.filter(plugin => plugin.group === group.id); - for (const plugin of filteredPlugins) { - await manager.appPlugins.enablePluginAndSave(plugin.id); - const mp = manager.settings.Plugins.find(p => p.id === plugin.id); - if (mp) mp.enabled = true; - await manager.savePluginAndExport(plugin.id); - } - Commands(app, manager); - } - }); - manager.addCommand({ - id: `manager-${group.id}-disable`, - name: `${manager.translator.t('命令行_一键禁用_文本')} ${group.name} ${manager.translator.t('命令行_分组_文本')}`, - callback: async () => { - const filteredPlugins = manager.settings.Plugins.filter(plugin => plugin.group === group.id); - for (const plugin of filteredPlugins) { - await manager.appPlugins.disablePluginAndSave(plugin.id); - const mp = manager.settings.Plugins.find(p => p.id === plugin.id); - if (mp) mp.enabled = false; - await manager.savePluginAndExport(plugin.id); - } - Commands(app, manager); - } - }); - }); - } - } -} - -export default Commands +import { + App, + Command, + Notice, + PluginManifest, + SuggestModal, + normalizePath, + setIcon, +} from "obsidian"; +import Manager from "./main"; +import { ManagerModal } from "./modal/manager-modal"; +import { TroubleshootModal } from "./troubleshoot/troubleshoot-modal"; +import { BPM_IGNORE_TAG, ManagerPlugin } from "./data/types"; +import { managerOpen } from "./utils"; +import { ObsidianAppWithInternals, VaultAdapterWithBasePath } from "./obsidian-internals"; +import { PluginCommandProfile, PluginCommandState } from "./settings/data"; + +type CommandManagerLike = { + commands?: Record; + removeCommand?: (id: string) => void; +}; + +type PluginActionId = + | "toggle" + | "enable" + | "disable" + | "single-start" + | "restart" + | "open-settings" + | "open-dir" + | "open-repo" + | "copy-id"; + +type PluginAction = { + id: PluginActionId; + label: string; + icon: string; + disabled?: boolean; +}; + +const commandServices = new WeakMap(); + +class ManagerCommandService { + private readonly app: App; + private readonly manager: Manager; + private staticCommandIds = new Set(); + private staticLanguage = ""; + private dynamicCommandIds = new Set(); + private running = new Set(); + + constructor(app: App, manager: Manager) { + this.app = app; + this.manager = manager; + } + + refresh() { + this.refreshStaticCommands(); + this.refreshDynamicCommands(); + } + + openPluginControl() { + new PluginControlModal(this.app, this).open(); + } + + openProfileNameModal() { + new ProfileNameModal(this.app, this).open(); + } + + getTranslator() { + return this.manager.translator; + } + + getPluginManifests(): PluginManifest[] { + return Object.values(this.manager.appPlugins.manifests || {}) + .filter((plugin) => plugin.id !== this.manager.manifest.id) + .sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)); + } + + getManagerPlugin(pluginId: string): ManagerPlugin | undefined { + return this.manager.settings.Plugins.find((plugin) => plugin.id === pluginId); + } + + isPluginEnabled(pluginId: string): boolean { + const managerPlugin = this.getManagerPlugin(pluginId); + if (this.manager.settings.DELAY && managerPlugin && !managerPlugin.tags.includes(BPM_IGNORE_TAG)) { + return managerPlugin.enabled; + } + return this.manager.appPlugins.enabledPlugins.has(pluginId); + } + + isActionablePlugin(pluginId: string): boolean { + if (pluginId === this.manager.manifest.id) return false; + if (!this.manager.appPlugins.manifests[pluginId]) return false; + const managerPlugin = this.getManagerPlugin(pluginId); + return !managerPlugin?.tags?.includes(BPM_IGNORE_TAG); + } + + getPluginActions(pluginId: string): PluginAction[] { + const enabled = this.isPluginEnabled(pluginId); + const isActionable = this.isActionablePlugin(pluginId); + return [ + { + id: "toggle", + label: enabled + ? this.t("command_action_disable") + : this.t("command_action_enable"), + icon: enabled ? "power-off" : "power", + disabled: !isActionable, + }, + { id: "enable", label: this.t("command_action_enable"), icon: "power", disabled: !isActionable || enabled }, + { id: "disable", label: this.t("command_action_disable"), icon: "power-off", disabled: !isActionable || !enabled }, + { + id: "single-start", + label: this.t("command_action_single_start"), + icon: "repeat-1", + disabled: this.manager.settings.DELAY || !isActionable || enabled, + }, + { + id: "restart", + label: this.t("command_action_restart"), + icon: "refresh-ccw", + disabled: this.manager.settings.DELAY || !isActionable || !enabled, + }, + { id: "open-settings", label: this.t("command_action_open_settings"), icon: "settings", disabled: !enabled }, + { id: "open-dir", label: this.t("command_action_open_dir"), icon: "folder-open" }, + { id: "open-repo", label: this.t("command_action_open_repo"), icon: "github" }, + { id: "copy-id", label: this.t("command_action_copy_id"), icon: "copy" }, + ]; + } + + async runPluginAction(pluginId: string, actionId: PluginActionId) { + const manifest = this.manager.appPlugins.manifests[pluginId]; + if (!manifest) { + new Notice(this.t("command_notice_missing_plugin", { id: pluginId })); + return; + } + + switch (actionId) { + case "toggle": + await this.togglePlugin(pluginId); + break; + case "enable": + await this.setPluginEnabled(pluginId, true); + break; + case "disable": + await this.setPluginEnabled(pluginId, false); + break; + case "single-start": + await this.singleStartPlugin(pluginId); + break; + case "restart": + await this.restartPlugin(pluginId); + break; + case "open-settings": + await this.openPluginSettings(pluginId); + break; + case "open-dir": + this.openPluginDir(manifest); + break; + case "open-repo": + await this.openPluginRepo(pluginId); + break; + case "copy-id": + this.copyPluginId(pluginId); + break; + } + } + + async togglePlugin(pluginId: string) { + await this.setPluginEnabled(pluginId, !this.isPluginEnabled(pluginId)); + } + + async setPluginEnabled(pluginId: string, targetEnabled: boolean) { + if (!this.isActionablePlugin(pluginId)) { + new Notice(this.t("command_notice_not_actionable")); + return; + } + await this.runLocked(`plugin:${pluginId}`, async () => { + this.capturePreviousState(this.t("command_snapshot_plugin", { name: this.getPluginName(pluginId) })); + const changed = await this.setPluginEnabledInternal(pluginId, targetEnabled); + await this.manager.saveSettings(); + if (changed) { + this.refreshAfterStatusChange([pluginId]); + new Notice(targetEnabled + ? this.t("command_notice_plugin_enabled", { name: this.getPluginName(pluginId) }) + : this.t("command_notice_plugin_disabled", { name: this.getPluginName(pluginId) })); + } + }); + } + + async applyGroup(groupId: string, targetEnabled: boolean) { + const group = this.manager.settings.GROUPS.find((item) => item.id === groupId); + const label = group?.name || groupId; + const plugins = this.getActionableManagerPlugins((plugin) => plugin.group === groupId); + await this.applyPluginsEnabled(plugins, targetEnabled, this.t("command_snapshot_group", { name: label })); + } + + async applyTag(tagId: string, targetEnabled: boolean) { + const tag = this.manager.settings.TAGS.find((item) => item.id === tagId); + const label = tag?.name || tagId; + const plugins = this.getActionableManagerPlugins((plugin) => plugin.tags.includes(tagId)); + await this.applyPluginsEnabled(plugins, targetEnabled, this.t("command_snapshot_tag", { name: label })); + } + + async saveCurrentProfile(name: string) { + const profileName = name.trim(); + if (!profileName) { + new Notice(this.t("command_notice_profile_name_required")); + return; + } + + const now = Date.now(); + const existing = this.manager.settings.COMMAND_PROFILES.find((profile) => profile.name === profileName); + const profile: PluginCommandProfile = existing || { + id: `${this.slugify(profileName)}-${now}`, + name: profileName, + pluginStates: {}, + createdAt: now, + }; + profile.name = profileName; + profile.pluginStates = this.captureCurrentState(); + profile.updatedAt = now; + + if (!existing) this.manager.settings.COMMAND_PROFILES.push(profile); + await this.manager.saveSettings(); + this.refresh(); + new Notice(this.t("command_notice_profile_saved", { name: profile.name })); + } + + async applyProfile(profileId: string) { + const profile = this.manager.settings.COMMAND_PROFILES.find((item) => item.id === profileId); + if (!profile) { + new Notice(this.t("command_notice_profile_missing")); + return; + } + await this.applyPluginStateMap(profile.pluginStates, this.t("command_snapshot_profile", { name: profile.name })); + } + + async restorePreviousState() { + const snapshot = this.manager.settings.COMMAND_LAST_STATE; + if (!snapshot) { + new Notice(this.t("command_notice_no_snapshot")); + return; + } + await this.applyPluginStateMap(snapshot.pluginStates, this.t("command_snapshot_restore"), true); + } + + private refreshStaticCommands() { + const language = this.manager.settings.LANGUAGE || ""; + if (this.staticCommandIds.size > 0 && this.staticLanguage === language) return; + this.removeStaticCommands(); + this.staticLanguage = language; + this.registerStaticCommands(); + } + + private registerStaticCommands() { + this.addStaticCommand({ + id: "manager-view", + name: this.manager.translator.t("命令_管理面板_描述"), + callback: () => { + this.manager.managerModal = new ManagerModal(this.app, this.manager); + this.manager.managerModal.open(); + }, + }); + + this.addStaticCommand({ + id: "control-plugin", + name: this.t("command_control_plugin"), + callback: () => this.openPluginControl(), + }); + + this.addStaticCommand({ + id: "save-command-profile", + name: this.t("command_save_profile"), + callback: () => this.openProfileNameModal(), + }); + + this.addStaticCommand({ + id: "restore-previous-command-state", + name: this.t("command_restore_previous_state"), + checkCallback: (checking) => { + const canRestore = Boolean(this.manager.settings.COMMAND_LAST_STATE); + if (checking) return canRestore; + if (canRestore) void this.restorePreviousState(); + return canRestore; + }, + }); + + this.addStaticCommand({ + id: "troubleshoot-conflicts", + name: this.manager.translator.t("排查_按钮_描述"), + callback: () => { new TroubleshootModal(this.app, this.manager).open(); }, + }); + } + + private refreshDynamicCommands() { + this.removeDynamicCommands(); + if (this.manager.settings.COMMAND_ITEM) this.registerPluginCommands(); + if (this.manager.settings.COMMAND_GROUP) this.registerGroupCommands(); + if (this.manager.settings.COMMAND_TAG) this.registerTagCommands(); + if (this.manager.settings.COMMAND_PROFILE) this.registerProfileCommands(); + } + + private registerPluginCommands() { + this.getPluginManifests() + .filter((plugin) => this.isActionablePlugin(plugin.id)) + .forEach((plugin) => { + const enabled = this.isPluginEnabled(plugin.id); + this.addDynamicCommand({ + id: `manager-${plugin.id}`, + name: `${enabled ? this.manager.translator.t("命令行_禁用_文本") : this.manager.translator.t("命令行_启用_文本")} ${plugin.name || plugin.id}`, + callback: () => { void this.togglePlugin(plugin.id); }, + }); + }); + } + + private registerGroupCommands() { + this.manager.settings.GROUPS.forEach((group) => { + const name = group.name || group.id; + this.addDynamicCommand({ + id: `manager-${group.id}-enabled`, + name: `${this.manager.translator.t("命令行_一键启用_文本")} ${name} ${this.manager.translator.t("命令行_分组_文本")}`, + callback: () => { void this.applyGroup(group.id, true); }, + }); + this.addDynamicCommand({ + id: `manager-${group.id}-disable`, + name: `${this.manager.translator.t("命令行_一键禁用_文本")} ${name} ${this.manager.translator.t("命令行_分组_文本")}`, + callback: () => { void this.applyGroup(group.id, false); }, + }); + }); + } + + private registerTagCommands() { + this.manager.settings.TAGS + .filter((tag) => tag.id !== BPM_IGNORE_TAG) + .forEach((tag) => { + const name = tag.name || tag.id; + const commandId = this.safeCommandPart(tag.id); + this.addDynamicCommand({ + id: `manager-tag-${commandId}-enabled`, + name: `${this.t("command_enable_tag")} ${name}`, + callback: () => { void this.applyTag(tag.id, true); }, + }); + this.addDynamicCommand({ + id: `manager-tag-${commandId}-disable`, + name: `${this.t("command_disable_tag")} ${name}`, + callback: () => { void this.applyTag(tag.id, false); }, + }); + }); + } + + private registerProfileCommands() { + this.manager.settings.COMMAND_PROFILES.forEach((profile) => { + this.addDynamicCommand({ + id: `manager-profile-${this.safeCommandPart(profile.id)}-apply`, + name: `${this.t("command_apply_profile")} ${profile.name}`, + callback: () => { void this.applyProfile(profile.id); }, + }); + }); + } + + private addDynamicCommand(command: Command) { + const registered = this.manager.addCommand(command); + this.dynamicCommandIds.add(registered.id); + this.dynamicCommandIds.add(this.fullCommandId(command.id)); + } + + private addStaticCommand(command: Command) { + const registered = this.manager.addCommand(command); + this.staticCommandIds.add(registered.id); + this.staticCommandIds.add(this.fullCommandId(command.id)); + } + + private removeStaticCommands() { + this.removeCommands(this.staticCommandIds); + } + + private removeDynamicCommands() { + this.removeCommands(this.dynamicCommandIds); + } + + private removeCommands(commandIds: Set) { + const commandManager = (this.app as unknown as { commands?: CommandManagerLike }).commands; + commandIds.forEach((id) => { + try { + commandManager?.removeCommand?.(id); + } catch { + // ignore unsupported internal command manager variants + } + if (commandManager?.commands) delete commandManager.commands[id]; + }); + commandIds.clear(); + } + + private async applyPluginsEnabled(plugins: ManagerPlugin[], targetEnabled: boolean, label: string) { + if (plugins.length === 0) { + new Notice(this.t("command_notice_no_actionable_plugins")); + return; + } + const states: PluginCommandState = {}; + plugins.forEach((plugin) => { states[plugin.id] = targetEnabled; }); + await this.applyPluginStateMap(states, label); + } + + private async applyPluginStateMap(states: PluginCommandState, label: string, restoring = false) { + const entries = Object.entries(states).filter(([pluginId]) => this.isActionablePlugin(pluginId)); + if (entries.length === 0) { + new Notice(this.t("command_notice_no_actionable_plugins")); + return; + } + + await this.runLocked(`bulk:${label}`, async () => { + this.capturePreviousState(restoring ? this.t("command_snapshot_before_restore") : label); + const progress = new Notice(`${this.t("command_notice_applying")} 0/${entries.length}`, 0); + let changedCount = 0; + let processed = 0; + const changedIds: string[] = []; + try { + for (const [pluginId, targetEnabled] of entries) { + const changed = await this.setPluginEnabledInternal(pluginId, targetEnabled); + if (changed) { + changedCount++; + changedIds.push(pluginId); + } + processed++; + progress.setMessage(`${this.t("command_notice_applying")} ${processed}/${entries.length} · ${pluginId}`); + } + } finally { + progress.hide(); + } + await this.manager.saveSettings(); + this.refreshAfterStatusChange(changedIds); + new Notice(this.t("command_notice_bulk_done", { count: changedCount })); + }); + } + + private async setPluginEnabledInternal(pluginId: string, targetEnabled: boolean): Promise { + const current = this.isPluginEnabled(pluginId); + const managerPlugin = this.getManagerPlugin(pluginId); + if (current === targetEnabled) { + if (managerPlugin) managerPlugin.enabled = targetEnabled; + return false; + } + + if (this.manager.settings.DELAY && managerPlugin && !managerPlugin.tags.includes(BPM_IGNORE_TAG)) { + managerPlugin.enabled = targetEnabled; + if (targetEnabled) await this.manager.appPlugins.enablePlugin(pluginId); + else await this.manager.appPlugins.disablePlugin(pluginId); + } else { + if (targetEnabled) await this.manager.appPlugins.enablePluginAndSave(pluginId); + else await this.manager.appPlugins.disablePluginAndSave(pluginId); + if (managerPlugin) managerPlugin.enabled = targetEnabled; + } + return true; + } + + private async singleStartPlugin(pluginId: string) { + if (this.manager.settings.DELAY || !this.isActionablePlugin(pluginId) || this.isPluginEnabled(pluginId)) return; + await this.runLocked(`single:${pluginId}`, async () => { + this.capturePreviousState(this.t("command_snapshot_single_start", { name: this.getPluginName(pluginId) })); + new Notice(this.manager.translator.t("管理器_单次启动中_提示")); + await this.manager.appPlugins.enablePlugin(pluginId); + this.refreshAfterStatusChange([pluginId]); + }); + } + + private async restartPlugin(pluginId: string) { + if (this.manager.settings.DELAY || !this.isActionablePlugin(pluginId) || !this.isPluginEnabled(pluginId)) return; + await this.runLocked(`restart:${pluginId}`, async () => { + this.capturePreviousState(this.t("command_snapshot_restart", { name: this.getPluginName(pluginId) })); + new Notice(this.manager.translator.t("管理器_重启中_提示")); + await this.manager.appPlugins.disablePluginAndSave(pluginId); + await this.manager.appPlugins.enablePluginAndSave(pluginId); + this.refreshAfterStatusChange([pluginId]); + }); + } + + private async openPluginSettings(pluginId: string) { + if (!this.isPluginEnabled(pluginId)) { + new Notice(this.t("command_notice_enable_before_settings")); + return; + } + const appSetting = (this.app as ObsidianAppWithInternals).setting; + await appSetting.open(); + await appSetting.openTabById(pluginId); + } + + private openPluginDir(plugin: PluginManifest) { + const getBasePath = (this.app.vault.adapter as VaultAdapterWithBasePath).getBasePath?.(); + const basePath = getBasePath ? normalizePath(getBasePath) : ""; + const cfgDir = this.app.vault.configDir; + const rawDir = plugin.dir || `plugins/${plugin.id}`; + const isAbsolute = new RegExp("^(?:[a-zA-Z]:[\\\\/]|[\\\\/])").test(rawDir); + let pluginDir: string; + if (isAbsolute) { + pluginDir = normalizePath(rawDir); + } else if (rawDir.startsWith(cfgDir) || rawDir.startsWith(".") || rawDir.startsWith("/")) { + pluginDir = normalizePath(`${basePath}/${rawDir}`); + } else { + pluginDir = normalizePath(`${basePath}/${cfgDir}/${rawDir}`); + } + managerOpen(pluginDir, this.manager); + } + + private async openPluginRepo(pluginId: string) { + const repo = this.manager.settings.REPO_MAP?.[pluginId] || await this.manager.repoResolver.resolveRepo(pluginId); + if (repo) { + window.open(`https://github.com/${repo}`); + return; + } + const isBpmInstall = this.manager.settings.BPM_INSTALLED.includes(pluginId); + new Notice(isBpmInstall + ? this.manager.translator.t("管理器_仓库未记录_提示") + : this.manager.translator.t("管理器_仓库需手动添加_提示")); + } + + private copyPluginId(pluginId: string) { + void navigator.clipboard.writeText(pluginId); + new Notice(this.manager.translator.t("通知_ID已复制")); + } + + private captureCurrentState(): PluginCommandState { + const state: PluginCommandState = {}; + this.getPluginManifests() + .filter((plugin) => this.isActionablePlugin(plugin.id)) + .forEach((plugin) => { state[plugin.id] = this.isPluginEnabled(plugin.id); }); + return state; + } + + private capturePreviousState(label: string) { + this.manager.settings.COMMAND_LAST_STATE = { + pluginStates: this.captureCurrentState(), + createdAt: Date.now(), + label, + }; + } + + private getActionableManagerPlugins(predicate: (plugin: ManagerPlugin) => boolean): ManagerPlugin[] { + return this.manager.settings.Plugins + .filter((plugin) => predicate(plugin)) + .filter((plugin) => this.isActionablePlugin(plugin.id)); + } + + private refreshAfterStatusChange(pluginIds: string[]) { + this.refresh(); + try { + if (pluginIds.length === 0) { + void this.manager.managerModal?.reloadShowData(); + return; + } + pluginIds.forEach((pluginId) => this.manager.managerModal?.refreshPluginCard(pluginId, { allowReload: true })); + } catch { + // UI may not be open + } + } + + private async runLocked(key: string, task: () => Promise) { + if (this.running.has(key)) return; + this.running.add(key); + try { + await task(); + } catch (error) { + console.error("[BPM] command failed", key, error); + new Notice(this.t("command_notice_failed")); + } finally { + this.running.delete(key); + } + } + + private getPluginName(pluginId: string): string { + const manifest = this.manager.appPlugins.manifests[pluginId]; + const managerPlugin = this.getManagerPlugin(pluginId); + return managerPlugin?.name || manifest?.name || pluginId; + } + + private fullCommandId(id: string): string { + return `${this.manager.manifest.id}:${id}`; + } + + private safeCommandPart(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/g, "-"); + } + + private slugify(name: string): string { + const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + return slug || "profile"; + } + + private t(key: string, vars?: Record): string { + return this.manager.translator.t(key, vars); + } +} + +class PluginControlModal extends SuggestModal { + private readonly service: ManagerCommandService; + + constructor(app: App, service: ManagerCommandService) { + super(app); + this.service = service; + this.setPlaceholder(service.getTranslator().t("command_control_placeholder")); + this.emptyStateText = service.getTranslator().t("command_control_empty"); + } + + getSuggestions(query: string): PluginManifest[] { + const lower = query.trim().toLowerCase(); + return this.service.getPluginManifests() + .filter((plugin) => { + if (!lower) return true; + return `${plugin.name} ${plugin.id} ${plugin.description || ""}`.toLowerCase().includes(lower); + }) + .slice(0, 50); + } + + renderSuggestion(plugin: PluginManifest, el: HTMLElement) { + const row = el.createDiv({ cls: "manager-command-suggestion" }); + const icon = row.createSpan({ cls: "manager-command-suggestion__icon" }); + setIcon(icon, this.service.isPluginEnabled(plugin.id) ? "toggle-right" : "toggle-left"); + const text = row.createDiv({ cls: "manager-command-suggestion__text" }); + text.createDiv({ cls: "manager-command-suggestion__title", text: plugin.name || plugin.id }); + text.createDiv({ cls: "manager-command-suggestion__meta", text: plugin.id }); + } + + onChooseSuggestion(plugin: PluginManifest) { + new PluginActionModal(this.app, this.service, plugin).open(); + } +} + +class PluginActionModal extends SuggestModal { + private readonly service: ManagerCommandService; + private readonly plugin: PluginManifest; + + constructor(app: App, service: ManagerCommandService, plugin: PluginManifest) { + super(app); + this.service = service; + this.plugin = plugin; + this.setPlaceholder(service.getTranslator().t("command_action_placeholder", { name: plugin.name || plugin.id })); + this.emptyStateText = service.getTranslator().t("command_control_empty"); + } + + getSuggestions(query: string): PluginAction[] { + const lower = query.trim().toLowerCase(); + return this.service.getPluginActions(this.plugin.id) + .filter((action) => !lower || action.label.toLowerCase().includes(lower)); + } + + renderSuggestion(action: PluginAction, el: HTMLElement) { + const row = el.createDiv({ cls: "manager-command-suggestion" }); + const icon = row.createSpan({ cls: "manager-command-suggestion__icon" }); + setIcon(icon, action.icon); + const text = row.createDiv({ cls: "manager-command-suggestion__text" }); + text.createDiv({ cls: "manager-command-suggestion__title", text: action.label }); + text.createDiv({ + cls: "manager-command-suggestion__meta", + text: action.disabled + ? this.service.getTranslator().t("command_action_disabled") + : this.plugin.id, + }); + if (action.disabled) row.addClass("is-disabled"); + } + + onChooseSuggestion(action: PluginAction) { + if (action.disabled) { + new Notice(this.service.getTranslator().t("command_action_disabled")); + return; + } + void this.service.runPluginAction(this.plugin.id, action.id); + } +} + +class ProfileNameModal extends SuggestModal { + private readonly service: ManagerCommandService; + + constructor(app: App, service: ManagerCommandService) { + super(app); + this.service = service; + this.setPlaceholder(service.getTranslator().t("command_profile_name_placeholder")); + this.emptyStateText = service.getTranslator().t("command_profile_name_empty"); + } + + getSuggestions(query: string): string[] { + const value = query.trim(); + if (!value) return []; + return [value]; + } + + renderSuggestion(value: string, el: HTMLElement) { + el.createDiv({ text: this.service.getTranslator().t("command_profile_save_as", { name: value }) }); + } + + onChooseSuggestion(value: string) { + void this.service.saveCurrentProfile(value); + } +} + +const Commands = (app: App, manager: Manager) => { + let service = commandServices.get(manager); + if (!service) { + service = new ManagerCommandService(app, manager); + commandServices.set(manager, service); + } + service.refresh(); +}; + +export default Commands; diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index 2ae9f7e..1a19823 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -264,9 +264,13 @@ export default { 设置_基础设置_筛选持久化_描述: 'After enabling, you will see the same plugin list every time you open the manager.', 设置_基础设置_单独命令_标题: 'Control Plugin Commands Separately', - 设置_基础设置_单独命令_描述: 'Enable this option to control the enabled and disabled state of each plugin separately. (Restart Obsidian to take effect)', + 设置_基础设置_单独命令_描述: 'Enable direct command palette toggles for individual plugins.', 设置_基础设置_分组命令_标题: 'Control Plugin Commands by Group', - 设置_基础设置_分组命令_描述: 'Enable this option to enable or disable all plugins in a specified group with one click. (Restart Obsidian to take effect)', + 设置_基础设置_分组命令_描述: 'Enable direct command palette actions for enabling or disabling a whole group.', + command_setting_tag_title: 'Control Plugin Commands by Tag', + command_setting_tag_desc: 'Enable direct command palette actions for enabling or disabling plugins by tag.', + command_setting_profile_title: 'Plugin Profile Commands', + command_setting_profile_desc: 'Register direct apply commands for saved plugin state profiles.', 设置_主页面功能_标题: 'Main Page Actions', 设置_主页面功能_描述: 'Choose whether each plugin action on the manager page is shown directly on the item or kept in the context menu.', @@ -312,6 +316,48 @@ export default { 通知_获取版本中文案: 'Fetching remote version info…', 命令_管理面板_描述: 'Open the plugin manager', + command_control_plugin: 'Control a plugin', + command_control_placeholder: 'Search plugin to control...', + command_control_empty: 'No matching plugin', + command_action_placeholder: 'Choose action for {name}', + command_action_enable: 'Enable', + command_action_disable: 'Disable', + command_action_single_start: 'Single start', + command_action_restart: 'Restart plugin', + command_action_open_settings: 'Open settings', + command_action_open_dir: 'Open plugin folder', + command_action_open_repo: 'Open repository', + command_action_copy_id: 'Copy plugin ID', + command_action_disabled: 'Unavailable for the current plugin state', + command_enable_tag: 'Enable tag', + command_disable_tag: 'Disable tag', + command_save_profile: 'Save current plugin state as profile', + command_restore_previous_state: 'Restore previous plugin state', + command_apply_profile: 'Apply plugin profile', + command_profile_name_placeholder: 'Profile name...', + command_profile_name_empty: 'Type a profile name', + command_profile_save_as: 'Save profile: {name}', + command_notice_profile_saved: 'Saved plugin profile: {name}', + command_notice_profile_missing: 'Plugin profile no longer exists', + command_notice_profile_name_required: 'Profile name is required', + command_notice_no_snapshot: 'No previous plugin state to restore', + command_notice_missing_plugin: 'Plugin not found: {id}', + command_notice_not_actionable: 'This plugin is not controlled by BPM', + command_notice_no_actionable_plugins: 'No actionable plugins found', + command_notice_applying: 'Applying plugin changes', + command_notice_bulk_done: 'Updated {count} plugins', + command_notice_plugin_enabled: 'Enabled {name}', + command_notice_plugin_disabled: 'Disabled {name}', + command_notice_enable_before_settings: 'Enable this plugin before opening its settings', + command_notice_failed: 'Command failed. Check the console for details.', + command_snapshot_plugin: 'Plugin command: {name}', + command_snapshot_group: 'Group command: {name}', + command_snapshot_tag: 'Tag command: {name}', + command_snapshot_profile: 'Profile command: {name}', + command_snapshot_restore: 'Restore previous plugin state', + command_snapshot_before_restore: 'Before restore', + command_snapshot_single_start: 'Single start: {name}', + command_snapshot_restart: 'Restart: {name}', 标签_BPM安装_名称: 'bpm install', 标签_Eondr插件_名称: 'Eondr plugin', 管理器_下载更新_描述: 'Download an update (choose version, incl. pre-release)', diff --git a/src/lang/locale/zh_cn.ts b/src/lang/locale/zh_cn.ts index 7e6fa44..274d695 100644 --- a/src/lang/locale/zh_cn.ts +++ b/src/lang/locale/zh_cn.ts @@ -266,9 +266,13 @@ export default { 设置_基础设置_筛选持久化_描述: '启用后,您将在每次打开管理器时看到相同的插件列表。', 设置_基础设置_单独命令_标题: '单独控制插件命令', - 设置_基础设置_单独命令_描述: '启用此选项可以单独控制每个插件的启用和禁用状态。(重启Obsidian生效)', + 设置_基础设置_单独命令_描述: '在命令面板中为每个插件注册独立启用/禁用命令。', 设置_基础设置_分组命令_标题: '分组控制插件命令', - 设置_基础设置_分组命令_描述: '启用此选项可以一键启用或禁用指定分组中的所有插件。(重启Obsidian生效)', + 设置_基础设置_分组命令_描述: '在命令面板中为每个分组注册一键启用/禁用命令。', + command_setting_tag_title: '标签控制插件命令', + command_setting_tag_desc: '在命令面板中按标签一键启用或禁用插件。', + command_setting_profile_title: '插件 Profile 命令', + command_setting_profile_desc: '为已保存的插件状态方案注册一键应用命令。', 标签_BPM安装_名称: 'BPM 安装', 标签_Eondr插件_名称: 'Eondr 出品', @@ -316,6 +320,48 @@ export default { 通知_获取版本中文案: '正在获取云端版本信息…', 命令_管理面板_描述: '开启插件管理器', + command_control_plugin: '控制插件', + command_control_placeholder: '搜索要控制的插件...', + command_control_empty: '没有匹配的插件', + command_action_placeholder: '选择 {name} 的操作', + command_action_enable: '启用', + command_action_disable: '禁用', + command_action_single_start: '单次启动', + command_action_restart: '重启插件', + command_action_open_settings: '打开设置', + command_action_open_dir: '打开插件目录', + command_action_open_repo: '打开仓库', + command_action_copy_id: '复制插件 ID', + command_action_disabled: '当前插件状态不可用', + command_enable_tag: '启用标签', + command_disable_tag: '禁用标签', + command_save_profile: '保存当前插件状态为 Profile', + command_restore_previous_state: '恢复上一次插件状态', + command_apply_profile: '应用插件 Profile', + command_profile_name_placeholder: 'Profile 名称...', + command_profile_name_empty: '输入一个 Profile 名称', + command_profile_save_as: '保存 Profile:{name}', + command_notice_profile_saved: '已保存插件 Profile:{name}', + command_notice_profile_missing: '插件 Profile 不存在', + command_notice_profile_name_required: '请输入 Profile 名称', + command_notice_no_snapshot: '没有可恢复的上一次插件状态', + command_notice_missing_plugin: '未找到插件:{id}', + command_notice_not_actionable: '此插件不由 BPM 控制', + command_notice_no_actionable_plugins: '没有可操作的插件', + command_notice_applying: '正在应用插件变更', + command_notice_bulk_done: '已更新 {count} 个插件', + command_notice_plugin_enabled: '已启用 {name}', + command_notice_plugin_disabled: '已禁用 {name}', + command_notice_enable_before_settings: '请先启用此插件再打开设置', + command_notice_failed: '命令执行失败,请查看控制台。', + command_snapshot_plugin: '插件命令:{name}', + command_snapshot_group: '分组命令:{name}', + command_snapshot_tag: '标签命令:{name}', + command_snapshot_profile: 'Profile 命令:{name}', + command_snapshot_restore: '恢复上一次插件状态', + command_snapshot_before_restore: '恢复前状态', + command_snapshot_single_start: '单次启动:{name}', + command_snapshot_restart: '重启:{name}', 管理器_下载更新_描述: '下载指定版本更新(支持预发布)', 安装_成功_提示: '已安装/更新插件:{name}', 安装_错误_限速: 'GitHub 请求受限(403),请配置 GitHub Token 后重试。', diff --git a/src/settings/data.ts b/src/settings/data.ts index e83da16..92bfb2d 100644 --- a/src/settings/data.ts +++ b/src/settings/data.ts @@ -26,6 +26,21 @@ export type TagFilterOperator = FilterOperator; export type PluginOverviewLayout = "list" | "two-column"; export type PluginUpdateCheckMode = NonNullable; export type ReleaseCompatibilityMode = NonNullable; +export type PluginCommandState = Record; + +export interface PluginCommandSnapshot { + pluginStates: PluginCommandState; + createdAt: number; + label?: string; +} + +export interface PluginCommandProfile { + id: string; + name: string; + pluginStates: PluginCommandState; + createdAt: number; + updatedAt?: number; +} export const DEFAULT_MAIN_PAGE_ACTION_PLACEMENT: Record = { checkUpdate: "menu", @@ -76,6 +91,14 @@ export interface ManagerSettings { COMMAND_ITEM: boolean; /** 是否为每个分组注册批量启用/禁用命令。 */ COMMAND_GROUP: boolean; + /** 是否为每个标签注册批量启用/禁用命令。 */ + COMMAND_TAG: boolean; + /** 是否为保存的插件状态方案注册一键应用命令。 */ + COMMAND_PROFILE: boolean; + /** 命令面板保存的插件状态方案。 */ + COMMAND_PROFILES: PluginCommandProfile[]; + /** 命令面板批量操作前的上一份状态快照,用于撤销。 */ + COMMAND_LAST_STATE?: PluginCommandSnapshot; /** 是否在插件启动后自动检查插件更新。 */ STARTUP_CHECK_UPDATES: boolean; /** 是否在插件启动后自动检查 GitHub 来源订阅的远程版本。 */ @@ -187,6 +210,9 @@ export const DEFAULT_SETTINGS: ManagerSettings = { SELF_CHECK_IGNORED: false, COMMAND_ITEM: false, COMMAND_GROUP: false, + COMMAND_TAG: false, + COMMAND_PROFILE: false, + COMMAND_PROFILES: [], STARTUP_CHECK_UPDATES: false, SOURCE_STARTUP_CHECK_UPDATES: false, SOURCE_AUTO_UPDATE: true, diff --git a/src/settings/ui/manager-basis.ts b/src/settings/ui/manager-basis.ts index e20b451..f901380 100644 --- a/src/settings/ui/manager-basis.ts +++ b/src/settings/ui/manager-basis.ts @@ -179,6 +179,28 @@ export default class ManagerBasis extends BaseSetting { Commands(this.app, this.manager); }); + const CommandTagBar = new Setting(this.containerEl) + .setName(this.manager.translator.t("command_setting_tag_title")) + .setDesc(this.manager.translator.t("command_setting_tag_desc")); + const CommandTagToggle = new ToggleComponent(CommandTagBar.controlEl); + CommandTagToggle.setValue(this.settings.COMMAND_TAG); + CommandTagToggle.onChange((value) => { + this.settings.COMMAND_TAG = value; + void this.manager.saveSettings(); + Commands(this.app, this.manager); + }); + + const CommandProfileBar = new Setting(this.containerEl) + .setName(this.manager.translator.t("command_setting_profile_title")) + .setDesc(this.manager.translator.t("command_setting_profile_desc")); + const CommandProfileToggle = new ToggleComponent(CommandProfileBar.controlEl); + CommandProfileToggle.setValue(this.settings.COMMAND_PROFILE); + CommandProfileToggle.onChange((value) => { + this.settings.COMMAND_PROFILE = value; + void this.manager.saveSettings(); + Commands(this.app, this.manager); + }); + heading("设置_基础设置_分组_开发网络"); const debugBar = new Setting(this.containerEl) diff --git a/styles.css b/styles.css index 6f1c550..0a54bc0 100644 --- a/styles.css +++ b/styles.css @@ -12171,3 +12171,36 @@ body.theme-dark .manager-transfer-segment[data-state="active"]:hover { .manager-repo-page .manager-install__segment { padding: 4px 8px !important; } + +.manager-command-suggestion { + align-items: center; + display: flex; + gap: 10px; + min-height: 38px; +} + +.manager-command-suggestion.is-disabled { + opacity: 0.55; +} + +.manager-command-suggestion__icon { + color: var(--text-muted); + display: inline-flex; + flex: 0 0 auto; +} + +.manager-command-suggestion__text { + min-width: 0; +} + +.manager-command-suggestion__title, +.manager-command-suggestion__meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.manager-command-suggestion__meta { + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} diff --git a/version-bump.mjs b/version-bump.mjs index 7e747f0..18fee4a 100644 --- a/version-bump.mjs +++ b/version-bump.mjs @@ -10,3 +10,8 @@ writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); let versions = JSON.parse(readFileSync("versions.json", "utf8")); versions[targetVersion] = minAppVersion; writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); + +let packageLock = JSON.parse(readFileSync("package-lock.json", "utf8")); +packageLock.version = targetVersion; +if (packageLock.packages?.[""]) packageLock.packages[""].version = targetVersion; +writeFileSync("package-lock.json", JSON.stringify(packageLock, null, 2));