You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Chore: Apply changes from mobile plugins to lib/ and app-desktop/ (#10079)
				
					
				
			This commit is contained in:
		| @@ -171,8 +171,6 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js | ||||
| packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js | ||||
| packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js | ||||
| packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js | ||||
| packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js | ||||
| packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js | ||||
| packages/app-desktop/gui/Dialog.js | ||||
| packages/app-desktop/gui/DialogButtonRow.js | ||||
| packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js | ||||
| @@ -717,6 +715,10 @@ packages/lib/commands/openMasterPasswordDialog.js | ||||
| packages/lib/commands/synchronize.js | ||||
| packages/lib/components/EncryptionConfigScreen/utils.js | ||||
| packages/lib/components/shared/config/config-shared.js | ||||
| packages/lib/components/shared/config/plugins/types.js | ||||
| packages/lib/components/shared/config/plugins/useOnDeleteHandler.js | ||||
| packages/lib/components/shared/config/plugins/useOnInstallHandler.test.js | ||||
| packages/lib/components/shared/config/plugins/useOnInstallHandler.js | ||||
| packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js | ||||
| packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js | ||||
| packages/lib/components/shared/note-screen-shared.js | ||||
| @@ -745,6 +747,7 @@ packages/lib/geolocation-node.js | ||||
| packages/lib/hooks/useAsyncEffect.js | ||||
| packages/lib/hooks/useElementSize.js | ||||
| packages/lib/hooks/useEventListener.js | ||||
| packages/lib/hooks/usePrevious.js | ||||
| packages/lib/htmlUtils.test.js | ||||
| packages/lib/htmlUtils.js | ||||
| packages/lib/htmlUtils2.test.js | ||||
|   | ||||
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -151,8 +151,6 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js | ||||
| packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js | ||||
| packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js | ||||
| packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js | ||||
| packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.test.js | ||||
| packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js | ||||
| packages/app-desktop/gui/Dialog.js | ||||
| packages/app-desktop/gui/DialogButtonRow.js | ||||
| packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js | ||||
| @@ -697,6 +695,10 @@ packages/lib/commands/openMasterPasswordDialog.js | ||||
| packages/lib/commands/synchronize.js | ||||
| packages/lib/components/EncryptionConfigScreen/utils.js | ||||
| packages/lib/components/shared/config/config-shared.js | ||||
| packages/lib/components/shared/config/plugins/types.js | ||||
| packages/lib/components/shared/config/plugins/useOnDeleteHandler.js | ||||
| packages/lib/components/shared/config/plugins/useOnInstallHandler.test.js | ||||
| packages/lib/components/shared/config/plugins/useOnInstallHandler.js | ||||
| packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js | ||||
| packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js | ||||
| packages/lib/components/shared/note-screen-shared.js | ||||
| @@ -725,6 +727,7 @@ packages/lib/geolocation-node.js | ||||
| packages/lib/hooks/useAsyncEffect.js | ||||
| packages/lib/hooks/useElementSize.js | ||||
| packages/lib/hooks/useEventListener.js | ||||
| packages/lib/hooks/usePrevious.js | ||||
| packages/lib/htmlUtils.test.js | ||||
| packages/lib/htmlUtils.js | ||||
| packages/lib/htmlUtils2.test.js | ||||
|   | ||||
| @@ -279,17 +279,7 @@ class Application extends BaseApplication { | ||||
| 		} | ||||
|  | ||||
| 		try { | ||||
| 			const devPluginOptions = { devMode: true, builtIn: false }; | ||||
|  | ||||
| 			if (Setting.value('plugins.devPluginPaths')) { | ||||
| 				const paths = Setting.value('plugins.devPluginPaths').split(',').map((p: string) => p.trim()); | ||||
| 				await service.loadAndRunPlugins(paths, pluginSettings, devPluginOptions); | ||||
| 			} | ||||
|  | ||||
| 			// Also load dev plugins that have passed via command line arguments | ||||
| 			if (Setting.value('startupDevPlugins')) { | ||||
| 				await service.loadAndRunPlugins(Setting.value('startupDevPlugins'), pluginSettings, devPluginOptions); | ||||
| 			} | ||||
| 			await service.loadAndRunDevPlugins(pluginSettings); | ||||
| 		} catch (error) { | ||||
| 			this.logger().error(`There was an error loading plugins from ${Setting.value('plugins.devPluginPaths')}:`, error); | ||||
| 		} | ||||
|   | ||||
| @@ -288,9 +288,7 @@ export class Bridge { | ||||
| 	} | ||||
|  | ||||
| 	/* returns the index of the clicked button */ | ||||
| 	public showMessageBox(message: string, options: MessageDialogOptions = null) { | ||||
| 		if (options === null) options = { message: '' }; | ||||
|  | ||||
| 	public showMessageBox(message: string, options: MessageDialogOptions = {}) { | ||||
| 		const result = this.showMessageBox_(this.window(), { type: 'question', | ||||
| 			message: message, | ||||
| 			buttons: [_('OK'), _('Cancel')], ...options }); | ||||
|   | ||||
| @@ -345,10 +345,6 @@ class ConfigScreenComponent extends React.Component<any, any> { | ||||
| 				this.setState({ needRestart: true }); | ||||
| 			} | ||||
| 			shared.updateSettingValue(this, key, value); | ||||
|  | ||||
| 			if (md.autoSave) { | ||||
| 				shared.scheduleSaveSettings(this); | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		const md = Setting.settingMetadata(key); | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import { AppType, SettingSectionSource } from '@joplin/lib/models/Setting'; | ||||
| import * as React from 'react'; | ||||
| import { useMemo } from 'react'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| const styled = require('styled-components').default; | ||||
| @@ -72,23 +71,6 @@ export const StyledListItemIcon = styled.i` | ||||
| export default function Sidebar(props: Props) { | ||||
| 	const buttons: any[] = []; | ||||
|  | ||||
| 	const sortedSections = useMemo(() => { | ||||
| 		const output = props.sections.slice(); | ||||
| 		output.sort((a: any, b: any) => { | ||||
| 			const s1 = a.source || SettingSectionSource.Default; | ||||
| 			const s2 = b.source || SettingSectionSource.Default; | ||||
| 			if (s1 === SettingSectionSource.Default && s2 === SettingSectionSource.Default) return props.sections.indexOf(s1) - props.sections.indexOf(s2); | ||||
| 			if (s1 === SettingSectionSource.Default && s2 === SettingSectionSource.Plugin) return -1; | ||||
| 			if (s1 === SettingSectionSource.Plugin && s2 === SettingSectionSource.Default) return +1; | ||||
|  | ||||
| 			const l1 = Setting.sectionNameToLabel(a.name); | ||||
| 			const l2 = Setting.sectionNameToLabel(b.name); | ||||
| 			if (s1 === SettingSectionSource.Plugin && s2 === SettingSectionSource.Plugin) return l1.toLowerCase() < l2.toLowerCase() ? -1 : +1; | ||||
| 			return 0; | ||||
| 		}); | ||||
| 		return output; | ||||
| 	}, [props.sections]); | ||||
|  | ||||
| 	function renderButton(section: any) { | ||||
| 		const selected = props.selection === section.name; | ||||
| 		return ( | ||||
| @@ -121,7 +103,7 @@ export default function Sidebar(props: Props) { | ||||
|  | ||||
| 	let pluginDividerAdded = false; | ||||
|  | ||||
| 	for (const section of sortedSections) { | ||||
| 	for (const section of props.sections) { | ||||
| 		if (section.source === SettingSectionSource.Plugin && !pluginDividerAdded) { | ||||
| 			buttons.push(renderDivider('divider-plugins')); | ||||
| 			pluginDividerAdded = true; | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import ToggleButton from '../../../lib/ToggleButton/ToggleButton'; | ||||
| import Button, { ButtonLevel } from '../../../Button/Button'; | ||||
| import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | ||||
| import bridge from '../../../../services/bridge'; | ||||
| import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
|  | ||||
| export enum InstallState { | ||||
| 	NotInstalled = 1, | ||||
| @@ -20,10 +21,6 @@ export enum UpdateState { | ||||
| 	HasBeenUpdated = 4, | ||||
| } | ||||
|  | ||||
| export interface ItemEvent { | ||||
| 	item: PluginItem; | ||||
| } | ||||
|  | ||||
| interface Props { | ||||
| 	item?: PluginItem; | ||||
| 	manifest?: PluginManifest; | ||||
| @@ -48,15 +45,6 @@ function manifestToItem(manifest: PluginManifest): PluginItem { | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| export interface PluginItem { | ||||
| 	manifest: PluginManifest; | ||||
| 	enabled: boolean; | ||||
| 	deleted: boolean; | ||||
| 	devMode: boolean; | ||||
| 	builtIn: boolean; | ||||
| 	hasBeenUpdated: boolean; | ||||
| } | ||||
|  | ||||
| const CellRoot = styled.div<{ isCompatible: boolean }>` | ||||
| 	display: flex; | ||||
| 	box-sizing: border-box; | ||||
|   | ||||
| @@ -4,15 +4,16 @@ import PluginService, { defaultPluginSetting, Plugins, PluginSetting, PluginSett | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import styled from 'styled-components'; | ||||
| import SearchPlugins from './SearchPlugins'; | ||||
| import PluginBox, { ItemEvent, UpdateState } from './PluginBox'; | ||||
| import PluginBox, { UpdateState } from './PluginBox'; | ||||
| import Button, { ButtonLevel, ButtonSize } from '../../../Button/Button'; | ||||
| import bridge from '../../../../services/bridge'; | ||||
| import produce from 'immer'; | ||||
| import { OnChangeEvent } from '../../../lib/SearchInput/SearchInput'; | ||||
| import { PluginItem } from './PluginBox'; | ||||
| import { PluginItem, ItemEvent, OnPluginSettingChangeEvent } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import useOnInstallHandler, { OnPluginSettingChangeEvent } from './useOnInstallHandler'; | ||||
| import useOnInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler'; | ||||
| import useOnDeleteHandler from '@joplin/lib/components/shared/config/plugins/useOnDeleteHandler'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import StyledMessage from '../../../style/StyledMessage'; | ||||
| import StyledLink from '../../../style/StyledLink'; | ||||
| @@ -59,7 +60,7 @@ let repoApi_: RepositoryApi = null; | ||||
|  | ||||
| function repoApi(): RepositoryApi { | ||||
| 	if (repoApi_) return repoApi_; | ||||
| 	repoApi_ = new RepositoryApi('https://github.com/joplin/plugins', Setting.value('tempDir')); | ||||
| 	repoApi_ = RepositoryApi.ofDefaultJoplinRepo(Setting.value('tempDir')); | ||||
| 	// repoApi_ = new RepositoryApi('/Users/laurent/src/joplin-plugins-test', Setting.value('tempDir')); | ||||
| 	return repoApi_; | ||||
| } | ||||
| @@ -170,20 +171,6 @@ export default function(props: Props) { | ||||
| 		}; | ||||
| 	}, [manifestsLoaded, pluginItems, pluginService.appVersion]); | ||||
|  | ||||
| 	const onDelete = useCallback(async (event: ItemEvent) => { | ||||
| 		const item = event.item; | ||||
| 		const confirm = await bridge().showConfirmMessageBox(_('Delete plugin "%s"?', item.manifest.name)); | ||||
| 		if (!confirm) return; | ||||
|  | ||||
| 		const newSettings = produce(pluginSettings, (draft: PluginSettings) => { | ||||
| 			if (!draft[item.manifest.id]) draft[item.manifest.id] = defaultPluginSetting(); | ||||
| 			draft[item.manifest.id].deleted = true; | ||||
| 		}); | ||||
|  | ||||
| 		props.onChange({ value: pluginService.serializePluginSettings(newSettings) }); | ||||
| 		// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied | ||||
| 	}, [pluginSettings, props.onChange]); | ||||
|  | ||||
| 	const onToggle = useCallback((event: ItemEvent) => { | ||||
| 		const item = event.item; | ||||
|  | ||||
| @@ -220,9 +207,9 @@ export default function(props: Props) { | ||||
|  | ||||
| 	const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => { | ||||
| 		props.onChange({ value: pluginService.serializePluginSettings(event.value) }); | ||||
| 		// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied | ||||
| 	}, []); | ||||
| 	}, [pluginService, props.onChange]); | ||||
|  | ||||
| 	const onDelete = useOnDeleteHandler(pluginSettings, onPluginSettingsChange, false); | ||||
| 	const onUpdate = useOnInstallHandler(setUpdatingPluginIds, pluginSettings, repoApi, onPluginSettingsChange, true); | ||||
|  | ||||
| 	const onToolsClick = useCallback(async () => { | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | ||||
| import PluginBox, { InstallState } from './PluginBox'; | ||||
| import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import useOnInstallHandler from './useOnInstallHandler'; | ||||
| import useOnInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler'; | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
|  | ||||
| const Root = styled.div` | ||||
| @@ -32,14 +32,6 @@ interface Props { | ||||
| 	disabled: boolean; | ||||
| } | ||||
|  | ||||
| function sortManifestResults(results: PluginManifest[]): PluginManifest[] { | ||||
| 	return results.sort((m1, m2) => { | ||||
| 		if (m1._recommended && !m2._recommended) return -1; | ||||
| 		if (!m1._recommended && m2._recommended) return +1; | ||||
| 		return m1.name.toLowerCase() < m2.name.toLowerCase() ? -1 : +1; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export default function(props: Props) { | ||||
| 	const [searchStarted, setSearchStarted] = useState(false); | ||||
| 	const [manifests, setManifests] = useState<PluginManifest[]>([]); | ||||
| @@ -57,7 +49,7 @@ export default function(props: Props) { | ||||
| 				setSearchResultCount(null); | ||||
| 			} else { | ||||
| 				const r = await props.repoApi().search(props.searchQuery); | ||||
| 				setManifests(sortManifestResults(r)); | ||||
| 				setManifests(r); | ||||
| 				setSearchResultCount(r.length); | ||||
| 			} | ||||
| 		}); | ||||
|   | ||||
| @@ -1,63 +0,0 @@ | ||||
| import { useCallback } from 'react'; | ||||
| import PluginService, { defaultPluginSetting, PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import produce from 'immer'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { ItemEvent } from './PluginBox'; | ||||
|  | ||||
| const logger = Logger.create('useOnInstallHandler'); | ||||
|  | ||||
| export interface OnPluginSettingChangeEvent { | ||||
| 	value: PluginSettings; | ||||
| } | ||||
|  | ||||
| type OnPluginSettingChangeHandler = (event: OnPluginSettingChangeEvent)=> void; | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| export default function(setInstallingPluginIds: Function, pluginSettings: PluginSettings, repoApi: Function, onPluginSettingsChange: OnPluginSettingChangeHandler, isUpdate: boolean) { | ||||
| 	return useCallback(async (event: ItemEvent) => { | ||||
| 		const pluginId = event.item.manifest.id; | ||||
|  | ||||
| 		setInstallingPluginIds((prev: any) => { | ||||
| 			return { | ||||
| 				...prev, [pluginId]: true, | ||||
| 			}; | ||||
| 		}); | ||||
|  | ||||
| 		let installError = null; | ||||
|  | ||||
| 		try { | ||||
| 			if (isUpdate) { | ||||
| 				await PluginService.instance().updatePluginFromRepo(repoApi(), pluginId); | ||||
| 			} else { | ||||
| 				await PluginService.instance().installPluginFromRepo(repoApi(), pluginId); | ||||
| 			} | ||||
| 		} catch (error) { | ||||
| 			installError = error; | ||||
| 			logger.error(error); | ||||
| 		} | ||||
|  | ||||
| 		if (!installError) { | ||||
| 			const newSettings = produce(pluginSettings, (draft: PluginSettings) => { | ||||
| 				draft[pluginId] = defaultPluginSetting(); | ||||
| 				if (isUpdate) { | ||||
| 					if (pluginSettings[pluginId]) { | ||||
| 						draft[pluginId].enabled = pluginSettings[pluginId].enabled; | ||||
| 					} | ||||
| 					draft[pluginId].hasBeenUpdated = true; | ||||
| 				} | ||||
| 			}); | ||||
|  | ||||
| 			onPluginSettingsChange({ value: newSettings }); | ||||
| 		} | ||||
|  | ||||
| 		setInstallingPluginIds((prev: any) => { | ||||
| 			return { | ||||
| 				...prev, [pluginId]: false, | ||||
| 			}; | ||||
| 		}); | ||||
|  | ||||
| 		if (installError) alert(_('Could not install plugin: %s', installError.message)); | ||||
| 		// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied | ||||
| 	}, [pluginSettings, onPluginSettingsChange]); | ||||
| } | ||||
| @@ -125,7 +125,7 @@ const useEditorCommands = (props: Props) => { | ||||
| 				} | ||||
| 			}, | ||||
| 			search: () => { | ||||
| 				editorRef.current.execCommand(EditorCommandType.ShowSearch); | ||||
| 				return editorRef.current.execCommand(EditorCommandType.ShowSearch); | ||||
| 			}, | ||||
| 		}; | ||||
| 	}, [ | ||||
|   | ||||
| @@ -32,6 +32,7 @@ export default class PlatformImplementation extends BasePlatformImplementation { | ||||
| 			version: packageInfo.version, | ||||
| 			syncVersion: Setting.value('syncVersion'), | ||||
| 			profileVersion: reg.db().version(), | ||||
| 			platform: 'desktop', | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import Setting, { AppType } from '../../../models/Setting'; | ||||
| import Setting, { AppType, SettingMetadataSection, SettingSectionSource } from '../../../models/Setting'; | ||||
| import SyncTargetRegistry from '../../../SyncTargetRegistry'; | ||||
| const ObjectUtils = require('../../../ObjectUtils'); | ||||
| const { _ } = require('../../../locale'); | ||||
| @@ -127,6 +127,11 @@ export const updateSettingValue = (comp: ConfigScreenComponent, key: string, val | ||||
| 			changedSettingKeys: changedSettingKeys, | ||||
| 		}; | ||||
| 	}, callback); | ||||
|  | ||||
| 	const metadata = Setting.settingMetadata(key); | ||||
| 	if (metadata.autoSave) { | ||||
| 		scheduleSaveSettings(comp); | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| let scheduleSaveSettingsIID: ReturnType<typeof setTimeout>|null = null; | ||||
| @@ -245,10 +250,27 @@ export const settingsSections = createSelector( | ||||
|  | ||||
| 		const order = Setting.sectionOrder(); | ||||
|  | ||||
| 		const sortOrderFor = (section: SettingMetadataSection) => { | ||||
| 			if (section.source === SettingSectionSource.Plugin) { | ||||
| 				// Plugins should go after all other sections | ||||
| 				return order.length + 1; | ||||
| 			} | ||||
|  | ||||
| 			return order.indexOf(section.name); | ||||
| 		}; | ||||
|  | ||||
| 		output.sort((a, b) => { | ||||
| 			const o1 = order.indexOf(a.name); | ||||
| 			const o2 = order.indexOf(b.name); | ||||
| 			return o1 < o2 ? -1 : +1; | ||||
| 			const o1 = sortOrderFor(a); | ||||
| 			const o2 = sortOrderFor(b); | ||||
|  | ||||
| 			if (o1 === o2) { | ||||
| 				const l1 = Setting.sectionNameToLabel(a.name); | ||||
| 				const l2 = Setting.sectionNameToLabel(b.name); | ||||
|  | ||||
| 				return l1.toLowerCase() < l2.toLowerCase() ? -1 : +1; | ||||
| 			} | ||||
|  | ||||
| 			return o1 - o2; | ||||
| 		}); | ||||
|  | ||||
| 		return output; | ||||
|   | ||||
							
								
								
									
										23
									
								
								packages/lib/components/shared/config/plugins/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/lib/components/shared/config/plugins/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import { PluginSettings } from '../../../../services/plugins/PluginService'; | ||||
| import { PluginManifest } from '../../../../services/plugins/utils/types'; | ||||
|  | ||||
|  | ||||
| export interface PluginItem { | ||||
| 	manifest: PluginManifest; | ||||
| 	enabled: boolean; | ||||
| 	deleted: boolean; | ||||
| 	devMode: boolean; | ||||
| 	builtIn: boolean; | ||||
| 	hasBeenUpdated: boolean; | ||||
| } | ||||
|  | ||||
| export interface ItemEvent { | ||||
| 	item: PluginItem; | ||||
| } | ||||
|  | ||||
|  | ||||
| export interface OnPluginSettingChangeEvent { | ||||
| 	value: PluginSettings; | ||||
| } | ||||
|  | ||||
| export type OnPluginSettingChangeHandler = (event: OnPluginSettingChangeEvent)=> void; | ||||
| @@ -0,0 +1,40 @@ | ||||
| import { _ } from '../../../../locale'; | ||||
| import PluginService, { PluginSettings, defaultPluginSetting } from '../../../../services/plugins/PluginService'; | ||||
| import shim from '../../../../shim'; | ||||
| import produce from 'immer'; | ||||
| import { ItemEvent, OnPluginSettingChangeHandler } from './types'; | ||||
|  | ||||
| const useOnDeleteHandler = ( | ||||
| 	pluginSettings: PluginSettings, | ||||
| 	onSettingsChange: OnPluginSettingChangeHandler, | ||||
| 	deleteNow: boolean, | ||||
| ) => { | ||||
| 	const React = shim.react(); | ||||
| 	return React.useCallback(async (event: ItemEvent) => { | ||||
| 		const item = event.item; | ||||
| 		const confirmed = await shim.showConfirmationDialog(_('Delete plugin "%s"?', item.manifest.name)); | ||||
| 		if (!confirmed) return; | ||||
|  | ||||
| 		let newSettings = produce(pluginSettings, (draft: PluginSettings) => { | ||||
| 			if (!draft[item.manifest.id]) draft[item.manifest.id] = defaultPluginSetting(); | ||||
| 			draft[item.manifest.id].deleted = true; | ||||
| 		}); | ||||
|  | ||||
| 		if (deleteNow) { | ||||
| 			const pluginService = PluginService.instance(); | ||||
|  | ||||
| 			// We first unload the plugin. This is done here rather than in pluginService.uninstallPlugins | ||||
| 			// because unloadPlugin may not work on desktop. | ||||
| 			const plugin = pluginService.plugins[item.manifest.id]; | ||||
| 			if (plugin) { | ||||
| 				await pluginService.unloadPlugin(item.manifest.id); | ||||
| 			} | ||||
|  | ||||
| 			newSettings = await pluginService.uninstallPlugins(newSettings); | ||||
| 		} | ||||
|  | ||||
| 		onSettingsChange({ value: newSettings }); | ||||
| 	}, [pluginSettings, onSettingsChange, deleteNow]); | ||||
| }; | ||||
|  | ||||
| export default useOnDeleteHandler; | ||||
| @@ -1,10 +1,10 @@ | ||||
| import useOnInstallHandler from './useOnInstallHandler'; | ||||
| import { renderHook } from '@testing-library/react-hooks'; | ||||
| 
 | ||||
| import PluginService, { defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import { ItemEvent } from './PluginBox'; | ||||
| import PluginService, { defaultPluginSetting } from '../../../../services/plugins/PluginService'; | ||||
| import { ItemEvent } from './types'; | ||||
| 
 | ||||
| jest.mock('@joplin/lib/services/plugins/PluginService'); | ||||
| jest.mock('../../../../services/plugins/PluginService'); | ||||
| 
 | ||||
| const pluginServiceInstance = { | ||||
| 	updatePluginFromRepo: jest.fn(), | ||||
| @@ -37,7 +37,7 @@ describe('useOnInstallHandler', () => { | ||||
| 	beforeAll(() => { | ||||
| 		(PluginService.instance as jest.Mock).mockReturnValue(pluginServiceInstance); | ||||
| 		(defaultPluginSetting as jest.Mock).mockImplementation( | ||||
| 			jest.requireActual('@joplin/lib/services/plugins/PluginService').defaultPluginSetting, | ||||
| 			jest.requireActual('../../../../services/plugins/PluginService').defaultPluginSetting, | ||||
| 		); | ||||
| 	}); | ||||
| 
 | ||||
| @@ -0,0 +1,75 @@ | ||||
| import produce from 'immer'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { ItemEvent, OnPluginSettingChangeHandler } from './types'; | ||||
| import type * as React from 'react'; | ||||
| import shim from '../../../../shim'; | ||||
| import RepositoryApi from '../../../../services/plugins/RepositoryApi'; | ||||
| import PluginService, { PluginSettings, defaultPluginSetting } from '../../../../services/plugins/PluginService'; | ||||
| import { _ } from '../../../../locale'; | ||||
|  | ||||
| const logger = Logger.create('useOnInstallHandler'); | ||||
|  | ||||
| type GetRepoApiCallback = ()=> RepositoryApi; | ||||
|  | ||||
| const useOnInstallHandler = ( | ||||
| 	setInstallingPluginIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>, | ||||
| 	pluginSettings: PluginSettings, | ||||
| 	getRepoApi: GetRepoApiCallback|RepositoryApi, | ||||
| 	onPluginSettingsChange: OnPluginSettingChangeHandler, | ||||
| 	isUpdate: boolean, | ||||
| ) => { | ||||
| 	const React = shim.react(); | ||||
| 	return React.useCallback(async (event: ItemEvent) => { | ||||
| 		const pluginId = event.item.manifest.id; | ||||
|  | ||||
| 		setInstallingPluginIds((prev: any) => { | ||||
| 			return { | ||||
| 				...prev, [pluginId]: true, | ||||
| 			}; | ||||
| 		}); | ||||
|  | ||||
| 		let installError = null; | ||||
|  | ||||
| 		try { | ||||
| 			const repoApi = typeof getRepoApi === 'function' ? getRepoApi() : getRepoApi; | ||||
|  | ||||
| 			if (isUpdate) { | ||||
| 				await PluginService.instance().updatePluginFromRepo(repoApi, pluginId); | ||||
| 			} else { | ||||
| 				await PluginService.instance().installPluginFromRepo(repoApi, pluginId); | ||||
| 			} | ||||
| 		} catch (error) { | ||||
| 			installError = error; | ||||
| 			logger.error(error); | ||||
| 		} | ||||
|  | ||||
| 		if (!installError) { | ||||
| 			const newSettings = produce(pluginSettings, (draft: PluginSettings) => { | ||||
| 				draft[pluginId] = defaultPluginSetting(); | ||||
| 				if (isUpdate) { | ||||
| 					if (pluginSettings[pluginId]) { | ||||
| 						draft[pluginId].enabled = pluginSettings[pluginId].enabled; | ||||
| 					} | ||||
| 					draft[pluginId].hasBeenUpdated = true; | ||||
| 				} | ||||
| 			}); | ||||
|  | ||||
| 			onPluginSettingsChange({ value: newSettings }); | ||||
| 		} | ||||
|  | ||||
| 		setInstallingPluginIds((prev: any) => { | ||||
| 			return { | ||||
| 				...prev, [pluginId]: false, | ||||
| 			}; | ||||
| 		}); | ||||
|  | ||||
| 		if (installError) { | ||||
| 			await shim.showMessageBox( | ||||
| 				_('Could not install plugin: %s', installError.message), | ||||
| 				{ buttons: [_('OK')] }, | ||||
| 			); | ||||
| 		} | ||||
| 	}, [getRepoApi, isUpdate, pluginSettings, onPluginSettingsChange, setInstallingPluginIds]); | ||||
| }; | ||||
|  | ||||
| export default useOnInstallHandler; | ||||
							
								
								
									
										13
									
								
								packages/lib/hooks/usePrevious.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								packages/lib/hooks/usePrevious.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import shim from '../shim'; | ||||
|  | ||||
| const { useRef, useEffect } = shim.react(); | ||||
|  | ||||
| const usePrevious = (value: any, initialValue: any = null) => { | ||||
| 	const ref = useRef(initialValue); | ||||
| 	useEffect(() => { | ||||
| 		ref.current = value; | ||||
| 	}); | ||||
| 	return ref.current; | ||||
| }; | ||||
|  | ||||
| export default usePrevious; | ||||
| @@ -5,9 +5,12 @@ const nodeSqlite = require('sqlite3'); | ||||
| const pdfJs = require('pdfjs-dist'); | ||||
| const packageInfo = require('./package.json'); | ||||
|  | ||||
| // Used for testing some shared components | ||||
| const React = require('react'); | ||||
|  | ||||
| require('../../jest.base-setup.js')(); | ||||
|  | ||||
| shimInit({ sharp, nodeSqlite, pdfJs, appVersion: () => packageInfo.version }); | ||||
| shimInit({ sharp, nodeSqlite, pdfJs, React, appVersion: () => packageInfo.version }); | ||||
|  | ||||
| global.afterEach(async () => { | ||||
| 	await afterEachCleanUp(); | ||||
|   | ||||
| @@ -406,4 +406,18 @@ describe('models/Setting', () => { | ||||
| 		expect(Setting.value('myCustom')).toBe(''); | ||||
| 	}); | ||||
|  | ||||
| 	test('should not fail Sqlite UNIQUE constraint when re-registering saved settings', async () => { | ||||
| 		// Re-registering a saved database setting previously caused issues with saving. | ||||
| 		for (let i = 0; i < 2; i++) { | ||||
| 			await Setting.registerSetting('myCustom', { | ||||
| 				public: true, | ||||
| 				value: `${i}`, | ||||
| 				type: Setting.TYPE_STRING, | ||||
| 				storage: SettingStorage.Database, | ||||
| 			}); | ||||
| 			Setting.setValue('myCustom', 'test'); | ||||
| 			await Setting.saveAll(); | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
|   | ||||
| @@ -227,6 +227,8 @@ export type SettingMetadataSection = { | ||||
| 	name: string; | ||||
| 	isScreen?: boolean; | ||||
| 	metadatas: SettingItem[]; | ||||
|  | ||||
| 	source?: SettingSectionSource; | ||||
| }; | ||||
| export type MetadataBySection = SettingMetadataSection[]; | ||||
|  | ||||
| @@ -1933,6 +1935,11 @@ class Setting extends BaseModel { | ||||
| 			// Reload the value from the database, if it was already present | ||||
| 			const valueRow = await this.loadOne(key); | ||||
| 			if (valueRow) { | ||||
| 				// Remove any duplicate copies of the setting -- if multiple items in cache_ | ||||
| 				// have the same key, we may encounter unique key errors while saving to the | ||||
| 				// database. | ||||
| 				this.cache_ = this.cache_.filter(setting => setting.key !== key); | ||||
|  | ||||
| 				this.cache_.push({ | ||||
| 					key: key, | ||||
| 					value: this.formatValue(key, valueRow.value), | ||||
| @@ -2271,7 +2278,7 @@ class Setting extends BaseModel { | ||||
| 		} | ||||
|  | ||||
| 		for (const k in enumOptions) { | ||||
| 			if (!enumOptions.hasOwnProperty(k)) continue; | ||||
| 			if (!Object.prototype.hasOwnProperty.call(enumOptions, k)) continue; | ||||
| 			if (order.includes(k)) continue; | ||||
|  | ||||
| 			output.push({ | ||||
| @@ -2702,10 +2709,29 @@ class Setting extends BaseModel { | ||||
| 			'revisionService': _('Toggle note history, keep notes for'), | ||||
| 			'tools': _('Logs, profiles, sync status'), | ||||
| 			'export': _('Export your data'), | ||||
| 			'plugins': _('Enable or disable plugins'), | ||||
| 			'moreInfo': _('Donate, website'), | ||||
| 		}; | ||||
|  | ||||
| 		return sectionNameToSummary[metadata.name] ?? ''; | ||||
| 		// In some cases (e.g. plugin settings pages) there is no preset summary. | ||||
| 		// In those cases, we generate the summary: | ||||
| 		const generateSummary = () => { | ||||
| 			const summary = []; | ||||
| 			for (const item of metadata.metadatas) { | ||||
| 				if (!item.public || item.advanced) { | ||||
| 					continue; | ||||
| 				} | ||||
|  | ||||
| 				if (item.label) { | ||||
| 					const label = item.label?.(); | ||||
| 					summary.push(label); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return summary.join(', '); | ||||
| 		}; | ||||
|  | ||||
| 		return sectionNameToSummary[metadata.name] ?? generateSummary(); | ||||
| 	} | ||||
|  | ||||
| 	public static sectionNameToIcon(name: string, appType: AppType) { | ||||
|   | ||||
| @@ -16,6 +16,7 @@ | ||||
|     "test-ci": "yarn test" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@testing-library/react-hooks": "8.0.1", | ||||
|     "@types/fs-extra": "11.0.4", | ||||
|     "@types/jest": "29.5.8", | ||||
|     "@types/js-yaml": "4.0.9", | ||||
| @@ -29,6 +30,8 @@ | ||||
|     "clean-html": "1.5.0", | ||||
|     "jest": "29.7.0", | ||||
|     "pdfjs-dist": "3.11.174", | ||||
|     "react": "18.2.0", | ||||
|     "react-test-renderer": "18.2.0", | ||||
|     "sharp": "0.33.2", | ||||
|     "tesseract.js": "5.0.4", | ||||
|     "typescript": "5.2.2" | ||||
|   | ||||
| @@ -6,7 +6,7 @@ export default class NavService { | ||||
| 	public static dispatch: Function = () => {}; | ||||
| 	private static handlers_: OnNavigateCallback[] = []; | ||||
|  | ||||
| 	public static async go(routeName: string) { | ||||
| 	public static async go(routeName: string, additionalProps: Record<string, any>|null = null) { | ||||
| 		if (this.handlers_.length) { | ||||
| 			const r = await this.handlers_[this.handlers_.length - 1](); | ||||
| 			if (r) return r; | ||||
| @@ -15,6 +15,7 @@ export default class NavService { | ||||
| 		this.dispatch({ | ||||
| 			type: 'NAV_GO', | ||||
| 			routeName: routeName, | ||||
| 			...additionalProps, | ||||
| 		}); | ||||
| 		return false; | ||||
| 	} | ||||
|   | ||||
| @@ -22,35 +22,35 @@ export interface Joplin { | ||||
| export default class BasePlatformImplementation { | ||||
|  | ||||
| 	public get versionInfo(): VersionInfo { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: versionInfo'); | ||||
| 	} | ||||
|  | ||||
| 	public get clipboard(): any { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: clipboard'); | ||||
| 	} | ||||
|  | ||||
| 	public get nativeImage(): any { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: nativeImage'); | ||||
| 	} | ||||
|  | ||||
| 	public get window(): WindowImplementation { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: window'); | ||||
| 	} | ||||
|  | ||||
| 	public registerComponent(_name: string, _component: any) { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: registerComponent'); | ||||
| 	} | ||||
|  | ||||
| 	public unregisterComponent(_name: string) { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: unregisterComponent'); | ||||
| 	} | ||||
|  | ||||
| 	public get joplin(): Joplin { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: joplin'); | ||||
| 	} | ||||
|  | ||||
| 	public get imaging(): ImagingImplementation { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: imaging'); | ||||
| 	} | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -22,6 +22,10 @@ export default abstract class BasePluginRunner extends BaseService { | ||||
| 		throw new Error(`Not implemented: ${plugin} / ${sandbox}`); | ||||
| 	} | ||||
|  | ||||
| 	public async stop(plugin: Plugin): Promise<void> { | ||||
| 		throw new Error(`Not implemented ${plugin} stop`); | ||||
| 	} | ||||
|  | ||||
| 	public async waitForSandboxCalls(): Promise<void> { | ||||
| 		throw new Error('Not implemented: waitForSandboxCalls'); | ||||
| 	} | ||||
|   | ||||
| @@ -39,6 +39,7 @@ export default class Plugin { | ||||
| 	private contentScriptMessageListeners_: Record<string, Function> = {}; | ||||
| 	private dataDir_: string; | ||||
| 	private dataDirCreated_ = false; | ||||
| 	private hasErrors_ = false; | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	public constructor(baseDir: string, manifest: PluginManifest, scriptText: string, dispatch: Function, dataDir: string) { | ||||
| @@ -97,6 +98,14 @@ export default class Plugin { | ||||
| 		return Object.keys(this.viewControllers_).length; | ||||
| 	} | ||||
|  | ||||
| 	public get hasErrors(): boolean { | ||||
| 		return this.hasErrors_; | ||||
| 	} | ||||
|  | ||||
| 	public set hasErrors(hasErrors: boolean) { | ||||
| 		this.hasErrors_ = hasErrors; | ||||
| 	} | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	public on(eventName: string, callback: Function) { | ||||
| 		return this.eventEmitter_.on(eventName, callback); | ||||
| @@ -190,4 +199,11 @@ export default class Plugin { | ||||
| 		return this.contentScriptMessageListeners_[id](message); | ||||
| 	} | ||||
|  | ||||
| 	public onUnload() { | ||||
| 		this.dispatch_({ | ||||
| 			type: 'PLUGIN_UNLOAD', | ||||
| 			pluginId: this.id, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -144,6 +144,22 @@ export default class PluginService extends BaseService { | ||||
| 		delete this.plugins_[pluginId]; | ||||
| 	} | ||||
|  | ||||
| 	public async unloadPlugin(pluginId: string) { | ||||
| 		const plugin = this.plugins_[pluginId]; | ||||
| 		if (plugin) { | ||||
| 			this.logger().info(`Unloading plugin ${pluginId}`); | ||||
|  | ||||
| 			plugin.onUnload(); | ||||
| 			await this.runner_.stop(plugin); | ||||
|  | ||||
| 			this.deletePluginAt(pluginId); | ||||
| 			this.startedPlugins_ = { ...this.startedPlugins_ }; | ||||
| 			delete this.startedPlugins_[pluginId]; | ||||
| 		} else { | ||||
| 			this.logger().info(`Unable to unload plugin ${pluginId} -- already unloaded`); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private async deletePluginFiles(plugin: Plugin) { | ||||
| 		await shim.fsDriver().remove(plugin.baseDir); | ||||
| 	} | ||||
| @@ -167,7 +183,7 @@ export default class PluginService extends BaseService { | ||||
| 		return output; | ||||
| 	} | ||||
|  | ||||
| 	public serializePluginSettings(settings: PluginSettings): any { | ||||
| 	public serializePluginSettings(settings: PluginSettings): string { | ||||
| 		return JSON.stringify(settings); | ||||
| 	} | ||||
|  | ||||
| @@ -343,7 +359,7 @@ export default class PluginService extends BaseService { | ||||
|  | ||||
| 	private pluginEnabled(settings: PluginSettings, pluginId: string): boolean { | ||||
| 		if (!settings[pluginId]) return true; | ||||
| 		return settings[pluginId].enabled !== false; | ||||
| 		return settings[pluginId].enabled !== false && settings[pluginId].deleted !== true; | ||||
| 	} | ||||
|  | ||||
| 	public callStatsSummary(pluginId: string, duration: number) { | ||||
| @@ -407,6 +423,20 @@ export default class PluginService extends BaseService { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async loadAndRunDevPlugins(settings: PluginSettings) { | ||||
| 		const devPluginOptions = { devMode: true, builtIn: false }; | ||||
|  | ||||
| 		if (Setting.value('plugins.devPluginPaths')) { | ||||
| 			const paths = Setting.value('plugins.devPluginPaths').split(',').map((p: string) => p.trim()); | ||||
| 			await this.loadAndRunPlugins(paths, settings, devPluginOptions); | ||||
| 		} | ||||
|  | ||||
| 		// Also load dev plugins that have passed via command line arguments | ||||
| 		if (Setting.value('startupDevPlugins')) { | ||||
| 			await this.loadAndRunPlugins(Setting.value('startupDevPlugins'), settings, devPluginOptions); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public isCompatible(pluginVersion: string): boolean { | ||||
| 		return compareVersions(this.appVersion_, pluginVersion) >= 0; | ||||
| 	} | ||||
| @@ -450,6 +480,7 @@ export default class PluginService extends BaseService { | ||||
| 	public async installPluginFromRepo(repoApi: RepositoryApi, pluginId: string): Promise<Plugin> { | ||||
| 		const pluginPath = await repoApi.downloadPlugin(pluginId); | ||||
| 		const plugin = await this.installPlugin(pluginPath); | ||||
|  | ||||
| 		await shim.fsDriver().remove(pluginPath); | ||||
| 		return plugin; | ||||
| 	} | ||||
| @@ -467,6 +498,13 @@ export default class PluginService extends BaseService { | ||||
| 		const preloadedPlugin = await this.loadPluginFromPath(jplPath); | ||||
| 		await this.deletePluginFiles(preloadedPlugin); | ||||
|  | ||||
| 		// On mobile, it's necessary to create the plugin directory before we can copy | ||||
| 		// into it. | ||||
| 		if (!(await shim.fsDriver().exists(Setting.value('pluginDir')))) { | ||||
| 			logger.info(`Creating plugin directory: ${Setting.value('pluginDir')}`); | ||||
| 			await shim.fsDriver().mkdir(Setting.value('pluginDir')); | ||||
| 		} | ||||
|  | ||||
| 		const destPath = `${Setting.value('pluginDir')}/${preloadedPlugin.id}.jpl`; | ||||
| 		await shim.fsDriver().copy(jplPath, destPath); | ||||
|  | ||||
|   | ||||
| @@ -68,6 +68,10 @@ export default class RepositoryApi { | ||||
| 		this.tempDir_ = tempDir; | ||||
| 	} | ||||
|  | ||||
| 	public static ofDefaultJoplinRepo(tempDirPath: string) { | ||||
| 		return new RepositoryApi('https://github.com/joplin/plugins', tempDirPath); | ||||
| 	} | ||||
|  | ||||
| 	public async initialize() { | ||||
| 		// https://github.com/joplin/plugins | ||||
| 		// https://api.github.com/repos/joplin/plugins/releases | ||||
| @@ -183,6 +187,12 @@ export default class RepositoryApi { | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		output.sort((m1, m2) => { | ||||
| 			if (m1._recommended && !m2._recommended) return -1; | ||||
| 			if (!m1._recommended && m2._recommended) return +1; | ||||
| 			return m1.name.toLowerCase() < m2.name.toLowerCase() ? -1 : +1; | ||||
| 		}); | ||||
|  | ||||
| 		return output; | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -47,6 +47,9 @@ export default class WebviewController extends ViewController { | ||||
| 	private messageListener_: Function = null; | ||||
| 	private closeResponse_: CloseResponse = null; | ||||
|  | ||||
| 	// True if a **panel** is shown in a modal window. | ||||
| 	private panelInModalMode_ = false; | ||||
|  | ||||
| 	public constructor(handle: ViewHandle, pluginId: string, store: any, baseDir: string, containerType: ContainerType) { | ||||
| 		super(handle, pluginId, store); | ||||
| 		this.baseDir_ = toSystemSlashes(baseDir, 'linux'); | ||||
| @@ -150,8 +153,26 @@ export default class WebviewController extends ViewController { | ||||
| 		return this.show(false); | ||||
| 	} | ||||
|  | ||||
| 	// This method allows us to determine whether a panel is shown in dialog mode, | ||||
| 	// which is used on mobile. | ||||
| 	public setIsShownInModal(shown: boolean) { | ||||
| 		this.panelInModalMode_ = shown; | ||||
| 	} | ||||
|  | ||||
| 	public get visible(): boolean { | ||||
| 		const mainLayout = this.store.getState().mainLayout; | ||||
| 		const appState = this.store.getState(); | ||||
|  | ||||
| 		if (this.panelInModalMode_) { | ||||
| 			return true; | ||||
| 		} | ||||
|  | ||||
| 		const mainLayout = appState.mainLayout; | ||||
|  | ||||
| 		// Mobile: There is no appState.mainLayout | ||||
| 		if (!mainLayout) { | ||||
| 			return false; | ||||
| 		} | ||||
|  | ||||
| 		const item = findItemByKey(mainLayout, this.handle); | ||||
| 		return item ? item.visible : false; | ||||
| 	} | ||||
|   | ||||
| @@ -55,8 +55,7 @@ import { Command } from './types'; | ||||
| export default class JoplinCommands { | ||||
|  | ||||
| 	/** | ||||
| 	 * <span class="platform-desktop">desktop</span> Executes the given | ||||
| 	 * command. | ||||
| 	 * Executes the given command. | ||||
| 	 * | ||||
| 	 * The command can take any number of arguments, and the supported | ||||
| 	 * arguments will vary based on the command. For custom commands, this | ||||
| @@ -78,7 +77,7 @@ export default class JoplinCommands { | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * <span class="platform-desktop">desktop</span> Registers a new command. | ||||
| 	 * Registers a new command. | ||||
| 	 * | ||||
| 	 * ```typescript | ||||
| 	 * // Register a new commmand called "testCommand1" | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| /* eslint-disable multiline-comment-style */ | ||||
|  | ||||
| import shim from '../../../shim'; | ||||
| import Plugin from '../Plugin'; | ||||
| import * as fs from 'fs-extra'; | ||||
|  | ||||
| export interface Implementation { | ||||
| 	injectCustomStyles(elementId: string, cssFilePath: string): Promise<void>; | ||||
| @@ -36,7 +36,7 @@ export default class JoplinWindow { | ||||
| 	 * for an example. | ||||
| 	 */ | ||||
| 	public async loadNoteCssFile(filePath: string) { | ||||
| 		const cssString = await fs.readFile(filePath, 'utf8'); | ||||
| 		const cssString = await shim.fsDriver().readFile(filePath, 'utf8'); | ||||
|  | ||||
| 		this.store_.dispatch({ | ||||
| 			type: 'CUSTOM_CSS_APPEND', | ||||
|   | ||||
| @@ -227,6 +227,8 @@ export interface VersionInfo { | ||||
| 	version: string; | ||||
| 	profileVersion: number; | ||||
| 	syncVersion: number; | ||||
|  | ||||
| 	platform: 'desktop'|'mobile'; | ||||
| } | ||||
|  | ||||
| // ================================================================= | ||||
|   | ||||
| @@ -1,13 +1,18 @@ | ||||
| import { Draft } from 'immer'; | ||||
| import { ContainerType } from './WebviewController'; | ||||
| import { ButtonSpec } from './api/types'; | ||||
|  | ||||
| export interface ViewInfo { | ||||
| 	view: any; | ||||
| 	plugin: any; | ||||
| } | ||||
|  | ||||
| interface PluginViewState { | ||||
| export interface PluginViewState { | ||||
| 	id: string; | ||||
| 	type: string; | ||||
| 	opened: boolean; | ||||
| 	buttons: ButtonSpec[]; | ||||
| 	fitToContent?: boolean; | ||||
| 	scripts?: string[]; | ||||
| 	html?: string; | ||||
| 	commandName?: string; | ||||
| 	location?: string; | ||||
| 	containerType: ContainerType; | ||||
| } | ||||
|  | ||||
| interface PluginViewStates { | ||||
| @@ -29,6 +34,11 @@ interface PluginState { | ||||
| 	views: PluginViewStates; | ||||
| } | ||||
|  | ||||
| export interface ViewInfo { | ||||
| 	view: PluginViewState; | ||||
| 	plugin: PluginState; | ||||
| } | ||||
|  | ||||
| export interface PluginStates { | ||||
| 	[key: string]: PluginState; | ||||
| } | ||||
| @@ -181,6 +191,10 @@ const reducer = (draftRoot: Draft<any>, action: any) => { | ||||
| 			break; | ||||
| 		} | ||||
|  | ||||
| 		case 'PLUGIN_UNLOAD': | ||||
| 			delete draft.plugins[action.pluginId]; | ||||
| 			break; | ||||
|  | ||||
| 		} | ||||
| 	} catch (error) { | ||||
| 		error.message = `In plugin reducer: ${error.message} Action: ${JSON.stringify(action)}`; | ||||
|   | ||||
| @@ -83,7 +83,6 @@ async function requestNoteToNote(requestNote: RequestNote): Promise<NoteEntity> | ||||
| 	if (requestNote.body_html) { | ||||
| 		if (requestNote.convert_to === 'html') { | ||||
| 			const style = await buildNoteStyleSheet(requestNote.stylesheets); | ||||
| 			const minify = require('html-minifier').minify; | ||||
|  | ||||
| 			const minifyOptions = { | ||||
| 				// Remove all spaces and, especially, newlines from tag attributes, as that would | ||||
| @@ -106,6 +105,9 @@ async function requestNoteToNote(requestNote: RequestNote): Promise<NoteEntity> | ||||
| 			const styleTag = style.length ? `<style>${styleString}</style>` + '\n' : ''; | ||||
| 			let minifiedHtml = ''; | ||||
| 			try { | ||||
| 				// We use requireDynamic here -- html-minifier seems to not work in environments | ||||
| 				// that lack `fs`. | ||||
| 				const minify = shim.requireDynamic('html-minifier').minify; | ||||
| 				minifiedHtml = minify(requestNote.body_html, minifyOptions); | ||||
| 			} catch (error) { | ||||
| 				console.warn('Could not minify HTML - using non-minified HTML instead', error); | ||||
|   | ||||
| @@ -195,7 +195,7 @@ function shimInit(options: ShimInitOptions = null) { | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	shim.showMessageBox = (message, options = null) => { | ||||
| 	shim.showMessageBox = async (message, options = null) => { | ||||
| 		if (shim.isElectron()) { | ||||
| 			return shim.electronBridge().showMessageBox(message, options); | ||||
| 		} else { | ||||
| @@ -253,7 +253,7 @@ function shimInit(options: ShimInitOptions = null) { | ||||
| 			if (canResize) { | ||||
| 				if (resizeLargeImages === 'alwaysAsk') { | ||||
| 					const Yes = 0, No = 1, Cancel = 2; | ||||
| 					const userAnswer = shim.showMessageBox(`${_('You are about to attach a large image (%dx%d pixels). Would you like to resize it down to %d pixels before attaching it?', image.width, image.height, maxDim)}\n\n${_('(You may disable this prompt in the options)')}`, { | ||||
| 					const userAnswer = await shim.showMessageBox(`${_('You are about to attach a large image (%dx%d pixels). Would you like to resize it down to %d pixels before attaching it?', image.width, image.height, maxDim)}\n\n${_('(You may disable this prompt in the options)')}`, { | ||||
| 						buttons: [_('Yes'), _('No'), _('Cancel')], | ||||
| 					}); | ||||
| 					if (userAnswer === Yes) return await saveResizedImage(); | ||||
|   | ||||
| @@ -38,7 +38,7 @@ const shim = { | ||||
| 	proxyAgent: null as any, | ||||
|  | ||||
| 	electronBridge: (): any => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: electronBridge'); | ||||
| 	}, | ||||
|  | ||||
| 	msleep_: (ms: number) => { | ||||
| @@ -215,7 +215,7 @@ const shim = { | ||||
| 	}, | ||||
|  | ||||
| 	fetch: (_url: string, _options: any = null): any => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: fetch'); | ||||
| 	}, | ||||
|  | ||||
| 	fetchText: async (url: string, options: any = null): Promise<string> => { | ||||
| @@ -225,56 +225,56 @@ const shim = { | ||||
| 	}, | ||||
|  | ||||
| 	createResourceFromPath: async (_filePath: string, _defaultProps: ResourceEntity = null, _options: CreateResourceFromPathOptions = null): Promise<ResourceEntity> => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: createResourceFromPath'); | ||||
| 	}, | ||||
|  | ||||
| 	FormData: typeof FormData !== 'undefined' ? FormData : null, | ||||
|  | ||||
| 	fsDriver: (): FsDriverBase => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: fsDriver'); | ||||
| 	}, | ||||
|  | ||||
| 	FileApiDriverLocal: null as any, | ||||
|  | ||||
| 	readLocalFileBase64: (_path: string): any => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: readLocalFileBase64'); | ||||
| 	}, | ||||
|  | ||||
| 	uploadBlob: (_url: string, _options: any): any => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: uploadBlob'); | ||||
| 	}, | ||||
|  | ||||
| 	sjclModule: null as any, | ||||
|  | ||||
| 	randomBytes: async (_count: number): Promise<any> => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: randomBytes'); | ||||
| 	}, | ||||
|  | ||||
| 	stringByteLength: (_s: string): any => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: stringByteLength'); | ||||
| 	}, | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	detectAndSetLocale: null as Function, | ||||
|  | ||||
| 	attachFileToNote: async (_note: any, _filePath: string): Promise<NoteEntity> => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: attachFileToNote'); | ||||
| 	}, | ||||
|  | ||||
| 	attachFileToNoteBody: async (_body: string, _filePath: string, _position: number, _options: any): Promise<string> => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: attachFileToNoteBody'); | ||||
| 	}, | ||||
|  | ||||
| 	imageToDataUrl: async (_filePath: string, _maxSize = 0): Promise<string> => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: imageToDataUrl'); | ||||
| 	}, | ||||
|  | ||||
| 	imageFromDataUrl: async (_imageDataUrl: string, _filePath: string, _options: any = null): Promise<any> => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: imageFromDataUrl'); | ||||
| 	}, | ||||
|  | ||||
| 	fetchBlob: function(_url: string, _options: any = null): any { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: fetchBlob'); | ||||
| 	}, | ||||
|  | ||||
| 	// Does not do OCR -- just extracts existing text from a PDF. | ||||
| @@ -283,29 +283,29 @@ const shim = { | ||||
| 	}, | ||||
|  | ||||
| 	pdfToImages: async (_pdfPath: string, _outputDirectoryPath: string): Promise<string[]> => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: pdfToImages'); | ||||
| 	}, | ||||
|  | ||||
| 	Buffer: null as any, | ||||
|  | ||||
| 	openUrl: (_url: string): any => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: openUrl'); | ||||
| 	}, | ||||
|  | ||||
| 	httpAgent: (_url: string): any => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: httpAgent'); | ||||
| 	}, | ||||
|  | ||||
| 	openOrCreateFile: (_path: string, _defaultContents: any): any => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: openOrCreateFile'); | ||||
| 	}, | ||||
|  | ||||
| 	waitForFrame: (): any => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: waitForFrame'); | ||||
| 	}, | ||||
|  | ||||
| 	appVersion: (): any => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 		throw new Error('Not implemented: appVersion'); | ||||
| 	}, | ||||
|  | ||||
| 	injectedJs: (_name: string) => '', | ||||
| @@ -322,10 +322,17 @@ const shim = { | ||||
| 		throw new Error('Not implemented'); | ||||
| 	}, | ||||
|  | ||||
| 	showMessageBox: (_message: string, _options: any = null): any => { | ||||
| 	// Returns the index of the button that was clicked. By default, | ||||
| 	// 0 -> OK | ||||
| 	// 1 -> Cancel | ||||
| 	showMessageBox: (_message: string, _options: any = null): Promise<number> => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 	}, | ||||
|  | ||||
| 	showConfirmationDialog: async (message: string): Promise<boolean> => { | ||||
| 		return await shim.showMessageBox(message) === 0; | ||||
| 	}, | ||||
|  | ||||
| 	writeImageToFile: (_image: any, _format: any, _filePath: string): void => { | ||||
| 		throw new Error('Not implemented'); | ||||
| 	}, | ||||
|   | ||||
| @@ -6846,6 +6846,7 @@ __metadata: | ||||
|     "@joplin/turndown": ^4.0.73 | ||||
|     "@joplin/turndown-plugin-gfm": ^1.0.55 | ||||
|     "@joplin/utils": ~3.0 | ||||
|     "@testing-library/react-hooks": 8.0.1 | ||||
|     "@types/fs-extra": 11.0.4 | ||||
|     "@types/jest": 29.5.8 | ||||
|     "@types/js-yaml": 4.0.9 | ||||
| @@ -6894,6 +6895,8 @@ __metadata: | ||||
|     promise: 8.3.0 | ||||
|     query-string: 7.1.3 | ||||
|     re-reselect: 4.0.1 | ||||
|     react: 18.2.0 | ||||
|     react-test-renderer: 18.2.0 | ||||
|     read-chunk: 2.1.0 | ||||
|     redux: 4.2.1 | ||||
|     relative: 3.0.2 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user