require('app-module-path').addPath(__dirname); const { BaseApplication } = require('lib/BaseApplication'); const { FoldersScreenUtils } = require('lib/folders-screen-utils.js'); const Setting = require('lib/models/Setting.js'); const { shim } = require('lib/shim.js'); const MasterKey = require('lib/models/MasterKey'); const Folder = require('lib/models/Folder'); const { _, setLocale } = require('lib/locale.js'); const { Logger } = require('lib/logger.js'); const fs = require('fs-extra'); const Tag = require('lib/models/Tag.js'); const { reg } = require('lib/registry.js'); const { defaultState } = require('lib/reducer.js'); const packageInfo = require('./packageInfo.js'); const AlarmService = require('lib/services/AlarmService.js'); const AlarmServiceDriverNode = require('lib/services/AlarmServiceDriverNode'); const DecryptionWorker = require('lib/services/DecryptionWorker'); const InteropService = require('lib/services/InteropService'); const InteropServiceHelper = require('./InteropServiceHelper.js'); const ResourceService = require('lib/services/ResourceService'); const ClipperServer = require('lib/ClipperServer'); const actionApi = require('lib/services/rest/actionApi.desktop').default; const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); const ResourceEditWatcher = require('lib/services/ResourceEditWatcher/index').default; const { bridge } = require('electron').remote.require('./bridge'); const { shell, webFrame, clipboard } = require('electron'); const Menu = bridge().Menu; const PluginManager = require('lib/services/PluginManager'); const RevisionService = require('lib/services/RevisionService'); const MigrationService = require('lib/services/MigrationService'); const CommandService = require('lib/services/CommandService').default; const TemplateUtils = require('lib/TemplateUtils'); const CssUtils = require('lib/CssUtils'); const resourceEditWatcherReducer = require('lib/services/ResourceEditWatcher/reducer').default; const commands = [ require('./gui/Header/commands/focusSearch'), require('./gui/MainScreen/commands/editAlarm'), require('./gui/MainScreen/commands/exportPdf'), require('./gui/MainScreen/commands/hideModalMessage'), require('./gui/MainScreen/commands/moveToFolder'), require('./gui/MainScreen/commands/newNote'), require('./gui/MainScreen/commands/newNotebook'), require('./gui/MainScreen/commands/newTodo'), require('./gui/MainScreen/commands/print'), require('./gui/MainScreen/commands/renameFolder'), require('./gui/MainScreen/commands/renameTag'), require('./gui/MainScreen/commands/search'), require('./gui/MainScreen/commands/selectTemplate'), require('./gui/MainScreen/commands/setTags'), require('./gui/MainScreen/commands/showModalMessage'), require('./gui/MainScreen/commands/showNoteContentProperties'), require('./gui/MainScreen/commands/showNoteProperties'), require('./gui/MainScreen/commands/showShareNoteDialog'), require('./gui/MainScreen/commands/toggleNoteList'), require('./gui/MainScreen/commands/toggleSidebar'), require('./gui/MainScreen/commands/toggleVisiblePanes'), require('./gui/NoteEditor/commands/focusElementNoteBody'), require('./gui/NoteEditor/commands/focusElementNoteTitle'), require('./gui/NoteEditor/commands/showLocalSearch'), require('./gui/NoteEditor/commands/showRevisions'), require('./gui/NoteList/commands/focusElementNoteList'), require('./gui/SideBar/commands/focusElementSideBar'), ]; // Commands that are not tied to any particular component. // The runtime for these commands can be loaded when the app starts. const globalCommands = [ require('./commands/focusElement'), require('./commands/startExternalEditing'), require('./commands/stopExternalEditing'), require('lib/commands/synchronize'), require('lib/commands/historyBackward'), require('lib/commands/historyForward'), ]; const editorCommandDeclarations = require('./gui/NoteEditor/commands/editorCommandDeclarations').default; const pluginClasses = [ require('./plugins/GotoAnything.min'), ]; const appDefaultState = Object.assign({}, defaultState, { route: { type: 'NAV_GO', routeName: 'Main', props: {}, }, navHistory: [], fileToImport: null, noteVisiblePanes: ['editor', 'viewer'], sidebarVisibility: true, noteListVisibility: true, windowContentSize: bridge().windowContentSize(), watchedNoteFiles: [], lastEditorScrollPercents: {}, devToolsVisible: false, }); class Application extends BaseApplication { constructor() { super(); this.lastMenuScreen_ = null; this.bridge_nativeThemeUpdated = this.bridge_nativeThemeUpdated.bind(this); this.commandService_commandsEnabledStateChange = this.commandService_commandsEnabledStateChange.bind(this); CommandService.instance().on('commandsEnabledStateChange', this.commandService_commandsEnabledStateChange); } commandService_commandsEnabledStateChange() { // TODO: only update if command is used in menu? this.updateMenuItemStates(); } hasGui() { return true; } checkForUpdateLoggerPath() { return `${Setting.value('profileDir')}/log-autoupdater.txt`; } reducer(state = appDefaultState, action) { let newState = state; try { switch (action.type) { case 'NAV_BACK': case 'NAV_GO': { const goingBack = action.type === 'NAV_BACK'; if (goingBack && !state.navHistory.length) break; const currentRoute = state.route; newState = Object.assign({}, state); const newNavHistory = state.navHistory.slice(); if (goingBack) { let newAction = null; while (newNavHistory.length) { newAction = newNavHistory.pop(); if (newAction.routeName !== state.route.routeName) break; } if (!newAction) break; action = newAction; } if (!goingBack) newNavHistory.push(currentRoute); newState.navHistory = newNavHistory; newState.route = action; } break; case 'WINDOW_CONTENT_SIZE_SET': newState = Object.assign({}, state); newState.windowContentSize = action.size; break; case 'NOTE_VISIBLE_PANES_TOGGLE': { const getNextLayout = (currentLayout) => { currentLayout = panes.length === 2 ? 'both' : currentLayout[0]; let paneOptions; if (state.settings.layoutButtonSequence === Setting.LAYOUT_EDITOR_VIEWER) { paneOptions = ['editor', 'viewer']; } else if (state.settings.layoutButtonSequence === Setting.LAYOUT_EDITOR_SPLIT) { paneOptions = ['editor', 'both']; } else if (state.settings.layoutButtonSequence === Setting.LAYOUT_VIEWER_SPLIT) { paneOptions = ['viewer', 'both']; } else { paneOptions = ['editor', 'viewer', 'both']; } const currentLayoutIndex = paneOptions.indexOf(currentLayout); const nextLayoutIndex = currentLayoutIndex === paneOptions.length - 1 ? 0 : currentLayoutIndex + 1; const nextLayout = paneOptions[nextLayoutIndex]; return nextLayout === 'both' ? ['editor', 'viewer'] : [nextLayout]; }; newState = Object.assign({}, state); const panes = state.noteVisiblePanes.slice(); newState.noteVisiblePanes = getNextLayout(panes); } break; case 'NOTE_VISIBLE_PANES_SET': newState = Object.assign({}, state); newState.noteVisiblePanes = action.panes; break; case 'SIDEBAR_VISIBILITY_TOGGLE': newState = Object.assign({}, state); newState.sidebarVisibility = !state.sidebarVisibility; break; case 'SIDEBAR_VISIBILITY_SET': newState = Object.assign({}, state); newState.sidebarVisibility = action.visibility; break; case 'NOTELIST_VISIBILITY_TOGGLE': newState = Object.assign({}, state); newState.noteListVisibility = !state.noteListVisibility; break; case 'NOTELIST_VISIBILITY_SET': newState = Object.assign({}, state); newState.noteListVisibility = action.visibility; break; case 'NOTE_FILE_WATCHER_ADD': if (newState.watchedNoteFiles.indexOf(action.id) < 0) { newState = Object.assign({}, state); const watchedNoteFiles = newState.watchedNoteFiles.slice(); watchedNoteFiles.push(action.id); newState.watchedNoteFiles = watchedNoteFiles; } break; case 'NOTE_FILE_WATCHER_REMOVE': { newState = Object.assign({}, state); const idx = newState.watchedNoteFiles.indexOf(action.id); if (idx >= 0) { const watchedNoteFiles = newState.watchedNoteFiles.slice(); watchedNoteFiles.splice(idx, 1); newState.watchedNoteFiles = watchedNoteFiles; } } break; case 'NOTE_FILE_WATCHER_CLEAR': if (state.watchedNoteFiles.length) { newState = Object.assign({}, state); newState.watchedNoteFiles = []; } break; case 'EDITOR_SCROLL_PERCENT_SET': { newState = Object.assign({}, state); const newPercents = Object.assign({}, newState.lastEditorScrollPercents); newPercents[action.noteId] = action.percent; newState.lastEditorScrollPercents = newPercents; } break; case 'NOTE_DEVTOOLS_TOGGLE': newState = Object.assign({}, state); newState.devToolsVisible = !newState.devToolsVisible; break; case 'NOTE_DEVTOOLS_SET': newState = Object.assign({}, state); newState.devToolsVisible = action.value; break; } } catch (error) { error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`; throw error; } newState = resourceEditWatcherReducer(newState, action); CommandService.instance().scheduleMapStateToProps(newState); return super.reducer(newState, action); } toggleDevTools(visible) { if (visible) { bridge().openDevTools(); } else { bridge().closeDevTools(); } } async generalMiddleware(store, next, action) { if (action.type == 'SETTING_UPDATE_ONE' && action.key == 'locale' || action.type == 'SETTING_UPDATE_ALL') { setLocale(Setting.value('locale')); // The bridge runs within the main process, with its own instance of locale.js // so it needs to be set too here. bridge().setLocale(Setting.value('locale')); await this.refreshMenu(); } if (action.type == 'SETTING_UPDATE_ONE' && action.key == 'showTrayIcon' || action.type == 'SETTING_UPDATE_ALL') { this.updateTray(); } if (action.type == 'SETTING_UPDATE_ONE' && action.key == 'style.editor.fontFamily' || action.type == 'SETTING_UPDATE_ALL') { this.updateEditorFont(); } if (action.type == 'SETTING_UPDATE_ONE' && action.key == 'windowContentZoomFactor' || action.type == 'SETTING_UPDATE_ALL') { webFrame.setZoomFactor(Setting.value('windowContentZoomFactor') / 100); } if (['EVENT_NOTE_ALARM_FIELD_CHANGE', 'NOTE_DELETE'].indexOf(action.type) >= 0) { await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE'); } const result = await super.generalMiddleware(store, next, action); const newState = store.getState(); if (action.type === 'NAV_GO' || action.type === 'NAV_BACK') { app().updateMenu(newState.route.routeName); } if (['NOTE_VISIBLE_PANES_TOGGLE', 'NOTE_VISIBLE_PANES_SET'].indexOf(action.type) >= 0) { Setting.setValue('noteVisiblePanes', newState.noteVisiblePanes); } if (['SIDEBAR_VISIBILITY_TOGGLE', 'SIDEBAR_VISIBILITY_SET'].indexOf(action.type) >= 0) { Setting.setValue('sidebarVisibility', newState.sidebarVisibility); } if (['NOTELIST_VISIBILITY_TOGGLE', 'NOTELIST_VISIBILITY_SET'].indexOf(action.type) >= 0) { Setting.setValue('noteListVisibility', newState.noteListVisibility); } if (['NOTE_DEVTOOLS_TOGGLE', 'NOTE_DEVTOOLS_SET'].indexOf(action.type) >= 0) { this.toggleDevTools(newState.devToolsVisible); } if (action.type === 'FOLDER_AND_NOTE_SELECT') { await Folder.expandTree(newState.folders, action.folderId); } if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && ['themeAutoDetect', 'theme', 'preferredLightTheme', 'preferredDarkTheme'].includes(action.key)) || action.type == 'SETTING_UPDATE_ALL')) { this.handleThemeAutoDetect(); } return result; } handleThemeAutoDetect() { if (!Setting.value('themeAutoDetect')) return; if (bridge().shouldUseDarkColors()) { Setting.setValue('theme', Setting.value('preferredDarkTheme')); } else { Setting.setValue('theme', Setting.value('preferredLightTheme')); } } async refreshMenu() { const screen = this.lastMenuScreen_; this.lastMenuScreen_ = null; await this.updateMenu(screen); } async updateMenu(screen) { if (this.lastMenuScreen_ === screen) return; const cmdService = CommandService.instance(); const sortNoteFolderItems = (type) => { const sortItems = []; const sortOptions = Setting.enumOptions(`${type}.sortOrder.field`); for (const field in sortOptions) { if (!sortOptions.hasOwnProperty(field)) continue; sortItems.push({ label: sortOptions[field], screens: ['Main'], type: 'checkbox', checked: Setting.value(`${type}.sortOrder.field`) === field, click: () => { Setting.setValue(`${type}.sortOrder.field`, field); this.refreshMenu(); }, }); } sortItems.push({ type: 'separator' }); sortItems.push({ id: `sort:${type}:reverse`, label: Setting.settingMetadata(`${type}.sortOrder.reverse`).label(), type: 'checkbox', checked: Setting.value(`${type}.sortOrder.reverse`), screens: ['Main'], click: () => { Setting.setValue(`${type}.sortOrder.reverse`, !Setting.value(`${type}.sortOrder.reverse`)); }, }); return sortItems; }; const sortNoteItems = sortNoteFolderItems('notes'); const sortFolderItems = sortNoteFolderItems('folders'); const focusItems = [ cmdService.commandToMenuItem('focusElementSideBar', 'CommandOrControl+Shift+S'), cmdService.commandToMenuItem('focusElementNoteList', 'CommandOrControl+Shift+L'), cmdService.commandToMenuItem('focusElementNoteTitle', 'CommandOrControl+Shift+N'), cmdService.commandToMenuItem('focusElementNoteBody', 'CommandOrControl+Shift+B'), ]; let toolsItems = []; const importItems = []; const exportItems = []; const toolsItemsFirst = []; const templateItems = []; const ioService = new InteropService(); const ioModules = ioService.modules(); for (let i = 0; i < ioModules.length; i++) { const module = ioModules[i]; if (module.type === 'exporter') { if (module.canDoMultiExport !== false) { exportItems.push({ label: module.fullLabel(), screens: ['Main'], click: async () => { await InteropServiceHelper.export(this.dispatch.bind(this), module); }, }); } } else { for (let j = 0; j < module.sources.length; j++) { const moduleSource = module.sources[j]; importItems.push({ label: module.fullLabel(moduleSource), screens: ['Main'], click: async () => { let path = null; const selectedFolderId = this.store().getState().selectedFolderId; if (moduleSource === 'file') { path = bridge().showOpenDialog({ filters: [{ name: module.description, extensions: module.fileExtensions }], }); } else { path = bridge().showOpenDialog({ properties: ['openDirectory', 'createDirectory'], }); } if (!path || (Array.isArray(path) && !path.length)) return; if (Array.isArray(path)) path = path[0]; cmdService.execute('showModalMessage', { message: _('Importing from "%s" as "%s" format. Please wait...', path, module.format) }); const importOptions = { path, format: module.format, modulePath: module.path, onError: console.warn, destinationFolderId: !module.isNoteArchive && moduleSource === 'file' ? selectedFolderId : null, }; const service = new InteropService(); try { const result = await service.import(importOptions); console.info('Import result: ', result); } catch (error) { bridge().showErrorMessageBox(error.message); } cmdService.execute('hideModalMessage'); }, }); } } } exportItems.push( cmdService.commandToMenuItem('exportPdf') ); // We need a dummy entry, otherwise the ternary operator to show a // menu item only on a specific OS does not work. const noItem = { type: 'separator', visible: false, }; const syncStatusItem = { label: _('Synchronisation Status'), click: () => { this.dispatch({ type: 'NAV_GO', routeName: 'Status', }); }, }; const newNoteItem = cmdService.commandToMenuItem('newNote', 'CommandOrControl+N'); const newTodoItem = cmdService.commandToMenuItem('newTodo', 'CommandOrControl+T'); const newNotebookItem = cmdService.commandToMenuItem('newNotebook'); const printItem = cmdService.commandToMenuItem('print'); toolsItemsFirst.push(syncStatusItem, { type: 'separator', screens: ['Main'], }); const templateDirExists = await shim.fsDriver().exists(Setting.value('templateDir')); templateItems.push({ label: _('Create note from template'), visible: templateDirExists, click: () => { cmdService.execute('selectTemplate', { noteType: 'note' }); }, }, { label: _('Create to-do from template'), visible: templateDirExists, click: () => { cmdService.execute('selectTemplate', { noteType: 'todo' }); }, }, { label: _('Insert template'), visible: templateDirExists, accelerator: 'CommandOrControl+Alt+I', click: () => { cmdService.execute('selectTemplate'); }, }, { label: _('Open template directory'), click: () => { const templateDir = Setting.value('templateDir'); if (!templateDirExists) shim.fsDriver().mkdir(templateDir); shell.openItem(templateDir); }, }, { label: _('Refresh templates'), click: async () => { const templates = await TemplateUtils.loadTemplates(Setting.value('templateDir')); this.store().dispatch({ type: 'TEMPLATE_UPDATE_ALL', templates: templates, }); }, }); // we need this workaround, because on macOS the menu is different const toolsItemsWindowsLinux = toolsItemsFirst.concat([{ label: _('Options'), visible: !shim.isMac(), accelerator: 'CommandOrControl+,', click: () => { this.dispatch({ type: 'NAV_GO', routeName: 'Config', }); }, }]); // the following menu items will be available for all OS under Tools const toolsItemsAll = [{ label: _('Note attachments...'), click: () => { this.dispatch({ type: 'NAV_GO', routeName: 'Resources', }); }, }]; if (!shim.isMac()) { toolsItems = toolsItems.concat(toolsItemsWindowsLinux); } toolsItems = toolsItems.concat(toolsItemsAll); function _checkForUpdates(ctx) { bridge().checkForUpdates(false, bridge().window(), ctx.checkForUpdateLoggerPath(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') }); } function _showAbout() { const p = packageInfo; let gitInfo = ''; if ('git' in p) { gitInfo = _('Revision: %s (%s)', p.git.hash, p.git.branch); } const copyrightText = 'Copyright © 2016-YYYY Laurent Cozic'; const message = [ p.description, '', copyrightText.replace('YYYY', new Date().getFullYear()), _('%s %s (%s, %s)', p.name, p.version, Setting.value('env'), process.platform), '', _('Client ID: %s', Setting.value('clientId')), _('Sync Version: %s', Setting.value('syncVersion')), _('Profile Version: %s', reg.db().version()), _('Keychain Supported: %s', Setting.value('keychain.supported') >= 1 ? _('Yes') : _('No')), ]; if (gitInfo) { message.push(`\n${gitInfo}`); console.info(gitInfo); } const text = message.join('\n'); const copyToClipboard = bridge().showMessageBox(text, { icon: `${bridge().electronApp().buildDir()}/icons/128x128.png`, buttons: [_('Copy'), _('OK')], cancelId: 1, defaultId: 1, }); if (copyToClipboard === 0) { clipboard.writeText(message.splice(3).join('\n')); } } const rootMenuFile = { // Using a dummy entry for macOS here, because first menu // becomes 'Joplin' and we need a nenu called 'File' later. label: shim.isMac() ? '&JoplinMainMenu' : _('&File'), // `&` before one of the char in the label name mean, that // will open this menu. It's needed becase electron // opens the first menu on Alt press if no hotkey assigned. // Issue: https://github.com/laurent22/joplin/issues/934 submenu: [{ label: _('About Joplin'), visible: shim.isMac() ? true : false, click: () => _showAbout(), }, { type: 'separator', visible: shim.isMac() ? true : false, }, { label: _('Preferences...'), visible: shim.isMac() ? true : false, accelerator: 'CommandOrControl+,', click: () => { this.dispatch({ type: 'NAV_GO', routeName: 'Config', }); }, }, { label: _('Check for updates...'), visible: shim.isMac() ? true : false, click: () => _checkForUpdates(this), }, { type: 'separator', visible: shim.isMac() ? true : false, }, shim.isMac() ? noItem : newNoteItem, shim.isMac() ? noItem : newTodoItem, shim.isMac() ? noItem : newNotebookItem, { type: 'separator', visible: shim.isMac() ? false : true, }, { label: _('Templates'), visible: shim.isMac() ? false : true, submenu: templateItems, }, { type: 'separator', visible: shim.isMac() ? false : true, }, { label: _('Import'), visible: shim.isMac() ? false : true, submenu: importItems, }, { label: _('Export'), visible: shim.isMac() ? false : true, submenu: exportItems, }, { type: 'separator', }, cmdService.commandToMenuItem('synchronize', 'CommandOrControl+S'), shim.isMac() ? syncStatusItem : noItem, { type: 'separator', }, shim.isMac() ? noItem : printItem, { type: 'separator', platforms: ['darwin'], }, { label: _('Hide %s', 'Joplin'), platforms: ['darwin'], accelerator: 'CommandOrControl+H', click: () => { bridge().electronApp().hide(); }, }, { type: 'separator', }, { label: _('Quit'), accelerator: 'CommandOrControl+Q', click: () => { bridge().electronApp().quit(); }, }], }; const rootMenuFileMacOs = { label: _('&File'), visible: shim.isMac() ? true : false, submenu: [ newNoteItem, newTodoItem, newNotebookItem, { label: _('Close Window'), platforms: ['darwin'], accelerator: 'Command+W', selector: 'performClose:', }, { type: 'separator', }, { label: _('Templates'), submenu: templateItems, }, { type: 'separator', }, { label: _('Import'), submenu: importItems, }, { label: _('Export'), submenu: exportItems, }, { type: 'separator', }, printItem, ], }; const layoutButtonSequenceOptions = Object.entries(Setting.enumOptions('layoutButtonSequence')).map(([layoutKey, layout]) => ({ label: layout, screens: ['Main'], type: 'checkbox', checked: Setting.value('layoutButtonSequence') == layoutKey, click: () => { Setting.setValue('layoutButtonSequence', layoutKey); this.refreshMenu(); }, })); const separator = () => { return { type: 'separator', screens: ['Main'], }; }; const rootMenus = { edit: { id: 'edit', label: _('&Edit'), submenu: [ cmdService.commandToMenuItem('textCopy', 'CommandOrControl+C'), cmdService.commandToMenuItem('textCut', 'CommandOrControl+X'), cmdService.commandToMenuItem('textPaste', 'CommandOrControl+V'), cmdService.commandToMenuItem('textSelectAll', 'CommandOrControl+A'), separator(), cmdService.commandToMenuItem('textBold', 'CommandOrControl+B'), cmdService.commandToMenuItem('textItalic', 'CommandOrControl+I'), cmdService.commandToMenuItem('textLink', 'CommandOrControl+K'), cmdService.commandToMenuItem('textCode', 'CommandOrControl+`'), separator(), cmdService.commandToMenuItem('insertDateTime', 'CommandOrControl+Shift+T'), separator(), cmdService.commandToMenuItem('focusSearch', shim.isMac() ? 'Shift+Command+F' : 'F6'), cmdService.commandToMenuItem('showLocalSearch', 'CommandOrControl+F'), ], }, view: { label: _('&View'), submenu: [ CommandService.instance().commandToMenuItem('toggleSidebar', shim.isMac() ? 'Option+Command+S' : 'F10'), CommandService.instance().commandToMenuItem('toggleNoteList'), CommandService.instance().commandToMenuItem('toggleVisiblePanes', 'CommandOrControl+L'), { label: _('Layout button sequence'), screens: ['Main'], submenu: layoutButtonSequenceOptions, }, separator(), { label: Setting.settingMetadata('notes.sortOrder.field').label(), screens: ['Main'], submenu: sortNoteItems, }, { label: Setting.settingMetadata('folders.sortOrder.field').label(), screens: ['Main'], submenu: sortFolderItems, }, { label: Setting.settingMetadata('showNoteCounts').label(), type: 'checkbox', checked: Setting.value('showNoteCounts'), screens: ['Main'], click: () => { Setting.setValue('showNoteCounts', !Setting.value('showNoteCounts')); }, }, { label: Setting.settingMetadata('uncompletedTodosOnTop').label(), type: 'checkbox', checked: Setting.value('uncompletedTodosOnTop'), screens: ['Main'], click: () => { Setting.setValue('uncompletedTodosOnTop', !Setting.value('uncompletedTodosOnTop')); }, }, { label: Setting.settingMetadata('showCompletedTodos').label(), type: 'checkbox', checked: Setting.value('showCompletedTodos'), screens: ['Main'], click: () => { Setting.setValue('showCompletedTodos', !Setting.value('showCompletedTodos')); }, }, separator(), { label: _('Focus'), screens: ['Main'], submenu: focusItems, }, separator(), { label: _('Actual Size'), click: () => { Setting.setValue('windowContentZoomFactor', 100); }, accelerator: 'CommandOrControl+0', }, { // There are 2 shortcuts for the action 'zoom in', mainly to increase the user experience. // Most applications handle this the same way. These applications indicate Ctrl +, but actually mean Ctrl =. // In fact they allow both: + and =. On the English keyboard layout - and = are used without the shift key. // So to use Ctrl + would mean to use the shift key, but this is not the case in any of the apps that show Ctrl +. // Additionally it allows the use of the plus key on the numpad. label: _('Zoom In'), click: () => { Setting.incValue('windowContentZoomFactor', 10); }, accelerator: 'CommandOrControl+Plus', }, { label: _('Zoom In'), visible: false, click: () => { Setting.incValue('windowContentZoomFactor', 10); }, accelerator: 'CommandOrControl+=', }, { label: _('Zoom Out'), click: () => { Setting.incValue('windowContentZoomFactor', -10); }, accelerator: 'CommandOrControl+-', }], }, note: { label: _('&Note'), submenu: [ CommandService.instance().commandToMenuItem('startExternalEditing', 'CommandOrControl+E'), CommandService.instance().commandToMenuItem('setTags', 'CommandOrControl+Alt+T'), separator(), CommandService.instance().commandToMenuItem('showNoteContentProperties'), ], }, tools: { label: _('&Tools'), submenu: toolsItems, }, help: { label: _('&Help'), role: 'help', // Makes it add the "Search" field on macOS submenu: [{ label: _('Website and documentation'), accelerator: 'F1', click() { bridge().openExternal('https://joplinapp.org'); }, }, { label: _('Joplin Forum'), click() { bridge().openExternal('https://discourse.joplinapp.org'); }, }, { label: _('Make a donation'), click() { bridge().openExternal('https://joplinapp.org/donate/'); }, }, { label: _('Check for updates...'), visible: shim.isMac() ? false : true, click: () => _checkForUpdates(this), }, separator(), { id: 'help:toggleDevTools', label: _('Toggle development tools'), click: () => { this.dispatch({ type: 'NOTE_DEVTOOLS_TOGGLE', }); }, }, { type: 'separator', visible: shim.isMac() ? false : true, screens: ['Main'], }, { label: _('About Joplin'), visible: shim.isMac() ? false : true, click: () => _showAbout(), }], }, }; if (shim.isMac()) { rootMenus.macOsApp = rootMenuFile; rootMenus.file = rootMenuFileMacOs; } else { rootMenus.file = rootMenuFile; } // It seems the "visible" property of separators is ignored by Electron, making // it display separators that we want hidden. So this function iterates through // them and remove them completely. const cleanUpSeparators = items => { const output = []; for (const item of items) { if ('visible' in item && item.type === 'separator' && !item.visible) continue; output.push(item); } return output; }; for (const key in rootMenus) { if (!rootMenus.hasOwnProperty(key)) continue; if (!rootMenus[key].submenu) continue; rootMenus[key].submenu = cleanUpSeparators(rootMenus[key].submenu); } const pluginMenuItems = PluginManager.instance().menuItems(); for (const item of pluginMenuItems) { const itemParent = rootMenus[item.parent] ? rootMenus[item.parent] : 'tools'; itemParent.submenu.push(item); } const template = [ rootMenus.file, rootMenus.edit, rootMenus.view, rootMenus.note, rootMenus.tools, rootMenus.help, ]; if (shim.isMac()) template.splice(0, 0, rootMenus.macOsApp); function isEmptyMenu(template) { for (let i = 0; i < template.length; i++) { const t = template[i]; if (t.type !== 'separator') return false; } return true; } function removeUnwantedItems(template, screen) { const platform = shim.platformName(); let output = []; for (let i = 0; i < template.length; i++) { const t = Object.assign({}, template[i]); if (t.screens && t.screens.indexOf(screen) < 0) continue; if (t.platforms && t.platforms.indexOf(platform) < 0) continue; if (t.submenu) t.submenu = removeUnwantedItems(t.submenu, screen); if (('submenu' in t) && isEmptyMenu(t.submenu)) continue; output.push(t); } // Remove empty separator for now empty sections const temp = []; let previous = null; for (let i = 0; i < output.length; i++) { const t = Object.assign({}, output[i]); if (t.type === 'separator') { if (!previous) continue; if (previous.type === 'separator') continue; } temp.push(t); previous = t; } output = temp; return output; } const screenTemplate = removeUnwantedItems(template, screen); const menu = Menu.buildFromTemplate(screenTemplate); Menu.setApplicationMenu(menu); this.lastMenuScreen_ = screen; } async updateMenuItemStates(state = null) { if (!this.lastMenuScreen_) return; if (!this.store() && !state) return; if (!state) state = this.store().getState(); const menuEnabledState = CommandService.instance().commandsEnabledState(this.previousMenuEnabledState); this.previousMenuEnabledState = menuEnabledState; const menu = Menu.getApplicationMenu(); for (const itemId in menuEnabledState) { const menuItem = menu.getMenuItemById(itemId); if (!menuItem) continue; menuItem.enabled = menuEnabledState[itemId]; } const sortNoteReverseItem = menu.getMenuItemById('sort:notes:reverse'); sortNoteReverseItem.enabled = state.settings['notes.sortOrder.field'] !== 'order'; // const devToolsMenuItem = menu.getMenuItemById('help:toggleDevTools'); // devToolsMenuItem.checked = state.devToolsVisible; } bridge_nativeThemeUpdated() { this.handleThemeAutoDetect(); } updateTray() { const app = bridge().electronApp(); if (app.trayShown() === Setting.value('showTrayIcon')) return; if (!Setting.value('showTrayIcon')) { app.destroyTray(); } else { const contextMenu = Menu.buildFromTemplate([ { label: _('Open %s', app.electronApp().name), click: () => { app.window().show(); } }, { type: 'separator' }, { label: _('Exit'), click: () => { app.quit(); } }, ]); app.createTray(contextMenu); } } updateEditorFont() { const fontFamilies = []; if (Setting.value('style.editor.fontFamily')) fontFamilies.push(`"${Setting.value('style.editor.fontFamily')}"`); fontFamilies.push('monospace'); // The '*' and '!important' parts are necessary to make sure Russian text is displayed properly // https://github.com/laurent22/joplin/issues/155 const css = `.CodeMirror * { font-family: ${fontFamilies.join(', ')} !important; }`; const ace_css = `.ace_editor * { font-family: ${fontFamilies.join(', ')} !important; }`; const styleTag = document.createElement('style'); styleTag.type = 'text/css'; styleTag.appendChild(document.createTextNode(css)); styleTag.appendChild(document.createTextNode(ace_css)); document.head.appendChild(styleTag); } async loadCustomCss(filePath) { let cssString = ''; if (await fs.pathExists(filePath)) { try { cssString = await fs.readFile(filePath, 'utf-8'); } catch (error) { let msg = error.message ? error.message : ''; msg = `Could not load custom css from ${filePath}\n${msg}`; error.message = msg; throw error; } } return cssString; } // async createManyNotes() { // return; // const folderIds = []; // const randomFolderId = (folderIds) => { // if (!folderIds.length) return ''; // const idx = Math.floor(Math.random() * folderIds.length); // if (idx > folderIds.length - 1) throw new Error('Invalid index ' + idx + ' / ' + folderIds.length); // return folderIds[idx]; // } // let rootFolderCount = 0; // let folderCount = 100; // for (let i = 0; i < folderCount; i++) { // let parentId = ''; // if (Math.random() >= 0.9 || rootFolderCount >= folderCount / 10) { // parentId = randomFolderId(folderIds); // } else { // rootFolderCount++; // } // const folder = await Folder.save({ title: 'folder' + i, parent_id: parentId }); // folderIds.push(folder.id); // } // for (let i = 0; i < 10000; i++) { // const parentId = randomFolderId(folderIds); // Note.save({ title: 'note' + i, parent_id: parentId }); // } // } async start(argv) { const electronIsDev = require('electron-is-dev'); // If running inside a package, the command line, instead of being "node.exe " is "joplin.exe " so // insert an extra argument so that they can be processed in a consistent way everywhere. if (!electronIsDev) argv.splice(1, 0, '.'); argv = await super.start(argv); // Loads app-wide styles. (Markdown preview-specific styles loaded in app.js) const dir = Setting.value('profileDir'); const filename = Setting.custom_css_files.JOPLIN_APP; await CssUtils.injectCustomStyles(`${dir}/${filename}`); AlarmService.setDriver(new AlarmServiceDriverNode({ appName: packageInfo.build.appId })); AlarmService.setLogger(reg.logger()); reg.setShowErrorMessageBoxHandler((message) => { bridge().showErrorMessageBox(message); }); if (Setting.value('flagOpenDevTools')) { bridge().openDevTools(); } PluginManager.instance().dispatch_ = this.dispatch.bind(this); PluginManager.instance().setLogger(reg.logger()); PluginManager.instance().register(pluginClasses); this.initRedux(); CommandService.instance().initialize(this.store()); for (const command of commands) { CommandService.instance().registerDeclaration(command.declaration); } for (const command of globalCommands) { CommandService.instance().registerDeclaration(command.declaration); CommandService.instance().registerRuntime(command.declaration.name, command.runtime()); } for (const declaration of editorCommandDeclarations) { CommandService.instance().registerDeclaration(declaration); } this.updateMenu('Main'); // Since the settings need to be loaded before the store is created, it will never // receive the SETTING_UPDATE_ALL even, which mean state.settings will not be // initialised. So we manually call dispatchUpdateAll() to force an update. Setting.dispatchUpdateAll(); await FoldersScreenUtils.refreshFolders(); const tags = await Tag.allWithNotes(); this.dispatch({ type: 'TAG_UPDATE_ALL', items: tags, }); const masterKeys = await MasterKey.all(); this.dispatch({ type: 'MASTERKEY_UPDATE_ALL', items: masterKeys, }); this.store().dispatch({ type: 'FOLDER_SELECT', id: Setting.value('activeFolderId'), }); this.store().dispatch({ type: 'FOLDER_SET_COLLAPSED_ALL', ids: Setting.value('collapsedFolderIds'), }); // Loads custom Markdown preview styles const cssString = await CssUtils.loadCustomCss(`${Setting.value('profileDir')}/userstyle.css`); this.store().dispatch({ type: 'LOAD_CUSTOM_CSS', css: cssString, }); const templates = await TemplateUtils.loadTemplates(Setting.value('templateDir')); this.store().dispatch({ type: 'TEMPLATE_UPDATE_ALL', templates: templates, }); this.store().dispatch({ type: 'NOTE_DEVTOOLS_SET', value: Setting.value('flagOpenDevTools'), }); // Note: Auto-update currently doesn't work in Linux: it downloads the update // but then doesn't install it on exit. if (shim.isWindows() || shim.isMac()) { const runAutoUpdateCheck = () => { if (Setting.value('autoUpdateEnabled')) { bridge().checkForUpdates(true, bridge().window(), this.checkForUpdateLoggerPath(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') }); } }; // Initial check on startup setTimeout(() => { runAutoUpdateCheck(); }, 5000); // Then every x hours setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000); } this.updateTray(); setTimeout(() => { AlarmService.garbageCollect(); }, 1000 * 60 * 60); if (Setting.value('startMinimized') && Setting.value('showTrayIcon')) { // Keep it hidden } else { bridge().window().show(); } ResourceService.runInBackground(); if (Setting.value('env') === 'dev') { AlarmService.updateAllNotifications(); } else { reg.scheduleSync().then(() => { // Wait for the first sync before updating the notifications, since synchronisation // might change the notifications. AlarmService.updateAllNotifications(); DecryptionWorker.instance().scheduleStart(); }); } const clipperLogger = new Logger(); clipperLogger.addTarget('file', { path: `${Setting.value('profileDir')}/log-clipper.txt` }); clipperLogger.addTarget('console'); ClipperServer.instance().initialize(actionApi); ClipperServer.instance().setLogger(clipperLogger); ClipperServer.instance().setDispatch(this.store().dispatch); if (Setting.value('clipperServer.autoStart')) { ClipperServer.instance().start(); } ExternalEditWatcher.instance().setLogger(reg.logger()); ExternalEditWatcher.instance().dispatch = this.store().dispatch; ResourceEditWatcher.instance().initialize(reg.logger(), (action) => { this.store().dispatch(action); }); RevisionService.instance().runInBackground(); this.updateMenuItemStates(); // Make it available to the console window - useful to call revisionService.collectRevisions() window.revisionService = RevisionService.instance(); window.migrationService = MigrationService.instance(); window.decryptionWorker = DecryptionWorker.instance(); bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated); } } let application_ = null; function app() { if (!application_) application_ = new Application(); return application_; } module.exports = { app };