mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-02 12:47:41 +02:00
Desktop: Add support for searching and installing plugins from repository
This commit is contained in:
parent
1700b29f7d
commit
6dc5a816e5
@ -326,9 +326,18 @@ packages/app-desktop/gui/ConfigScreen/ConfigScreen.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/SideBar.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/SideBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/SideBar.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/controls/PluginsStates.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/PluginsStates.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/PluginsStates.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js.map
|
||||
packages/app-desktop/gui/DropboxLoginScreen.d.ts
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js.map
|
||||
@ -656,9 +665,6 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js.map
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.d.ts
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js.map
|
||||
packages/app-desktop/gui/SearchBar/styles/index.d.ts
|
||||
packages/app-desktop/gui/SearchBar/styles/index.js
|
||||
packages/app-desktop/gui/SearchBar/styles/index.js.map
|
||||
packages/app-desktop/gui/ShareNoteDialog.d.ts
|
||||
packages/app-desktop/gui/ShareNoteDialog.js
|
||||
packages/app-desktop/gui/ShareNoteDialog.js.map
|
||||
@ -707,6 +713,9 @@ packages/app-desktop/gui/hooks/usePrevious.js.map
|
||||
packages/app-desktop/gui/hooks/usePropsDebugger.d.ts
|
||||
packages/app-desktop/gui/hooks/usePropsDebugger.js
|
||||
packages/app-desktop/gui/hooks/usePropsDebugger.js.map
|
||||
packages/app-desktop/gui/lib/SearchInput/SearchInput.d.ts
|
||||
packages/app-desktop/gui/lib/SearchInput/SearchInput.js
|
||||
packages/app-desktop/gui/lib/SearchInput/SearchInput.js.map
|
||||
packages/app-desktop/gui/lib/ToggleButton/ToggleButton.d.ts
|
||||
packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js
|
||||
packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js.map
|
||||
@ -1121,6 +1130,9 @@ packages/lib/services/plugins/Plugin.js.map
|
||||
packages/lib/services/plugins/PluginService.d.ts
|
||||
packages/lib/services/plugins/PluginService.js
|
||||
packages/lib/services/plugins/PluginService.js.map
|
||||
packages/lib/services/plugins/RepositoryApi.d.ts
|
||||
packages/lib/services/plugins/RepositoryApi.js
|
||||
packages/lib/services/plugins/RepositoryApi.js.map
|
||||
packages/lib/services/plugins/ToolbarButtonController.d.ts
|
||||
packages/lib/services/plugins/ToolbarButtonController.js
|
||||
packages/lib/services/plugins/ToolbarButtonController.js.map
|
||||
|
24
.gitignore
vendored
24
.gitignore
vendored
@ -315,9 +315,18 @@ packages/app-desktop/gui/ConfigScreen/ConfigScreen.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/SideBar.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/SideBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/SideBar.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/controls/PluginsStates.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/PluginsStates.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/PluginsStates.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js.map
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.d.ts
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/useOnInstallHandler.js.map
|
||||
packages/app-desktop/gui/DropboxLoginScreen.d.ts
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js.map
|
||||
@ -645,9 +654,6 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js.map
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.d.ts
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js.map
|
||||
packages/app-desktop/gui/SearchBar/styles/index.d.ts
|
||||
packages/app-desktop/gui/SearchBar/styles/index.js
|
||||
packages/app-desktop/gui/SearchBar/styles/index.js.map
|
||||
packages/app-desktop/gui/ShareNoteDialog.d.ts
|
||||
packages/app-desktop/gui/ShareNoteDialog.js
|
||||
packages/app-desktop/gui/ShareNoteDialog.js.map
|
||||
@ -696,6 +702,9 @@ packages/app-desktop/gui/hooks/usePrevious.js.map
|
||||
packages/app-desktop/gui/hooks/usePropsDebugger.d.ts
|
||||
packages/app-desktop/gui/hooks/usePropsDebugger.js
|
||||
packages/app-desktop/gui/hooks/usePropsDebugger.js.map
|
||||
packages/app-desktop/gui/lib/SearchInput/SearchInput.d.ts
|
||||
packages/app-desktop/gui/lib/SearchInput/SearchInput.js
|
||||
packages/app-desktop/gui/lib/SearchInput/SearchInput.js.map
|
||||
packages/app-desktop/gui/lib/ToggleButton/ToggleButton.d.ts
|
||||
packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js
|
||||
packages/app-desktop/gui/lib/ToggleButton/ToggleButton.js.map
|
||||
@ -1110,6 +1119,9 @@ packages/lib/services/plugins/Plugin.js.map
|
||||
packages/lib/services/plugins/PluginService.d.ts
|
||||
packages/lib/services/plugins/PluginService.js
|
||||
packages/lib/services/plugins/PluginService.js.map
|
||||
packages/lib/services/plugins/RepositoryApi.d.ts
|
||||
packages/lib/services/plugins/RepositoryApi.js
|
||||
packages/lib/services/plugins/RepositoryApi.js.map
|
||||
packages/lib/services/plugins/ToolbarButtonController.d.ts
|
||||
packages/lib/services/plugins/ToolbarButtonController.js
|
||||
packages/lib/services/plugins/ToolbarButtonController.js.map
|
||||
|
@ -5,7 +5,7 @@ import Button, { ButtonLevel } from '../Button/Button';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../services/bridge';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import control_PluginsStates from './controls/PluginsStates';
|
||||
import control_PluginsStates from './controls/plugins/PluginsStates';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
@ -159,10 +159,14 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
const settingComps = createSettingComponents(false);
|
||||
const advancedSettingComps = createSettingComponents(true);
|
||||
|
||||
const sectionWidths: Record<string, number> = {
|
||||
plugins: 900,
|
||||
};
|
||||
|
||||
const sectionStyle: any = {
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
maxWidth: 640,
|
||||
maxWidth: sectionWidths[section.name] ? sectionWidths[section.name] : 640,
|
||||
};
|
||||
|
||||
if (!selected) sectionStyle.display = 'none';
|
||||
@ -277,7 +281,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize * 1.083333,
|
||||
fontWeight: 500,
|
||||
marginBottom: theme.mainPadding / 4,
|
||||
marginBottom: theme.mainPadding / 2,
|
||||
});
|
||||
}
|
||||
|
||||
@ -310,7 +314,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
const output: any = null;
|
||||
|
||||
const rowStyle = {
|
||||
marginBottom: theme.mainPadding,
|
||||
marginBottom: theme.mainPadding * 1.5,
|
||||
};
|
||||
|
||||
const labelStyle = this.labelStyle(this.props.themeId);
|
||||
@ -366,9 +370,10 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
|
||||
if (settingKeyToControl[key]) {
|
||||
const SettingComponent = settingKeyToControl[key];
|
||||
const label = md.label ? this.renderLabel(this.props.themeId, md.label()) : null;
|
||||
return (
|
||||
<div key={key} style={rowStyle}>
|
||||
{this.renderLabel(this.props.themeId, md.label())}
|
||||
{label}
|
||||
{this.renderDescription(this.props.themeId, md.description ? md.description() : null)}
|
||||
<SettingComponent
|
||||
metadata={md}
|
||||
@ -377,6 +382,8 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
onChange={(event: any) => {
|
||||
updateSettingValue(key, event.value);
|
||||
}}
|
||||
renderLabel={this.renderLabel}
|
||||
renderDescription={this.renderDescription}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,255 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import PluginService, { defaultPluginSetting, Plugins, PluginSetting, PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import styled from 'styled-components';
|
||||
import ToggleButton from '../../lib/ToggleButton/ToggleButton';
|
||||
import Button, { ButtonLevel } from '../../Button/Button';
|
||||
import bridge from '../../../services/bridge';
|
||||
import produce from 'immer';
|
||||
|
||||
const Root = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const TableRoot = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const InstallButton = styled(Button)`
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const CellRoot = styled.div`
|
||||
display: flex;
|
||||
background-color: ${props => props.theme.backgroundColor};
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 15px;
|
||||
border: 1px solid ${props => props.theme.dividerColor};
|
||||
border-radius: 6px;
|
||||
width: 250px;
|
||||
margin-right: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 1px 1px 3px rgba(0,0,0,0.2);
|
||||
`;
|
||||
|
||||
const CellTop = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const CellContent = styled.div`
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const CellFooter = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const DevModeLabel = styled.div`
|
||||
border: 1px solid ${props => props.theme.color};
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-size: ${props => props.theme.fontSize * 0.75}px;
|
||||
color: ${props => props.theme.color};
|
||||
`;
|
||||
|
||||
const StyledName = styled.div`
|
||||
font-family: ${props => props.theme.fontFamily};
|
||||
color: ${props => props.theme.color};
|
||||
font-size: ${props => props.theme.fontSize}px;
|
||||
font-weight: bold;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StyledDescription = styled.div`
|
||||
font-family: ${props => props.theme.fontFamily};
|
||||
color: ${props => props.theme.colorFaded};
|
||||
font-size: ${props => props.theme.fontSize}px;
|
||||
line-height: 1.6em;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
value: any;
|
||||
themeId: number;
|
||||
onChange: Function;
|
||||
}
|
||||
|
||||
interface CellProps {
|
||||
item: PluginItem;
|
||||
themeId: number;
|
||||
onToggle: Function;
|
||||
onDelete: Function;
|
||||
}
|
||||
|
||||
interface PluginItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
deleted: boolean;
|
||||
devMode: boolean;
|
||||
}
|
||||
|
||||
function Cell(props: CellProps) {
|
||||
const { item } = props;
|
||||
|
||||
// For plugins in dev mode things like enabling/disabling or
|
||||
// uninstalling them doesn't make sense, as that should be done by
|
||||
// adding/removing them from wherever they were loaded from.
|
||||
|
||||
function renderToggleButton() {
|
||||
if (item.devMode) {
|
||||
return <DevModeLabel>DEV</DevModeLabel>;
|
||||
}
|
||||
|
||||
return <ToggleButton
|
||||
themeId={props.themeId}
|
||||
value={item.enabled}
|
||||
onToggle={() => props.onToggle({ item: props.item })}
|
||||
/>;
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
if (item.devMode) return null;
|
||||
|
||||
return (
|
||||
<CellFooter>
|
||||
<Button level={ButtonLevel.Secondary} onClick={() => props.onDelete({ item: props.item })} title={_('Delete')}/>
|
||||
<div style={{ display: 'flex', flex: 1 }}/>
|
||||
</CellFooter>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CellRoot>
|
||||
<CellTop>
|
||||
<StyledName mb={'5px'}>{item.name} {item.deleted ? '(Deleted)' : ''}</StyledName>
|
||||
{renderToggleButton()}
|
||||
</CellTop>
|
||||
<CellContent>
|
||||
<StyledDescription>{item.description}</StyledDescription>
|
||||
</CellContent>
|
||||
{renderFooter()}
|
||||
</CellRoot>
|
||||
);
|
||||
}
|
||||
|
||||
function usePluginItems(plugins: Plugins, settings: PluginSettings): PluginItem[] {
|
||||
return useMemo(() => {
|
||||
const output: PluginItem[] = [];
|
||||
|
||||
for (const pluginId in plugins) {
|
||||
const plugin = plugins[pluginId];
|
||||
|
||||
const setting: PluginSetting = {
|
||||
...defaultPluginSetting(),
|
||||
...settings[pluginId],
|
||||
};
|
||||
|
||||
output.push({
|
||||
id: pluginId,
|
||||
name: plugin.manifest.name,
|
||||
description: plugin.manifest.description,
|
||||
enabled: setting.enabled,
|
||||
deleted: setting.deleted,
|
||||
devMode: plugin.devMode,
|
||||
});
|
||||
}
|
||||
|
||||
output.sort((a: PluginItem, b: PluginItem) => {
|
||||
return a.name < b.name ? -1 : +1;
|
||||
});
|
||||
|
||||
return output;
|
||||
}, [plugins, settings]);
|
||||
}
|
||||
|
||||
export default function(props: Props) {
|
||||
const pluginService = PluginService.instance();
|
||||
|
||||
const pluginSettings = useMemo(() => {
|
||||
return pluginService.unserializePluginSettings(props.value);
|
||||
}, [props.value]);
|
||||
|
||||
const onDelete = useCallback(async (event: any) => {
|
||||
const item: PluginItem = event.item;
|
||||
const confirm = await bridge().showConfirmMessageBox(_('Delete plugin "%s"?', item.name));
|
||||
if (!confirm) return;
|
||||
|
||||
const newSettings = produce(pluginSettings, (draft: PluginSettings) => {
|
||||
if (!draft[item.id]) draft[item.id] = defaultPluginSetting();
|
||||
draft[item.id].deleted = true;
|
||||
});
|
||||
|
||||
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
|
||||
}, [pluginSettings, props.onChange]);
|
||||
|
||||
const onToggle = useCallback((event: any) => {
|
||||
const item: PluginItem = event.item;
|
||||
|
||||
const newSettings = produce(pluginSettings, (draft: PluginSettings) => {
|
||||
if (!draft[item.id]) draft[item.id] = defaultPluginSetting();
|
||||
draft[item.id].enabled = !draft[item.id].enabled;
|
||||
});
|
||||
|
||||
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
|
||||
}, [pluginSettings, props.onChange]);
|
||||
|
||||
const onInstall = useCallback(async () => {
|
||||
const result = bridge().showOpenDialog({
|
||||
filters: [{ name: 'Joplin Plugin Archive', extensions: ['jpl'] }],
|
||||
});
|
||||
|
||||
const filePath = result && result.length ? result[0] : null;
|
||||
if (!filePath) return;
|
||||
|
||||
const plugin = await pluginService.installPlugin(filePath);
|
||||
|
||||
const newSettings = produce(pluginSettings, (draft: PluginSettings) => {
|
||||
draft[plugin.manifest.id] = defaultPluginSetting();
|
||||
});
|
||||
|
||||
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
|
||||
}, [pluginSettings, props.onChange]);
|
||||
|
||||
function renderCells(items: PluginItem[]) {
|
||||
const output = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.deleted) continue;
|
||||
|
||||
output.push(<Cell
|
||||
key={item.id}
|
||||
item={item}
|
||||
themeId={props.themeId}
|
||||
onDelete={onDelete}
|
||||
onToggle={onToggle}
|
||||
/>);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const pluginItems = usePluginItems(pluginService.plugins, pluginSettings);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<InstallButton level={ButtonLevel.Primary} onClick={onInstall} title={_('Install plugin')}/>
|
||||
<div style={{ display: 'flex', flex: 1 }}/>
|
||||
</div>
|
||||
<TableRoot>
|
||||
{renderCells(pluginItems)}
|
||||
</TableRoot>
|
||||
</Root>
|
||||
);
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import styled from 'styled-components';
|
||||
import ToggleButton from '../../../lib/ToggleButton/ToggleButton';
|
||||
import Button, { ButtonLevel } from '../../../Button/Button';
|
||||
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||
|
||||
export enum InstallState {
|
||||
NotInstalled = 1,
|
||||
Installing = 2,
|
||||
Installed = 3,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
item?: PluginItem;
|
||||
manifest?: PluginManifest;
|
||||
installState?: InstallState;
|
||||
themeId: number;
|
||||
onToggle?: Function;
|
||||
onDelete?: Function;
|
||||
onInstall?: Function;
|
||||
}
|
||||
|
||||
function manifestToItem(manifest: PluginManifest): PluginItem {
|
||||
return {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
enabled: true,
|
||||
deleted: false,
|
||||
devMode: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
deleted: boolean;
|
||||
devMode: boolean;
|
||||
}
|
||||
|
||||
const CellRoot = styled.div`
|
||||
display: flex;
|
||||
background-color: ${props => props.theme.backgroundColor};
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 15px;
|
||||
border: 1px solid ${props => props.theme.dividerColor};
|
||||
border-radius: 6px;
|
||||
width: 250px;
|
||||
margin-right: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 1px 1px 3px rgba(0,0,0,0.2);
|
||||
`;
|
||||
|
||||
const CellTop = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const CellContent = styled.div`
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const CellFooter = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const DevModeLabel = styled.div`
|
||||
border: 1px solid ${props => props.theme.color};
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-size: ${props => props.theme.fontSize * 0.75}px;
|
||||
color: ${props => props.theme.color};
|
||||
`;
|
||||
|
||||
const StyledName = styled.div`
|
||||
font-family: ${props => props.theme.fontFamily};
|
||||
color: ${props => props.theme.color};
|
||||
font-size: ${props => props.theme.fontSize}px;
|
||||
font-weight: bold;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StyledDescription = styled.div`
|
||||
font-family: ${props => props.theme.fontFamily};
|
||||
color: ${props => props.theme.colorFaded};
|
||||
font-size: ${props => props.theme.fontSize}px;
|
||||
line-height: 1.6em;
|
||||
`;
|
||||
|
||||
export default function(props: Props) {
|
||||
const item = props.item ? props.item : manifestToItem(props.manifest);
|
||||
|
||||
// For plugins in dev mode things like enabling/disabling or
|
||||
// uninstalling them doesn't make sense, as that should be done by
|
||||
// adding/removing them from wherever they were loaded from.
|
||||
|
||||
function renderToggleButton() {
|
||||
if (!props.onToggle) return null;
|
||||
|
||||
if (item.devMode) {
|
||||
return <DevModeLabel>DEV</DevModeLabel>;
|
||||
}
|
||||
|
||||
return <ToggleButton
|
||||
themeId={props.themeId}
|
||||
value={item.enabled}
|
||||
onToggle={() => props.onToggle({ item })}
|
||||
/>;
|
||||
}
|
||||
|
||||
function renderDeleteButton() {
|
||||
if (!props.onDelete) return null;
|
||||
return <Button level={ButtonLevel.Secondary} onClick={() => props.onDelete({ item })} title={_('Delete')}/>;
|
||||
}
|
||||
|
||||
function renderInstallButton() {
|
||||
if (!props.onInstall) return null;
|
||||
|
||||
let title = _('Install');
|
||||
if (props.installState === InstallState.Installing) title = _('Installing...');
|
||||
if (props.installState === InstallState.Installed) title = _('Installed');
|
||||
|
||||
return <Button
|
||||
level={ButtonLevel.Secondary}
|
||||
disabled={props.installState !== InstallState.NotInstalled}
|
||||
onClick={() => props.onInstall({ item })}
|
||||
title={title}
|
||||
/>;
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
if (item.devMode) return null;
|
||||
|
||||
return (
|
||||
<CellFooter>
|
||||
{renderDeleteButton()}
|
||||
{renderInstallButton()}
|
||||
<div style={{ display: 'flex', flex: 1 }}/>
|
||||
</CellFooter>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CellRoot>
|
||||
<CellTop>
|
||||
<StyledName mb={'5px'}>{item.name} {item.deleted ? '(Deleted)' : ''}</StyledName>
|
||||
{renderToggleButton()}
|
||||
</CellTop>
|
||||
<CellContent>
|
||||
<StyledDescription>{item.description}</StyledDescription>
|
||||
</CellContent>
|
||||
{renderFooter()}
|
||||
</CellRoot>
|
||||
);
|
||||
}
|
@ -0,0 +1,197 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import PluginService, { defaultPluginSetting, Plugins, PluginSetting, PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import styled from 'styled-components';
|
||||
import SearchPlugins from './SearchPlugins';
|
||||
import PluginBox from './PluginBox';
|
||||
// import Button, { ButtonLevel } from '../../../Button/Button';
|
||||
import bridge from '../../../../services/bridge';
|
||||
import produce from 'immer';
|
||||
import { OnChangeEvent } from '../../../lib/SearchInput/SearchInput';
|
||||
import { PluginItem } from './PluginBox';
|
||||
const { space } = require('styled-system');
|
||||
|
||||
const Root = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const UserPluginsRoot = styled.div`
|
||||
${space}
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
// const InstallButton = styled(Button)``;
|
||||
|
||||
interface Props {
|
||||
value: any;
|
||||
themeId: number;
|
||||
onChange: Function;
|
||||
renderLabel: Function;
|
||||
renderDescription: Function;
|
||||
}
|
||||
|
||||
function usePluginItems(plugins: Plugins, settings: PluginSettings): PluginItem[] {
|
||||
return useMemo(() => {
|
||||
const output: PluginItem[] = [];
|
||||
|
||||
for (const pluginId in plugins) {
|
||||
const plugin = plugins[pluginId];
|
||||
|
||||
const setting: PluginSetting = {
|
||||
...defaultPluginSetting(),
|
||||
...settings[pluginId],
|
||||
};
|
||||
|
||||
output.push({
|
||||
id: pluginId,
|
||||
name: plugin.manifest.name,
|
||||
description: plugin.manifest.description,
|
||||
enabled: setting.enabled,
|
||||
deleted: setting.deleted,
|
||||
devMode: plugin.devMode,
|
||||
});
|
||||
}
|
||||
|
||||
output.sort((a: PluginItem, b: PluginItem) => {
|
||||
return a.name < b.name ? -1 : +1;
|
||||
});
|
||||
|
||||
return output;
|
||||
}, [plugins, settings]);
|
||||
}
|
||||
|
||||
export default function(props: Props) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const pluginService = PluginService.instance();
|
||||
|
||||
const pluginSettings = useMemo(() => {
|
||||
return pluginService.unserializePluginSettings(props.value);
|
||||
}, [props.value]);
|
||||
|
||||
const onDelete = useCallback(async (event: any) => {
|
||||
const item: PluginItem = event.item;
|
||||
const confirm = await bridge().showConfirmMessageBox(_('Delete plugin "%s"?', item.name));
|
||||
if (!confirm) return;
|
||||
|
||||
const newSettings = produce(pluginSettings, (draft: PluginSettings) => {
|
||||
if (!draft[item.id]) draft[item.id] = defaultPluginSetting();
|
||||
draft[item.id].deleted = true;
|
||||
});
|
||||
|
||||
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
|
||||
}, [pluginSettings, props.onChange]);
|
||||
|
||||
const onToggle = useCallback((event: any) => {
|
||||
const item: PluginItem = event.item;
|
||||
|
||||
const newSettings = produce(pluginSettings, (draft: PluginSettings) => {
|
||||
if (!draft[item.id]) draft[item.id] = defaultPluginSetting();
|
||||
draft[item.id].enabled = !draft[item.id].enabled;
|
||||
});
|
||||
|
||||
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
|
||||
}, [pluginSettings, props.onChange]);
|
||||
|
||||
// const onInstall = useCallback(async () => {
|
||||
// const result = bridge().showOpenDialog({
|
||||
// filters: [{ name: 'Joplin Plugin Archive', extensions: ['jpl'] }],
|
||||
// });
|
||||
|
||||
// const filePath = result && result.length ? result[0] : null;
|
||||
// if (!filePath) return;
|
||||
|
||||
// const plugin = await pluginService.installPlugin(filePath);
|
||||
|
||||
// const newSettings = produce(pluginSettings, (draft: PluginSettings) => {
|
||||
// draft[plugin.manifest.id] = defaultPluginSetting();
|
||||
// });
|
||||
|
||||
// props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
|
||||
// }, [pluginSettings, props.onChange]);
|
||||
|
||||
const onSearchQueryChange = useCallback((event: OnChangeEvent) => {
|
||||
setSearchQuery(event.value);
|
||||
}, []);
|
||||
|
||||
const onSearchPluginSettingsChange = useCallback((event: any) => {
|
||||
props.onChange({ value: pluginService.serializePluginSettings(event.value) });
|
||||
}, [props.onChange]);
|
||||
|
||||
function renderCells(items: PluginItem[]) {
|
||||
const output = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.deleted) continue;
|
||||
|
||||
output.push(<PluginBox
|
||||
key={item.id}
|
||||
item={item}
|
||||
themeId={props.themeId}
|
||||
onDelete={onDelete}
|
||||
onToggle={onToggle}
|
||||
/>);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderUserPlugins(pluginItems: PluginItem[]) {
|
||||
const allDeleted = !pluginItems.find(it => it.deleted !== true);
|
||||
|
||||
if (!pluginItems.length || allDeleted) {
|
||||
return (
|
||||
<UserPluginsRoot mb={'10px'}>
|
||||
{props.renderDescription(props.themeId, _('You do not have any installed plugin.'))}
|
||||
</UserPluginsRoot>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<UserPluginsRoot>
|
||||
{renderCells(pluginItems)}
|
||||
</UserPluginsRoot>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderInstallFromFile(): any {
|
||||
return null;
|
||||
|
||||
// Disabled for now since there are already options for developments,
|
||||
// and installing from file can be done by dropping the file in /plugins
|
||||
|
||||
// return (
|
||||
// <div>
|
||||
// {props.renderLabel(props.themeId, _('Install plugin from file'))}
|
||||
// <InstallButton level={ButtonLevel.Primary} onClick={onInstall} title={_('Install plugin')}/>
|
||||
// <div style={{ display: 'flex', flex: 1 }}/>
|
||||
// </div>
|
||||
// );
|
||||
}
|
||||
|
||||
const pluginItems = usePluginItems(pluginService.plugins, pluginSettings);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{props.renderLabel(props.themeId, _('Search for plugins'))}
|
||||
<SearchPlugins
|
||||
themeId={props.themeId}
|
||||
searchQuery={searchQuery}
|
||||
pluginSettings={pluginSettings}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
onPluginSettingsChange={onSearchPluginSettingsChange}
|
||||
renderDescription={props.renderDescription}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{props.renderLabel(props.themeId, _('Manage your plugins'))}
|
||||
{renderUserPlugins(pluginItems)}
|
||||
|
||||
{renderInstallFromFile()}
|
||||
</Root>
|
||||
);
|
||||
}
|
@ -0,0 +1,117 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import SearchInput, { OnChangeEvent } from '../../../lib/SearchInput/SearchInput';
|
||||
import styled from 'styled-components';
|
||||
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||
import PluginBox, { InstallState } from './PluginBox';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useOnInstallHandler from './useOnInstallHandler';
|
||||
|
||||
const Root = styled.div`
|
||||
`;
|
||||
|
||||
const ResultsRoot = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange(event: OnChangeEvent): void;
|
||||
pluginSettings: PluginSettings;
|
||||
onPluginSettingsChange(event: any): void;
|
||||
renderDescription: Function;
|
||||
}
|
||||
|
||||
let repoApi_: RepositoryApi = null;
|
||||
|
||||
function repoApi(): RepositoryApi {
|
||||
if (repoApi_) return repoApi_;
|
||||
repoApi_ = new RepositoryApi('https://github.com/joplin/plugins', Setting.value('tempDir'));
|
||||
return repoApi_;
|
||||
}
|
||||
|
||||
export default function(props: Props) {
|
||||
const [searchStarted, setSearchStarted] = useState(false);
|
||||
const [manifests, setManifests] = useState<PluginManifest[]>([]);
|
||||
const asyncSearchQueue = useRef(new AsyncActionQueue(200));
|
||||
const [installingPluginsIds, setInstallingPluginIds] = useState<Record<string, boolean>>({});
|
||||
const [searchResultCount, setSearchResultCount] = useState(null);
|
||||
|
||||
const onInstall = useOnInstallHandler(setInstallingPluginIds, props.pluginSettings, repoApi, props.onPluginSettingsChange);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchResultCount(null);
|
||||
asyncSearchQueue.current.push(async () => {
|
||||
if (!props.searchQuery) {
|
||||
setManifests([]);
|
||||
setSearchResultCount(null);
|
||||
} else {
|
||||
const r = await repoApi().search(props.searchQuery);
|
||||
setManifests(r);
|
||||
setSearchResultCount(r.length);
|
||||
}
|
||||
});
|
||||
}, [props.searchQuery]);
|
||||
|
||||
const onChange = useCallback((event: OnChangeEvent) => {
|
||||
setSearchStarted(true);
|
||||
props.onSearchQueryChange(event);
|
||||
}, [props.onSearchQueryChange]);
|
||||
|
||||
const onSearchButtonClick = useCallback(() => {
|
||||
setSearchStarted(false);
|
||||
props.onSearchQueryChange({ value: '' });
|
||||
}, []);
|
||||
|
||||
function installState(pluginId: string): InstallState {
|
||||
const settings = props.pluginSettings[pluginId];
|
||||
if (settings && !settings.deleted) return InstallState.Installed;
|
||||
if (installingPluginsIds[pluginId]) return InstallState.Installing;
|
||||
return InstallState.NotInstalled;
|
||||
}
|
||||
|
||||
function renderResults(query: string, manifests: PluginManifest[]) {
|
||||
if (query && !manifests.length) {
|
||||
if (searchResultCount === null) return ''; // Search in progress
|
||||
return props.renderDescription(props.themeId, _('No results'));
|
||||
} else {
|
||||
const output = [];
|
||||
|
||||
for (const manifest of manifests) {
|
||||
output.push(<PluginBox
|
||||
key={manifest.id}
|
||||
manifest={manifest}
|
||||
themeId={props.themeId}
|
||||
onInstall={onInstall}
|
||||
installState={installState(manifest.id)}
|
||||
/>);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ marginBottom: 10, width: 250 }}>
|
||||
<SearchInput
|
||||
inputRef={null}
|
||||
value={props.searchQuery}
|
||||
onChange={onChange}
|
||||
onSearchButtonClick={onSearchButtonClick}
|
||||
searchStarted={searchStarted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ResultsRoot>
|
||||
{renderResults(props.searchQuery, manifests)}
|
||||
</ResultsRoot>
|
||||
</Root>
|
||||
);
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
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/lib/Logger';
|
||||
|
||||
const logger = Logger.create('useOnInstallHandler');
|
||||
|
||||
export default function(setInstallingPluginIds: Function, pluginSettings: PluginSettings, repoApi: Function, onPluginSettingsChange: Function) {
|
||||
return useCallback(async (event: any) => {
|
||||
const pluginId = event.item.id;
|
||||
|
||||
setInstallingPluginIds((prev: any) => {
|
||||
return {
|
||||
...prev, [pluginId]: true,
|
||||
};
|
||||
});
|
||||
|
||||
let installError = null;
|
||||
|
||||
try {
|
||||
await PluginService.instance().installPluginFromRepo(repoApi(), pluginId);
|
||||
} catch (error) {
|
||||
installError = error;
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
if (!installError) {
|
||||
const newSettings = produce(pluginSettings, (draft: PluginSettings) => {
|
||||
draft[pluginId] = defaultPluginSetting();
|
||||
});
|
||||
|
||||
onPluginSettingsChange({ value: newSettings });
|
||||
}
|
||||
|
||||
setInstallingPluginIds((prev: any) => {
|
||||
return {
|
||||
...prev, [pluginId]: false,
|
||||
};
|
||||
});
|
||||
|
||||
if (installError) alert(_('Could not install plugin: %s', installError.message));
|
||||
}, [pluginSettings, onPluginSettingsChange]);
|
||||
}
|
@ -1,16 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { Root, SearchInput, SearchButton, SearchButtonIcon } from './styles';
|
||||
import SearchInput from '../lib/SearchInput/SearchInput';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
const { connect } = require('react-redux');
|
||||
const Note = require('@joplin/lib/models/Note');
|
||||
const debounce = require('debounce');
|
||||
const styled = require('styled-components').default;
|
||||
|
||||
export const Root = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
inputRef?: any;
|
||||
@ -22,7 +26,6 @@ interface Props {
|
||||
function SearchBar(props: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [searchStarted, setSearchStarted] = useState(false);
|
||||
const iconName = !searchStarted ? CommandService.instance().iconName('search') : 'fa fa-times';
|
||||
const searchId = useRef(uuid.create());
|
||||
|
||||
useEffect(() => {
|
||||
@ -78,7 +81,7 @@ function SearchBar(props: Props) {
|
||||
|
||||
function onChange(event: any) {
|
||||
setSearchStarted(true);
|
||||
setQuery(event.currentTarget.value);
|
||||
setQuery(event.value);
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
@ -120,19 +123,15 @@ function SearchBar(props: Props) {
|
||||
return (
|
||||
<Root>
|
||||
<SearchInput
|
||||
ref={props.inputRef}
|
||||
inputRef={props.inputRef}
|
||||
value={query}
|
||||
type="text"
|
||||
placeholder={_('Search...')}
|
||||
onChange={onChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
spellCheck={false}
|
||||
onSearchButtonClick={onSearchButtonClick}
|
||||
searchStarted={searchStarted}
|
||||
/>
|
||||
<SearchButton onClick={onSearchButtonClick}>
|
||||
<SearchButtonIcon className={iconName}/>
|
||||
</SearchButton>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
@ -1,28 +0,0 @@
|
||||
import StyledInput from '../../style/StyledInput';
|
||||
const styled = require('styled-components').default;
|
||||
|
||||
export const Root = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SearchButton = styled.button`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
height: 100%;
|
||||
opacity: ${(props: any) => props.disabled ? 0.5 : 1};
|
||||
`;
|
||||
|
||||
export const SearchButtonIcon = styled.span`
|
||||
font-size: ${(props: any) => props.theme.toolbarIconSize}px;
|
||||
color: ${(props: any) => props.theme.color4};
|
||||
`;
|
||||
|
||||
export const SearchInput = styled(StyledInput)`
|
||||
padding-right: 20px;
|
||||
flex: 1;
|
||||
width: 10px;
|
||||
`;
|
75
packages/app-desktop/gui/lib/SearchInput/SearchInput.tsx
Normal file
75
packages/app-desktop/gui/lib/SearchInput/SearchInput.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
import StyledInput from '../../style/StyledInput';
|
||||
const styled = require('styled-components').default;
|
||||
|
||||
export const Root = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SearchButton = styled.button`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
height: 100%;
|
||||
opacity: ${(props: any) => props.disabled ? 0.5 : 1};
|
||||
`;
|
||||
|
||||
export const SearchButtonIcon = styled.span`
|
||||
font-size: ${(props: any) => props.theme.toolbarIconSize}px;
|
||||
color: ${(props: any) => props.theme.color4};
|
||||
`;
|
||||
|
||||
export const SearchInput = styled(StyledInput)`
|
||||
padding-right: 20px;
|
||||
flex: 1;
|
||||
width: 10px;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
inputRef?: any;
|
||||
value: string;
|
||||
onChange(event: OnChangeEvent): void;
|
||||
onFocus?: Function;
|
||||
onBlur?: Function;
|
||||
onKeyDown?: Function;
|
||||
onSearchButtonClick: Function;
|
||||
searchStarted: boolean;
|
||||
}
|
||||
|
||||
export interface OnChangeEvent {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default function(props: Props) {
|
||||
const iconName = !props.searchStarted ? CommandService.instance().iconName('search') : 'fa fa-times';
|
||||
|
||||
const onChange = useCallback((event: any) => {
|
||||
props.onChange({ value: event.currentTarget.value });
|
||||
}, [props.onChange]);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<SearchInput
|
||||
ref={props.inputRef}
|
||||
value={props.value}
|
||||
type="text"
|
||||
placeholder={_('Search...')}
|
||||
onChange={onChange}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
onKeyDown={props.onKeyDown}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<SearchButton onClick={props.onSearchButtonClick}>
|
||||
<SearchButtonIcon className={iconName}/>
|
||||
</SearchButton>
|
||||
</Root>
|
||||
);
|
||||
}
|
@ -20,19 +20,18 @@ export enum IntervalType {
|
||||
// Each queue should be associated with a specific entity (a note, resource, etc.)
|
||||
export default class AsyncActionQueue {
|
||||
|
||||
queue_: QueueItem[] = [];
|
||||
interval_: number;
|
||||
intervalType_: number;
|
||||
scheduleProcessingIID_: any = null;
|
||||
processing_ = false;
|
||||
needProcessing_ = false;
|
||||
private queue_: QueueItem[] = [];
|
||||
private interval_: number;
|
||||
private intervalType_: number;
|
||||
private scheduleProcessingIID_: any = null;
|
||||
private processing_ = false;
|
||||
|
||||
constructor(interval: number = 100, intervalType: IntervalType = IntervalType.Debounce) {
|
||||
public constructor(interval: number = 100, intervalType: IntervalType = IntervalType.Debounce) {
|
||||
this.interval_ = interval;
|
||||
this.intervalType_ = intervalType;
|
||||
}
|
||||
|
||||
push(action: QueueItemAction, context: any = null) {
|
||||
public push(action: QueueItemAction, context: any = null) {
|
||||
this.queue_.push({
|
||||
action: action,
|
||||
context: context,
|
||||
@ -40,8 +39,8 @@ export default class AsyncActionQueue {
|
||||
this.scheduleProcessing();
|
||||
}
|
||||
|
||||
get queue(): QueueItem[] {
|
||||
return this.queue_;
|
||||
public get isEmpty(): boolean {
|
||||
return !this.queue_.length;
|
||||
}
|
||||
|
||||
private scheduleProcessing(interval: number = null) {
|
||||
@ -77,7 +76,7 @@ export default class AsyncActionQueue {
|
||||
this.processing_ = false;
|
||||
}
|
||||
|
||||
async reset() {
|
||||
public async reset() {
|
||||
if (this.scheduleProcessingIID_) {
|
||||
shim.clearTimeout(this.scheduleProcessingIID_);
|
||||
this.scheduleProcessingIID_ = null;
|
||||
@ -89,11 +88,11 @@ export default class AsyncActionQueue {
|
||||
|
||||
// Currently waitForAllDone() already finishes all the actions
|
||||
// as quickly as possible so we can make it an alias.
|
||||
async processAllNow() {
|
||||
public async processAllNow() {
|
||||
return this.waitForAllDone();
|
||||
}
|
||||
|
||||
async waitForAllDone() {
|
||||
public async waitForAllDone() {
|
||||
if (!this.queue_.length) return Promise.resolve();
|
||||
|
||||
this.scheduleProcessing(1);
|
||||
|
@ -619,7 +619,6 @@ class Setting extends BaseModel {
|
||||
section: 'plugins',
|
||||
public: true,
|
||||
appTypes: ['desktop'],
|
||||
label: () => _('Plugins'),
|
||||
needRestart: true,
|
||||
autoSave: true,
|
||||
},
|
||||
@ -629,6 +628,7 @@ class Setting extends BaseModel {
|
||||
type: SettingItemType.String,
|
||||
section: 'plugins',
|
||||
public: true,
|
||||
advanced: true,
|
||||
appTypes: ['desktop'],
|
||||
label: () => 'Development plugins',
|
||||
description: () => 'You may add multiple plugin paths, each separated by a comma. You will need to restart the application for the changes to take effect.',
|
||||
@ -1504,7 +1504,7 @@ class Setting extends BaseModel {
|
||||
if (name === 'appearance') return _('Appearance');
|
||||
if (name === 'note') return _('Note');
|
||||
if (name === 'markdownPlugins') return _('Markdown');
|
||||
if (name === 'plugins') return `${_('Plugins')} (Beta)`;
|
||||
if (name === 'plugins') return _('Plugins');
|
||||
if (name === 'application') return _('Application');
|
||||
if (name === 'revisionService') return _('Note History');
|
||||
if (name === 'encryption') return _('Encryption');
|
||||
|
@ -7,6 +7,7 @@ import shim from '../../shim';
|
||||
import { filename, dirname, rtrimSlashes } from '../../path-utils';
|
||||
import Setting from '../../models/Setting';
|
||||
import Logger from '../../Logger';
|
||||
import RepositoryApi from './RepositoryApi';
|
||||
const compareVersions = require('compare-versions');
|
||||
const uslug = require('uslug');
|
||||
const md5File = require('md5-file/promise');
|
||||
@ -328,6 +329,13 @@ export default class PluginService extends BaseService {
|
||||
return this.runner_.run(plugin, pluginApi);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public async installPlugin(jplPath: string): Promise<Plugin> {
|
||||
logger.info(`Installing plugin: "${jplPath}"`);
|
||||
|
||||
|
86
packages/lib/services/plugins/RepositoryApi.ts
Normal file
86
packages/lib/services/plugins/RepositoryApi.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import shim from '../../shim';
|
||||
import { PluginManifest } from './utils/types';
|
||||
const md5 = require('md5');
|
||||
|
||||
export default class RepositoryApi {
|
||||
|
||||
// For now, it's assumed that the baseUrl is a GitHub repo URL, such as
|
||||
// https://github.com/joplin/plugins
|
||||
//
|
||||
// Later on, other repo types could be supported.
|
||||
private baseUrl_: string;
|
||||
private tempDir_: string;
|
||||
private manifests_: PluginManifest[] = null;
|
||||
|
||||
public constructor(baseUrl: string, tempDir: string) {
|
||||
this.baseUrl_ = baseUrl;
|
||||
this.tempDir_ = tempDir;
|
||||
}
|
||||
|
||||
private get contentBaseUrl(): string {
|
||||
return `${this.baseUrl_.replace(/github\.com/, 'raw.githubusercontent.com')}/master`;
|
||||
}
|
||||
|
||||
private fileUrl(relativePath: string): string {
|
||||
return `${this.contentBaseUrl}/${relativePath}`;
|
||||
}
|
||||
|
||||
private async fetchText(path: string): Promise<string> {
|
||||
return shim.fetchText(this.fileUrl(path));
|
||||
}
|
||||
|
||||
public async search(query: string): Promise<PluginManifest[]> {
|
||||
query = query.toLowerCase().trim();
|
||||
|
||||
const manifests = await this.manifests();
|
||||
const output: PluginManifest[] = [];
|
||||
|
||||
for (const manifest of manifests) {
|
||||
for (const field of ['name', 'description']) {
|
||||
const v = (manifest as any)[field];
|
||||
if (!v) continue;
|
||||
|
||||
if (v.toLowerCase().indexOf(query) >= 0) {
|
||||
output.push(manifest);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Returns a temporary path, where the plugin has been downloaded to. Temp
|
||||
// file should be deleted by caller.
|
||||
public async downloadPlugin(pluginId: string): Promise<string> {
|
||||
const manifests = await this.manifests();
|
||||
const manifest = manifests.find(m => m.id === pluginId);
|
||||
if (!manifest) throw new Error(`No manifest for plugin ID "${pluginId}"`);
|
||||
|
||||
const fileUrl = this.fileUrl(`plugins/${manifest.id}/plugin.jpl`);
|
||||
const hash = md5(Date.now() + Math.random());
|
||||
const targetPath = `${this.tempDir_}/${hash}_${manifest.id}.jpl`;
|
||||
const response = await shim.fetchBlob(fileUrl, {
|
||||
path: targetPath,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Could not download plugin "${pluginId}" from "${fileUrl}"`);
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
private async manifests(): Promise<PluginManifest[]> {
|
||||
if (this.manifests_) return this.manifests_;
|
||||
const manifestsText = await this.fetchText('manifests.json');
|
||||
try {
|
||||
const manifests = JSON.parse(manifestsText);
|
||||
if (!manifests) throw new Error('Invalid or missing JSON');
|
||||
this.manifests_ = Object.keys(manifests).map(id => {
|
||||
return manifests[id];
|
||||
});
|
||||
return this.manifests_;
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -16,4 +16,7 @@ export interface PluginManifest {
|
||||
|
||||
// Private keys
|
||||
_package_hash?: string;
|
||||
_publish_hash?: string;
|
||||
_publish_commit?: string;
|
||||
_npm_package_name?: string;
|
||||
}
|
||||
|
@ -165,6 +165,12 @@ const shim = {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
|
||||
fetchText: async (url: string, options: any = null): Promise<string> => {
|
||||
const r = await shim.fetch(url, options || {});
|
||||
if (!r.ok) throw new Error(`Could not fetch ${url}`);
|
||||
return r.text();
|
||||
},
|
||||
|
||||
createResourceFromPath: async (_filePath: string, _defaultProps: any = null, _options: any = null): Promise<ResourceEntity> => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
@ -209,7 +215,7 @@ const shim = {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
|
||||
fetchBlob: async function(_url: string, _options: any = null) {
|
||||
fetchBlob: function(_url: string, _options: any = null): any {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user