1
0
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:
Laurent Cozic 2021-01-07 16:30:53 +00:00
parent 1700b29f7d
commit 6dc5a816e5
17 changed files with 775 additions and 329 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

@ -16,4 +16,7 @@ export interface PluginManifest {
// Private keys
_package_hash?: string;
_publish_hash?: string;
_publish_commit?: string;
_npm_package_name?: string;
}

View File

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