1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-02-19 20:00:20 +02:00

Chore: Apply changes from mobile plugins to lib/ and app-desktop/ (#10079)

This commit is contained in:
Henry Heino 2024-03-09 03:03:57 -08:00 committed by GitHub
parent 91004f5714
commit 25cd5affca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 418 additions and 205 deletions

View File

@ -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
View File

@ -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

View File

@ -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);
}

View File

@ -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 });

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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 () => {

View File

@ -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);
}
});

View File

@ -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]);
}

View File

@ -125,7 +125,7 @@ const useEditorCommands = (props: Props) => {
}
},
search: () => {
editorRef.current.execCommand(EditorCommandType.ShowSearch);
return editorRef.current.execCommand(EditorCommandType.ShowSearch);
},
};
}, [

View File

@ -32,6 +32,7 @@ export default class PlatformImplementation extends BasePlatformImplementation {
version: packageInfo.version,
syncVersion: Setting.value('syncVersion'),
profileVersion: reg.db().version(),
platform: 'desktop',
};
}

View File

@ -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;

View 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;

View File

@ -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;

View File

@ -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,
);
});

View File

@ -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;

View 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;

View File

@ -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();

View File

@ -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();
}
});
});

View File

@ -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) {

View File

@ -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"

View File

@ -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;
}

View File

@ -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');
}
}

View File

@ -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');
}

View File

@ -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,
});
}
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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"

View File

@ -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',

View File

@ -227,6 +227,8 @@ export interface VersionInfo {
version: string;
profileVersion: number;
syncVersion: number;
platform: 'desktop'|'mobile';
}
// =================================================================

View File

@ -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)}`;

View File

@ -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);

View File

@ -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();

View File

@ -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');
},

View File

@ -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