feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,438 @@
|
||||
import { State } from '../reducer';
|
||||
import eventManager, { EventListenerCallback, EventName } from '../eventManager';
|
||||
import BaseService from './BaseService';
|
||||
import shim from '../shim';
|
||||
import WhenClause from './WhenClause';
|
||||
import type { WhenClauseContext, WhenClauseContextOptions } from './commands/stateToWhenClauseContext';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
type LabelFunction = ()=> string;
|
||||
type EnabledCondition = string;
|
||||
type VisibleCondition = string;
|
||||
|
||||
export type MenuItemRole = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'pasteAndMatchStyle' | 'delete' | 'selectAll' | 'reload' | 'forceReload' | 'toggleDevTools' | 'resetZoom' | 'zoomIn' | 'zoomOut' | 'toggleSpellChecker' | 'togglefullscreen' | 'window' | 'minimize' | 'close' | 'help' | 'about' | 'services' | 'hide' | 'hideOthers' | 'unhide' | 'quit' | 'showSubstitutions' | 'toggleSmartQuotes' | 'toggleSmartDashes' | 'toggleTextReplacement' | 'startSpeaking' | 'stopSpeaking' | 'zoom' | 'front' | 'appMenu' | 'fileMenu' | 'editMenu' | 'viewMenu' | 'shareMenu' | 'recentDocuments' | 'toggleTabBar' | 'selectNextTab' | 'selectPreviousTab' | 'showAllTabs' | 'mergeAllWindows' | 'clearRecentDocuments' | 'moveTabToNewWindow' | 'windowMenu';
|
||||
|
||||
export interface CommandContext {
|
||||
// The state may also be of type "AppState" (used by the desktop app), which inherits from "State" (used by all apps)
|
||||
state: State;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
export interface CommandRuntime {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each command has its own args/return shape; tightening forces a generic across every CommandRuntime declaration
|
||||
execute(context: CommandContext, ...args: any[]): Promise<any | void>;
|
||||
enabledCondition?: EnabledCondition;
|
||||
// Used for toolbar button visibility state
|
||||
visibleCondition?: VisibleCondition;
|
||||
// Used for the (optional) toolbar button title
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- State is lib's State for cli but app-desktop runtimes type this as AppState; narrowing here breaks downstream contravariance
|
||||
mapStateToTitle?(state: any): string;
|
||||
// Used to break ties when commands are registered by different components.
|
||||
// `state` should be the main app state. `targetWindowId` should be the window ID
|
||||
// the command should run in, or `null` for any window.
|
||||
getPriority?(state: State, targetWindowId: string|null): number;
|
||||
}
|
||||
|
||||
export interface CommandDeclaration {
|
||||
name: string;
|
||||
|
||||
// Used for the menu item label, and toolbar button tooltip
|
||||
label?: LabelFunction | string;
|
||||
|
||||
// Command description - if none is provided, the label will be used as description
|
||||
description?: string;
|
||||
|
||||
// This is a bit of a hack because some labels don't make much sense in isolation. For example,
|
||||
// the commmand to focus the note list is called just "Note list". This makes sense within the menu
|
||||
// but not so much within the keymap config screen, where the parent item is not displayed. Because
|
||||
// of this we have this "parentLabel()" property to clarify the meaning of the certain items.
|
||||
// For example, the focusElementNoteList will have these two properties:
|
||||
// label() => _('Note list'),
|
||||
// parentLabel() => _('Focus'),
|
||||
// Which will be displayed as "Focus: Note list" in the keymap config screen.
|
||||
parentLabel?: LabelFunction | string;
|
||||
|
||||
// All free Font Awesome icons are available: https://fontawesome.com/icons?d=gallery&m=free
|
||||
iconName?: string;
|
||||
|
||||
// Same as `role` key in Electron MenuItem:
|
||||
// https://www.electronjs.org/docs/api/menu-item#new-menuitemoptions
|
||||
// Note that due to a bug in Electron, menu items with a role cannot
|
||||
// be disabled.
|
||||
role?: MenuItemRole;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
declaration: CommandDeclaration;
|
||||
runtime?: CommandRuntime|CommandRuntime[];
|
||||
}
|
||||
|
||||
interface CommandSpec {
|
||||
declaration: CommandDeclaration;
|
||||
runtime: ()=> CommandRuntime;
|
||||
}
|
||||
|
||||
export interface ComponentCommandSpec<ComponentType> {
|
||||
declaration: CommandDeclaration;
|
||||
runtime: (component: ComponentType)=> CommandRuntime;
|
||||
}
|
||||
|
||||
interface Commands {
|
||||
[key: string]: Command;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tests and platforms pass partial stores; the desktop/mobile store also exposes additional methods (subscribe, etc.) we don't use here
|
||||
type ReduxStore = any;
|
||||
|
||||
interface Utils {
|
||||
store: ReduxStore;
|
||||
}
|
||||
|
||||
export const utils: Utils = {
|
||||
store: {
|
||||
dispatch: () => {},
|
||||
getState: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
interface CommandByNameOptions {
|
||||
mustExist?: boolean;
|
||||
runtimeMustBeRegistered?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
commandName: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface RegisteredRuntime {
|
||||
deregister: ()=> void;
|
||||
}
|
||||
|
||||
export interface ExecuteInWindowOptions {
|
||||
windowId: string|null; // null -> active window
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
export default class CommandService extends BaseService {
|
||||
|
||||
private static instance_: CommandService;
|
||||
|
||||
public static instance(): CommandService {
|
||||
if (this.instance_) return this.instance_;
|
||||
this.instance_ = new CommandService();
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
private commands_: Commands = {};
|
||||
private store_: ReduxStore;
|
||||
private devMode_: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Concrete state-to-context function is parametrised over per-app state (AppState in desktop/mobile, State in cli); typing too narrowly here breaks derived classes
|
||||
private stateToWhenClauseContext_: Function;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- See stateToWhenClauseContext_
|
||||
public initialize(store: ReduxStore, devMode: boolean, stateToWhenClauseContext: Function) {
|
||||
utils.store = store;
|
||||
this.store_ = store;
|
||||
this.devMode_ = devMode;
|
||||
this.stateToWhenClauseContext_ = stateToWhenClauseContext;
|
||||
}
|
||||
|
||||
public on<Name extends EventName>(eventName: Name, callback: EventListenerCallback<Name>) {
|
||||
eventManager.on(eventName, callback);
|
||||
}
|
||||
|
||||
public off<Name extends EventName>(eventName: Name, callback: EventListenerCallback<Name>) {
|
||||
eventManager.off(eventName, callback);
|
||||
}
|
||||
|
||||
public searchCommands(query: string, returnAllWhenEmpty: boolean, excludeWithoutLabel = true): SearchResult[] {
|
||||
query = query.toLowerCase();
|
||||
|
||||
const output = [];
|
||||
|
||||
const whenClauseContext = this.currentWhenClauseContext();
|
||||
|
||||
for (const commandName of this.commandNames()) {
|
||||
const label = this.label(commandName, true);
|
||||
if (!label && excludeWithoutLabel) continue;
|
||||
if (!this.isEnabled(commandName, whenClauseContext)) continue;
|
||||
|
||||
const title = label ? `${label} (${commandName})` : commandName;
|
||||
|
||||
if ((returnAllWhenEmpty && !query) || title.toLowerCase().includes(query)) {
|
||||
output.push({
|
||||
commandName: commandName,
|
||||
title: title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
output.sort((a: SearchResult, b: SearchResult) => {
|
||||
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public commandNames(publicOnly = false) {
|
||||
if (publicOnly) {
|
||||
const output = [];
|
||||
for (const name in this.commands_) {
|
||||
if (!this.isPublic(name)) continue;
|
||||
output.push(name);
|
||||
}
|
||||
return output;
|
||||
} else {
|
||||
return Object.keys(this.commands_);
|
||||
}
|
||||
}
|
||||
|
||||
public commandByName(name: string, options: CommandByNameOptions = null): Command {
|
||||
options = {
|
||||
mustExist: true,
|
||||
runtimeMustBeRegistered: false,
|
||||
...options,
|
||||
};
|
||||
|
||||
const command = this.commands_[name];
|
||||
|
||||
if (!command) {
|
||||
if (options.mustExist) throw new Error(`Command not found: ${name}. Make sure the declaration has been registered.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.runtimeMustBeRegistered && !command.runtime) throw new Error(`Runtime is not registered for command ${name}`);
|
||||
return command;
|
||||
}
|
||||
|
||||
public registerDeclaration(declaration: CommandDeclaration) {
|
||||
declaration = { ...declaration };
|
||||
if (!declaration.label) declaration.label = '';
|
||||
if (!declaration.iconName) declaration.iconName = 'fas fa-cog';
|
||||
|
||||
this.commands_[declaration.name] = {
|
||||
declaration: declaration,
|
||||
};
|
||||
}
|
||||
|
||||
public unregisterDeclaration(name: string) {
|
||||
delete this.commands_[name];
|
||||
}
|
||||
|
||||
public registerRuntime(commandName: string, runtime: CommandRuntime, allowMultiple = false): RegisteredRuntime {
|
||||
if (typeof commandName !== 'string') throw new Error(`Command name must be a string. Got: ${JSON.stringify(commandName)}`);
|
||||
|
||||
const command = this.commandByName(commandName);
|
||||
|
||||
runtime = { ...runtime };
|
||||
if (!runtime.enabledCondition) runtime.enabledCondition = 'true';
|
||||
if (!allowMultiple) {
|
||||
command.runtime = runtime;
|
||||
} else {
|
||||
if (!Array.isArray(command.runtime)) {
|
||||
command.runtime = command.runtime ? [command.runtime] : [];
|
||||
}
|
||||
command.runtime.push(runtime);
|
||||
}
|
||||
|
||||
return {
|
||||
// Like .deregisterRuntime, but deletes only the current runtime if there are multiple runtimes
|
||||
// for the same command.
|
||||
deregister: () => {
|
||||
const command = this.commandByName(commandName);
|
||||
if (Array.isArray(command.runtime)) {
|
||||
command.runtime = command.runtime.filter(r => {
|
||||
return r !== runtime;
|
||||
});
|
||||
|
||||
if (command.runtime.length === 0) {
|
||||
delete command.runtime;
|
||||
}
|
||||
} else if (command.runtime) {
|
||||
delete command.runtime;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public registerCommands(commands: CommandSpec[]) {
|
||||
for (const command of commands) {
|
||||
CommandService.instance().registerRuntime(command.declaration.name, command.runtime());
|
||||
}
|
||||
}
|
||||
|
||||
public unregisterCommands(commands: CommandSpec[]) {
|
||||
for (const command of commands) {
|
||||
CommandService.instance().unregisterRuntime(command.declaration.name);
|
||||
}
|
||||
}
|
||||
|
||||
public componentRegisterCommands<ComponentType>(component: ComponentType, commands: ComponentCommandSpec<ComponentType>[], allowMultiple?: boolean): RegisteredRuntime {
|
||||
const runtimeHandles: RegisteredRuntime[] = [];
|
||||
for (const command of commands) {
|
||||
runtimeHandles.push(
|
||||
CommandService.instance().registerRuntime(command.declaration.name, command.runtime(component), allowMultiple),
|
||||
);
|
||||
}
|
||||
return {
|
||||
deregister: () => {
|
||||
for (const handle of runtimeHandles) {
|
||||
handle.deregister();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ComponentCommandSpec is parametrised per-component (Note, Folder, etc.); the runtime here only needs the declaration name
|
||||
public componentUnregisterCommands(commands: ComponentCommandSpec<any>[]) {
|
||||
for (const command of commands) {
|
||||
CommandService.instance().unregisterRuntime(command.declaration.name);
|
||||
}
|
||||
}
|
||||
|
||||
public unregisterRuntime(commandName: string) {
|
||||
const command = this.commandByName(commandName, { mustExist: false });
|
||||
if (!command || !command.runtime) return;
|
||||
delete command.runtime;
|
||||
}
|
||||
|
||||
private createContext(): CommandContext {
|
||||
return {
|
||||
state: this.store_.getState(),
|
||||
dispatch: (action: unknown) => {
|
||||
return this.store_.dispatch(action);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getRuntime(command: Command, targetWindowId: string|null = null) {
|
||||
if (!Array.isArray(command.runtime)) return command.runtime;
|
||||
if (!command.runtime.length) return null;
|
||||
|
||||
let bestRuntime = null;
|
||||
let bestRuntimeScore = -1;
|
||||
for (const runtime of command.runtime) {
|
||||
const score = runtime.getPriority?.(this.store_.getState(), targetWindowId) ?? 0;
|
||||
if (score >= bestRuntimeScore) {
|
||||
bestRuntime = runtime;
|
||||
bestRuntimeScore = score;
|
||||
}
|
||||
}
|
||||
return bestRuntime;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Args/return shape varies per command, see CommandRuntime.execute
|
||||
public async execute(commandName: string, ...args: any[]): Promise<any | void> {
|
||||
return this.executeInWindow(commandName, { windowId: null, args });
|
||||
}
|
||||
|
||||
public async executeInWindow(commandName: string, options: ExecuteInWindowOptions): Promise<unknown | void> {
|
||||
const command = this.commandByName(commandName);
|
||||
// Some commands such as "showModalMessage" can be executed many
|
||||
// times per seconds, so we should only display this message in
|
||||
// debug mode.
|
||||
if (commandName !== 'showModalMessage') this.logger().debug('CommandService::execute:', commandName, options.args);
|
||||
|
||||
const runtime = this.getRuntime(command, options.windowId);
|
||||
if (!runtime) throw new Error(`Cannot execute a command without a runtime: ${commandName}`);
|
||||
|
||||
return runtime.execute(this.createContext(), ...options.args);
|
||||
}
|
||||
|
||||
public scheduleExecute(commandName: string, args: unknown) {
|
||||
shim.setTimeout(() => {
|
||||
void this.execute(commandName, args);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
public currentWhenClauseContext(options: WhenClauseContextOptions|null = null): WhenClauseContext {
|
||||
return this.stateToWhenClauseContext_(this.store_.getState(), options);
|
||||
}
|
||||
|
||||
public isPublic(commandName: string) {
|
||||
return !!this.label(commandName);
|
||||
}
|
||||
|
||||
// When looping on commands and checking their enabled state, the whenClauseContext
|
||||
// should be specified (created using currentWhenClauseContext) to avoid having
|
||||
// to re-create it on each call.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- WhenClauseContext is computed per-app and tests pass Partial<WhenClauseContext>; widening here keeps the test fixtures workable
|
||||
public isEnabled(commandName: string, whenClauseContext: any = null): boolean {
|
||||
const command = this.commandByName(commandName);
|
||||
if (!command) return false;
|
||||
const runtime = this.getRuntime(command);
|
||||
if (!runtime) return false;
|
||||
|
||||
if (!whenClauseContext) whenClauseContext = this.currentWhenClauseContext();
|
||||
|
||||
const exp = new WhenClause(runtime.enabledCondition, this.devMode_);
|
||||
return exp.evaluate(whenClauseContext);
|
||||
}
|
||||
|
||||
public isVisible(commandName: string, whenClauseContext: WhenClauseContext): boolean {
|
||||
// Default to true, to avoid buttons appearing/disappearing as commands are
|
||||
// declared and runtimes are loaded.
|
||||
const command = this.commandByName(commandName);
|
||||
if (!command) return true;
|
||||
const runtime = this.getRuntime(command);
|
||||
if (!runtime) return true;
|
||||
if (!runtime.visibleCondition) return true;
|
||||
|
||||
const exp = new WhenClause(runtime.visibleCondition, this.devMode_);
|
||||
return exp.evaluate(whenClauseContext);
|
||||
}
|
||||
|
||||
// The title is dynamic and derived from the state, which is why the state is passed
|
||||
// as an argument. Title can be used for example to display the alarm date on the
|
||||
// "set alarm" toolbar button.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- State can be lib's State or app-desktop AppState; titles are computed per-runtime, see ReduxStore.getState above
|
||||
public title(commandName: string, state: any = null): string {
|
||||
const command = this.commandByName(commandName);
|
||||
if (!command) return null;
|
||||
const runtime = this.getRuntime(command);
|
||||
if (!runtime) return null;
|
||||
|
||||
state = state || this.store_.getState();
|
||||
|
||||
if (runtime.mapStateToTitle) {
|
||||
return runtime.mapStateToTitle(state);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
public iconName(commandName: string): string {
|
||||
const command = this.commandByName(commandName);
|
||||
if (!command) throw new Error(`No such command: ${commandName}`);
|
||||
|
||||
return command.declaration.iconName;
|
||||
}
|
||||
|
||||
public label(commandName: string, fullLabel = false): string {
|
||||
const command = this.commandByName(commandName);
|
||||
if (!command) throw new Error(`Command: ${commandName} is not declared`);
|
||||
const output = [];
|
||||
|
||||
const parentLabel = (d: CommandDeclaration): string => {
|
||||
if (!d.parentLabel) return '';
|
||||
if (typeof d.parentLabel === 'function') return d.parentLabel();
|
||||
return d.parentLabel as string;
|
||||
};
|
||||
|
||||
if (fullLabel && parentLabel(command.declaration)) output.push(parentLabel(command.declaration));
|
||||
output.push(typeof command.declaration.label === 'function' ? command.declaration.label() : command.declaration.label);
|
||||
return output.join(': ');
|
||||
}
|
||||
|
||||
public description(commandName: string): string {
|
||||
const command = this.commandByName(commandName);
|
||||
if (command.declaration.description) return command.declaration.description;
|
||||
return this.label(commandName, true);
|
||||
}
|
||||
|
||||
public exists(commandName: string): boolean {
|
||||
const command = this.commandByName(commandName, { mustExist: false });
|
||||
return !!command;
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户