import { utils as pluginUtils, PluginStates } from '@joplin/lib/services/plugins/reducer'; import CommandService from '@joplin/lib/services/CommandService'; import InteropService from '@joplin/lib/services/interop/InteropService'; import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; import InteropServiceHelper from '../../InteropServiceHelper'; import { _ } from '@joplin/lib/locale'; import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils'; import bridge from '../../services/bridge'; import BaseModel from '@joplin/lib/BaseModel'; import Note from '@joplin/lib/models/Note'; import Setting from '@joplin/lib/models/Setting'; import { clipboard } from 'electron'; import { Dispatch } from 'redux'; import { NoteEntity } from '@joplin/lib/services/database/types'; import { MarkupLanguage } from '@joplin/renderer'; import hasLockedNoteWhileSessionLocked from '@joplin/lib/services/noteLock/hasLockedNoteWhileSessionLocked'; const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; interface ContextMenuProps { notes: NoteEntity[]; dispatch: Dispatch; watchedNoteFiles: string[]; plugins: PluginStates; inConflictFolder: boolean; customCss: string; } export default class NoteListUtils { public static makeContextMenu(noteIds: string[], props: ContextMenuProps) { const cmdService = CommandService.instance(); const menuUtils = new MenuUtils(cmdService); const notes: NoteEntity[] = BaseModel.modelsByIds(props.notes, noteIds); const singleNoteId = noteIds.length === 1 ? noteIds[0] : null; const includeDeletedNotes = notes.find(n => !!n.deleted_time); const includeEncryptedNotes = notes.find(n => !!n.encryption_applied); const menu = new Menu(); if (!includeEncryptedNotes && !includeDeletedNotes) { if (singleNoteId) { menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem('openNoteInNewWindow', singleNoteId)), ); const cmd = props.watchedNoteFiles.includes(singleNoteId) ? 'stopExternalEditing' : 'startExternalEditing'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem(cmd, singleNoteId) as any)); const pinnedNoteIds: string[] = Setting.value('notes.pinnedNoteIds') || []; const isPinned = pinnedNoteIds.includes(singleNoteId); menu.append( new MenuItem({ label: isPinned ? _('Unpin note') : _('Pin note'), click: () => { const currentPinned = (Setting.value('notes.pinnedNoteIds') as string[]) || []; let nextPinned: string[]; if (currentPinned.includes(singleNoteId)) { nextPinned = currentPinned.filter(id => id !== singleNoteId); } else { nextPinned = [singleNoteId, ...currentPinned]; } Setting.setValue('notes.pinnedNoteIds', nextPinned); }, }), ); menu.append(new MenuItem({ type: 'separator' })); } menu.append( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds) as any), ); menu.append(new MenuItem({ type: 'separator' })); if (noteIds.length <= 1) { menu.append( new MenuItem( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds) as any, ), ); } else { const switchNoteType = async (noteIds: string[], type: string) => { if (await hasLockedNoteWhileSessionLocked(noteIds)) { bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked')); return; } for (let i = 0; i < noteIds.length; i++) { const note = await Note.load(noteIds[i]); const newNote = Note.changeNoteType(note, type); if (newNote === note) continue; await Note.save(newNote, { userSideValidation: true }); } }; menu.append( new MenuItem({ label: _('Switch to note type'), click: async () => { await switchNoteType(noteIds, 'note'); }, }), ); menu.append( new MenuItem({ label: _('Switch to to-do type'), click: async () => { await switchNoteType(noteIds, 'todo'); }, }), ); } menu.append( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions new MenuItem(menuUtils.commandToStatefulMenuItem('moveToFolder', noteIds) as any), ); menu.append( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds) as any), ); menu.append( new MenuItem( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) as any, ), ); menu.append(new MenuItem({ type: 'separator' })); menu.append( new MenuItem({ label: _('Copy Markdown link'), click: async () => { const links = []; for (let i = 0; i < noteIds.length; i++) { const note = await Note.load(noteIds[i]); links.push(Note.markdownTag(note)); } clipboard.writeText(links.join(' ')); }, }), ); if (noteIds.length === 1) { menu.append( new MenuItem({ label: _('Copy external link'), click: () => { clipboard.writeText(getNoteCallbackUrl(noteIds[0])); }, }), ); } menu.append(new MenuItem({ type: 'separator' })); const includesHtmlNotes = notes.some(n => n.markup_language === MarkupLanguage.Html); if (includesHtmlNotes) { menu.append( new MenuItem( menuUtils.commandToStatefulMenuItem('convertNoteToMarkdown', noteIds), ), ); menu.append(new MenuItem({ type: 'separator' })); } if ([9, 10, 11].includes(Setting.value('sync.target'))) { menu.append( new MenuItem( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions menuUtils.commandToStatefulMenuItem('showShareNoteDialog', noteIds.slice()) as any, ), ); } const exportMenu = new Menu(); const ioService = InteropService.instance(); const ioModules = ioService.modules(); for (let i = 0; i < ioModules.length; i++) { const module = ioModules[i]; if (module.type !== 'exporter') continue; if (noteIds.length > 1 && module.isNoteArchive === false) continue; exportMenu.append( new MenuItem({ label: module.fullLabel(), click: async () => { await InteropServiceHelper.export(props.dispatch.bind(this), module, { sourceNoteIds: noteIds, includeConflicts: props.inConflictFolder, plugins: props.plugins, customCss: props.customCss, }); }, }), ); } exportMenu.append( new MenuItem( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions menuUtils.commandToStatefulMenuItem('exportPdf', noteIds) as any, ), ); const exportMenuItem = new MenuItem({ label: _('Export'), submenu: exportMenu }); menu.append(exportMenuItem); } if (includeDeletedNotes) { menu.append( new MenuItem( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions menuUtils.commandToStatefulMenuItem('restoreNote', noteIds) as any, ), ); menu.append( new MenuItem( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions menuUtils.commandToStatefulMenuItem('permanentlyDeleteNote', noteIds) as any, ), ); } const pluginViewInfos = pluginUtils.viewInfosByType(props.plugins, 'menuItem'); for (const info of pluginViewInfos) { const location = info.view.location; if (location !== MenuItemLocation.Context && location !== MenuItemLocation.NoteListContextMenu) continue; if (cmdService.isEnabled(info.view.commandName)) { menu.append( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions new MenuItem(menuUtils.commandToStatefulMenuItem(info.view.commandName, noteIds) as any), ); } } return menu; } }