1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Desktop: Allow updating a plugin

This commit is contained in:
Laurent Cozic 2021-01-19 22:58:09 +00:00
parent 63e30f6ccb
commit f37d37e613
25 changed files with 447 additions and 69 deletions

View File

@ -111,6 +111,9 @@ packages/app-cli/tests/models_Note.js.map
packages/app-cli/tests/models_Setting.d.ts
packages/app-cli/tests/models_Setting.js
packages/app-cli/tests/models_Setting.js.map
packages/app-cli/tests/services/plugins/RepositoryApi.d.ts
packages/app-cli/tests/services/plugins/RepositoryApi.js
packages/app-cli/tests/services/plugins/RepositoryApi.js.map
packages/app-cli/tests/services/plugins/api/JoplinSettings.d.ts
packages/app-cli/tests/services/plugins/api/JoplinSettings.js
packages/app-cli/tests/services/plugins/api/JoplinSettings.js.map

3
.gitignore vendored
View File

@ -99,6 +99,9 @@ packages/app-cli/tests/models_Note.js.map
packages/app-cli/tests/models_Setting.d.ts
packages/app-cli/tests/models_Setting.js
packages/app-cli/tests/models_Setting.js.map
packages/app-cli/tests/services/plugins/RepositoryApi.d.ts
packages/app-cli/tests/services/plugins/RepositoryApi.js
packages/app-cli/tests/services/plugins/RepositoryApi.js.map
packages/app-cli/tests/services/plugins/api/JoplinSettings.d.ts
packages/app-cli/tests/services/plugins/api/JoplinSettings.js
packages/app-cli/tests/services/plugins/api/JoplinSettings.js.map

View File

@ -27,17 +27,22 @@ describe('InMemoryCache', function() {
await time.msleep(510);
expect(cache.value('test')).toBe(undefined);
// Check that the TTL is reset every time setValue is called
cache.setValue('test', 'something', 300);
await time.msleep(100);
cache.setValue('test', 'something', 300);
await time.msleep(100);
cache.setValue('test', 'something', 300);
await time.msleep(100);
cache.setValue('test', 'something', 300);
await time.msleep(100);
// This test can sometimes fail in some cases, probably because it
// sleeps for more than 100ms (when the computer is slow). Changing this
// to use higher values would slow down the test unit too much, so let's
// disable it for now.
expect(cache.value('test')).toBe('something');
// Check that the TTL is reset every time setValue is called
// cache.setValue('test', 'something', 300);
// await time.msleep(100);
// cache.setValue('test', 'something', 300);
// await time.msleep(100);
// cache.setValue('test', 'something', 300);
// await time.msleep(100);
// cache.setValue('test', 'something', 300);
// await time.msleep(100);
// expect(cache.value('test')).toBe('something');
});
it('should delete old records', async () => {

View File

@ -0,0 +1,56 @@
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
import shim from '@joplin/lib/shim';
import { setupDatabaseAndSynchronizer, switchClient, supportDir, createTempDir } from '../../test-utils';
async function newRepoApi(): Promise<RepositoryApi> {
const repo = new RepositoryApi(`${supportDir}/pluginRepo`, await createTempDir());
await repo.loadManifests();
return repo;
}
describe('services_plugins_RepositoryApi', function() {
beforeEach(async (done: Function) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
done();
});
it('should get the manifests', (async () => {
const api = await newRepoApi();
const manifests = await api.manifests();
expect(!!manifests.find(m => m.id === 'joplin.plugin.ambrt.backlinksToNote')).toBe(true);
expect(!!manifests.find(m => m.id === 'org.joplinapp.plugins.ToggleSidebars')).toBe(true);
}));
it('should search', (async () => {
const api = await newRepoApi();
{
const results = await api.search('to');
expect(results.length).toBe(2);
expect(!!results.find(m => m.id === 'joplin.plugin.ambrt.backlinksToNote')).toBe(true);
expect(!!results.find(m => m.id === 'org.joplinapp.plugins.ToggleSidebars')).toBe(true);
}
{
const results = await api.search('backlink');
expect(results.length).toBe(1);
expect(!!results.find(m => m.id === 'joplin.plugin.ambrt.backlinksToNote')).toBe(true);
}
}));
it('should download a plugin', (async () => {
const api = await newRepoApi();
const pluginPath = await api.downloadPlugin('org.joplinapp.plugins.ToggleSidebars');
expect(await shim.fsDriver().exists(pluginPath)).toBe(true);
}));
it('should tell if a plugin can be updated', (async () => {
const api = await newRepoApi();
expect(await api.pluginCanBeUpdated('org.joplinapp.plugins.ToggleSidebars', '1.0.0')).toBe(true);
expect(await api.pluginCanBeUpdated('org.joplinapp.plugins.ToggleSidebars', '1.0.2')).toBe(false);
expect(await api.pluginCanBeUpdated('does.not.exist', '1.0.0')).toBe(false);
}));
});

View File

@ -0,0 +1,25 @@
# Joplin Plugin Repository
This is the official Joplin Plugin Repository
## Installation
To install any of these plugins, open the desktop application, then go to the "Plugins" section in the Configuration screen. You can then search for any plugin and install it from there.
## Plugins
This repository contains the following plugins:
<!-- PLUGIN_LIST -->
&nbsp; | Name | Version | Description | Author
--- | --- | --- | --- | ---
[🏠](https://discourse.joplinapp.org/t/insert-referencing-notes-backlinks-plugin/13632) | Backlinks to note | 1.0.4 | Creates backlinks to opened note | a
[🏠](https://github.com/JackGruber/joplin-plugin-combine-notes) | Combine notes | 0.2.1 | Combine one or more notes | JackGruber
[🏠](https://github.com/JackGruber/joplin-plugin-copytags) | Copy Tags | 0.3.2 | Plugin to extend the Joplin tagging menu with a coppy all tags and tagging list with more control. | JackGruber
[🏠](https://discourse.joplinapp.org/t/go-to-note-tag-or-notebook-via-highlighting-text-in-editor/12731) | Create and go to #tags and @notebooks | 1.3.4 | Go to tag,notebook or note via links or via text | a
[🏠](https://github.com/benji300/joplin-favorites) | Favorites | 1.0.0 | Save any notebook, note, to-do, tag, or search as favorite in an extra panel view for quick access. (v1.0.0) | Benji300
[🏠](https://github.com/laurent22/joplin/tree/dev/packages/plugins/ToggleSidebars) | Note list and side bar toggle buttons | 1.0.2 | Adds buttons to toggle note list and sidebar | Laurent Cozic
[🏠](https://github.com/JackGruber/joplin-plugin-note-overview) | Note overview | 1.0.0 | A note overview is created based on the defined search and the specified fields | JackGruber
[🏠](https://github.com/benji300/joplin-note-tabs) | Note Tabs | 1.1.1 | Allows to open several notes at once in tabs and pin them. (v1.1.1) | Benji300
[🏠](https://github.com/JackGruber/joplin-plugin-backup) | Simple Backup | 0.3.0 | Plugin to create manual and automatic backups | JackGruber
<!-- PLUGIN_LIST -->

View File

@ -0,0 +1,29 @@
{
"joplin.plugin.ambrt.backlinksToNote": {
"manifest_version": 1,
"id": "joplin.plugin.ambrt.backlinksToNote",
"app_min_version": "1.5",
"version": "1.0.4",
"name": "Backlinks to note",
"description": "Creates backlinks to opened note",
"author": "a",
"homepage_url": "https://discourse.joplinapp.org/t/insert-referencing-notes-backlinks-plugin/13632",
"_publish_hash": "sha256:ab9c9bc776e167a3b1a9ec40a4927d93da14514c0508f46dd6e5ea3f8a3a6c3b",
"_publish_commit": "master:5b74f93c687463572c46200292fa911e0ba96033",
"_npm_package_name": "joplin-plugin-backlinks"
},
"org.joplinapp.plugins.ToggleSidebars": {
"manifest_version": 1,
"id": "org.joplinapp.plugins.ToggleSidebars",
"app_min_version": "1.6",
"version": "1.0.2",
"name": "Note list and side bar toggle buttons",
"description": "Adds buttons to toggle note list and sidebar",
"author": "Laurent Cozic",
"homepage_url": "https://github.com/laurent22/joplin/tree/dev/packages/plugins/ToggleSidebars",
"repository_url": "https://github.com/laurent22/joplin/tree/dev/packages/plugins/ToggleSidebars",
"_publish_hash": "sha256:e0d833b7ef1bb8f02ee4cb861ef1989621358c45a5614911071302dc0527a3b4",
"_publish_commit": "dev:1b5b2342fc25717b77ad9f1627c1a334e5bbae54",
"_npm_package_name": "@joplin/joplin-plugin-toggle-sidebars"
}
}

View File

@ -0,0 +1,13 @@
{
"manifest_version": 1,
"id": "joplin.plugin.ambrt.backlinksToNote",
"app_min_version": "1.5",
"version": "1.0.4",
"name": "Backlinks to note",
"description": "Creates backlinks to opened note",
"author": "a",
"homepage_url": "https://discourse.joplinapp.org/t/insert-referencing-notes-backlinks-plugin/13632",
"_publish_hash": "sha256:ab9c9bc776e167a3b1a9ec40a4927d93da14514c0508f46dd6e5ea3f8a3a6c3b",
"_publish_commit": "master:5b74f93c687463572c46200292fa911e0ba96033",
"_npm_package_name": "joplin-plugin-backlinks"
}

View File

@ -0,0 +1,14 @@
{
"manifest_version": 1,
"id": "org.joplinapp.plugins.ToggleSidebars",
"app_min_version": "1.6",
"version": "1.0.2",
"name": "Note list and side bar toggle buttons",
"description": "Adds buttons to toggle note list and sidebar",
"author": "Laurent Cozic",
"homepage_url": "https://github.com/laurent22/joplin/tree/dev/packages/plugins/ToggleSidebars",
"repository_url": "https://github.com/laurent22/joplin/tree/dev/packages/plugins/ToggleSidebars",
"_publish_hash": "sha256:e0d833b7ef1bb8f02ee4cb861ef1989621358c45a5614911071302dc0527a3b4",
"_publish_commit": "dev:1b5b2342fc25717b77ad9f1627c1a334e5bbae54",
"_npm_package_name": "@joplin/joplin-plugin-toggle-sidebars"
}

View File

@ -4,7 +4,7 @@
"app_min_version": "1.4",
"name": "Register Command Test",
"description": "To test registering commands",
"version": "1.0.0",
"version": "1.0.3",
"author": "Laurent Cozic",
"homepage_url": "https://joplinapp.org"
}

View File

@ -0,0 +1,7 @@
#!/bin/bash
# - Update src/manifest.json with the new version number
# - Run the below command
# - Then the file /manifests.json also needs to be updated with the new manifest file
npm run dist && cp publish/org.joplinapp.plugins.RegisterCommandDemo.jpl ~/src/joplin-plugins-test/plugins/org.joplinapp.plugins.RegisterCommandDemo/plugin.jpl && cp publish/org.joplinapp.plugins.RegisterCommandDemo.json ~/src/joplin-plugins-test/plugins/org.joplinapp.plugins.RegisterCommandDemo/plugin.json

View File

@ -104,6 +104,7 @@ FileApiDriverLocal.fsDriver_ = fsDriver;
const logDir = `${__dirname}/../tests/logs`;
const baseTempDir = `${__dirname}/../tests/tmp/${suiteName_}`;
const supportDir = `${__dirname}/support`;
// We add a space in the data directory path as that will help uncover
// various space-in-path issues.
@ -180,6 +181,7 @@ BaseItem.loadClass('Revision', Revision);
Setting.setConstant('appId', 'net.cozic.joplintest-cli');
Setting.setConstant('appType', 'cli');
Setting.setConstant('tempDir', baseTempDir);
Setting.setConstant('cacheDir', baseTempDir);
Setting.setConstant('env', 'dev');
BaseService.logger_ = logger;
@ -864,4 +866,4 @@ class TestApp extends BaseApplication {
}
}
module.exports = { waitForFolderCount, afterAllCleanUp, exportDir, newPluginService, newPluginScript, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };
export { supportDir, waitForFolderCount, afterAllCleanUp, exportDir, newPluginService, newPluginScript, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };

View File

@ -527,7 +527,7 @@ class Application extends BaseApplication {
// time, however we only effectively uninstall the plugin the next
// time the app is started. What plugin should be uninstalled is
// stored in the settings.
const newSettings = await service.uninstallPlugins(pluginSettings);
const newSettings = service.clearUpdateState(await service.uninstallPlugins(pluginSettings));
Setting.setValue('plugins.states', newSettings);
try {

View File

@ -7,6 +7,7 @@ export enum ButtonLevel {
Secondary = 'secondary',
Tertiary = 'tertiary',
SidebarSecondary = 'sidebarSecondary',
Recommended = 'recommended',
}
interface Props {
@ -121,6 +122,20 @@ const StyledButtonTertiary = styled(StyledButtonBase)`
}
`;
const StyledButtonRecommended = styled(StyledButtonBase)`
border: 1px solid ${(props: any) => props.theme.borderColor4};
background-color: ${(props: any) => props.theme.warningBackgroundColor};
${StyledIcon} {
color: ${(props: any) => props.theme.color};
}
${StyledTitle} {
color: ${(props: any) => props.theme.color};
opacity: 0.9;
}
`;
const StyledButtonSidebarSecondary = styled(StyledButtonBase)`
background: none;
border-color: ${(props: any) => props.theme.color2};
@ -167,10 +182,11 @@ function buttonClass(level: ButtonLevel) {
if (level === ButtonLevel.Primary) return StyledButtonPrimary;
if (level === ButtonLevel.Tertiary) return StyledButtonTertiary;
if (level === ButtonLevel.SidebarSecondary) return StyledButtonSidebarSecondary;
if (level === ButtonLevel.Recommended) return StyledButtonRecommended;
return StyledButtonSecondary;
}
export default function Button(props: Props) {
function Button(props: Props) {
const iconOnly = props.iconName && !props.title;
const StyledButton = buttonClass(props.level);
@ -197,3 +213,5 @@ export default function Button(props: Props) {
</StyledButton>
);
}
export default styled(Button)`${space}`;

View File

@ -148,6 +148,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
const createSettingComponents = (advanced: boolean) => {
const output = [];
for (let i = 0; i < section.metadatas.length; i++) {
const md = section.metadatas[i];
if (!!md.advanced !== advanced) continue;
@ -160,8 +161,8 @@ class ConfigScreenComponent extends React.Component<any, any> {
const settingComps = createSettingComponents(false);
const advancedSettingComps = createSettingComponents(true);
const sectionWidths: Record<string, number> = {
plugins: 900,
const sectionWidths: Record<string, any> = {
plugins: '100%',
};
const sectionStyle: any = {
@ -305,7 +306,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
);
}
private renderHeader(themeId: number, label: string) {
private renderHeader(themeId: number, label: string, style: any = null) {
const theme = themeStyle(themeId);
const labelStyle = Object.assign({}, theme.textStyle, {
@ -314,6 +315,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
fontSize: theme.fontSize * 1.25,
fontWeight: 500,
marginBottom: theme.mainPadding,
...style,
});
return (
@ -457,7 +459,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
// There's probably a better way to do this but can't figure it out.
return (
<div key={key + value.toString()} style={rowStyle}>
<div key={key + (`${value}`).toString()} style={rowStyle}>
<div style={{ ...controlStyle, backgroundColor: 'transparent', display: 'flex', alignItems: 'center' }}>
<input
id={`setting_checkbox_${key}`}
@ -564,7 +566,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
value={cmd[1]}
spellCheck={false}
/>
<div style={{ width: inputStyle.width }}>
<div style={{ width: inputStyle.width, minWidth: inputStyle.minWidth }}>
{descriptionComp}
</div>
</div>
@ -593,7 +595,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
}}
spellCheck={false}
/>
<div style={{ width: inputStyle.width }}>
<div style={{ width: inputStyle.width, minWidth: inputStyle.minWidth }}>
{descriptionComp}
</div>
</div>

View File

@ -11,34 +11,47 @@ export enum InstallState {
Installed = 3,
}
export enum UpdateState {
Idle = 1,
CanUpdate = 2,
Updating = 3,
HasBeenUpdated = 4,
}
interface Props {
item?: PluginItem;
manifest?: PluginManifest;
installState?: InstallState;
updateState?: UpdateState;
themeId: number;
onToggle?: Function;
onDelete?: Function;
onInstall?: Function;
onUpdate?: Function;
}
function manifestToItem(manifest: PluginManifest): PluginItem {
return {
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
enabled: true,
deleted: false,
devMode: false,
hasBeenUpdated: false,
};
}
export interface PluginItem {
id: string;
name: string;
version: string;
description: string;
enabled: boolean;
deleted: boolean;
devMode: boolean;
hasBeenUpdated: boolean;
}
const CellRoot = styled.div`
@ -50,7 +63,7 @@ const CellRoot = styled.div`
padding: 15px;
border: 1px solid ${props => props.theme.dividerColor};
border-radius: 6px;
width: 250px;
width: 320px;
margin-right: 20px;
margin-bottom: 20px;
box-shadow: 1px 1px 3px rgba(0,0,0,0.2);
@ -90,6 +103,12 @@ const StyledName = styled.div`
flex: 1;
`;
const StyledVersion = styled.span`
margin-left: 5px;
color: ${props => props.theme.colorFaded};
font-size: ${props => props.theme.fontSize * 0.9}px;
`;
const StyledDescription = styled.div`
font-family: ${props => props.theme.fontFamily};
color: ${props => props.theme.colorFaded};
@ -138,6 +157,23 @@ export default function(props: Props) {
/>;
}
function renderUpdateButton() {
if (!props.onUpdate) return null;
let title = _('Update');
if (props.updateState === UpdateState.Updating) title = _('Updating...');
if (props.updateState === UpdateState.Idle) title = _('Updated');
if (props.updateState === UpdateState.HasBeenUpdated) title = _('Updated');
return <Button
ml={1}
level={ButtonLevel.Recommended}
onClick={() => props.onUpdate({ item })}
title={title}
disabled={props.updateState === UpdateState.HasBeenUpdated}
/>;
}
function renderFooter() {
if (item.devMode) return null;
@ -145,6 +181,7 @@ export default function(props: Props) {
<CellFooter>
{renderDeleteButton()}
{renderInstallButton()}
{renderUpdateButton()}
<div style={{ display: 'flex', flex: 1 }}/>
</CellFooter>
);
@ -153,7 +190,7 @@ export default function(props: Props) {
return (
<CellRoot>
<CellTop>
<StyledName mb={'5px'}>{item.name} {item.deleted ? '(Deleted)' : ''}</StyledName>
<StyledName mb={'5px'}>{item.name} {item.deleted ? '(Deleted)' : ''} <StyledVersion>v{item.version}</StyledVersion></StyledName>
{renderToggleButton()}
</CellTop>
<CellContent>

View File

@ -1,18 +1,21 @@
import * as React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, 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 PluginBox, { UpdateState } 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';
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
import Setting from '@joplin/lib/models/Setting';
import useOnInstallHandler, { OnPluginSettingChangeEvent } from './useOnInstallHandler';
const { space } = require('styled-system');
const maxWidth: number = 250;
const maxWidth: number = 320;
const Root = styled.div`
display: flex;
@ -26,7 +29,7 @@ const UserPluginsRoot = styled.div`
`;
const ToolsButton = styled(Button)`
margin-right: 2px;
margin-right: 6px;
`;
interface Props {
@ -38,6 +41,15 @@ interface Props {
renderHeader: Function;
}
let repoApi_: RepositoryApi = null;
function repoApi(): RepositoryApi {
if (repoApi_) return repoApi_;
repoApi_ = new RepositoryApi('https://github.com/joplin/plugins', Setting.value('tempDir'));
// repoApi_ = new RepositoryApi('/Users/laurent/src/joplin-plugins-test', Setting.value('tempDir'));
return repoApi_;
}
function usePluginItems(plugins: Plugins, settings: PluginSettings): PluginItem[] {
return useMemo(() => {
const output: PluginItem[] = [];
@ -53,10 +65,12 @@ function usePluginItems(plugins: Plugins, settings: PluginSettings): PluginItem[
output.push({
id: pluginId,
name: plugin.manifest.name,
version: plugin.manifest.version,
description: plugin.manifest.description,
enabled: setting.enabled,
deleted: setting.deleted,
devMode: plugin.devMode,
hasBeenUpdated: setting.hasBeenUpdated,
});
}
@ -70,6 +84,9 @@ function usePluginItems(plugins: Plugins, settings: PluginSettings): PluginItem[
export default function(props: Props) {
const [searchQuery, setSearchQuery] = useState('');
const [manifestsLoaded, setManifestsLoaded] = useState<boolean>(false);
const [updatingPluginsIds, setUpdatingPluginIds] = useState<Record<string, boolean>>({});
const [canBeUpdatedPluginIds, setCanBeUpdatedPluginIds] = useState<Record<string, boolean>>({});
const pluginService = PluginService.instance();
@ -77,6 +94,43 @@ export default function(props: Props) {
return pluginService.unserializePluginSettings(props.value);
}, [props.value]);
const pluginItems = usePluginItems(pluginService.plugins, pluginSettings);
useEffect(() => {
let cancelled = false;
async function fetchManifests() {
await repoApi().loadManifests();
if (cancelled) return;
setManifestsLoaded(true);
}
void fetchManifests();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!manifestsLoaded) return () => {};
let cancelled = false;
async function fetchPluginIds() {
const pluginIds = await repoApi().canBeUpdatedPlugins(pluginItems as any);
if (cancelled) return;
const conv: Record<string, boolean> = {};
pluginIds.forEach(id => conv[id] = true);
setCanBeUpdatedPluginIds(conv);
}
void fetchPluginIds();
return () => {
cancelled = true;
};
}, [manifestsLoaded, pluginItems]);
const onDelete = useCallback(async (event: any) => {
const item: PluginItem = event.item;
const confirm = await bridge().showConfirmMessageBox(_('Delete plugin "%s"?', item.name));
@ -118,6 +172,12 @@ export default function(props: Props) {
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
}, [pluginSettings, props.onChange]);
const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => {
props.onChange({ value: pluginService.serializePluginSettings(event.value) });
}, []);
const onUpdate = useOnInstallHandler(setUpdatingPluginIds, pluginSettings, repoApi, onPluginSettingsChange, true);
const onToolsClick = useCallback(async () => {
const template = [];
@ -144,12 +204,22 @@ export default function(props: Props) {
for (const item of items) {
if (item.deleted) continue;
const isUpdating = updatingPluginsIds[item.id];
const onUpdateHandler = canBeUpdatedPluginIds[item.id] ? onUpdate : null;
let updateState = UpdateState.Idle;
if (onUpdateHandler) updateState = UpdateState.CanUpdate;
if (isUpdating) updateState = UpdateState.Updating;
if (item.hasBeenUpdated) updateState = UpdateState.HasBeenUpdated;
output.push(<PluginBox
key={item.id}
item={item}
themeId={props.themeId}
updateState={updateState}
onDelete={onDelete}
onToggle={onToggle}
onUpdate={onUpdateHandler}
/>);
}
@ -174,13 +244,11 @@ export default function(props: Props) {
}
}
const pluginItems = usePluginItems(pluginService.plugins, pluginSettings);
return (
<Root>
function renderSearchArea() {
return (
<div style={{ marginBottom: 20 }}>
{props.renderHeader(props.themeId, _('Search for plugins'))}
<SearchPlugins
disabled={!manifestsLoaded}
maxWidth={maxWidth}
themeId={props.themeId}
searchQuery={searchQuery}
@ -188,18 +256,32 @@ export default function(props: Props) {
onSearchQueryChange={onSearchQueryChange}
onPluginSettingsChange={onSearchPluginSettingsChange}
renderDescription={props.renderDescription}
repoApi={repoApi}
/>
</div>
);
}
function renderBottomArea() {
if (searchQuery) return null;
return (
<div>
<div style={{ display: 'flex', flexDirection: 'row', maxWidth }}>
<ToolsButton tooltip={_('Plugin tools')} iconName="fas fa-cog" level={ButtonLevel.Secondary} onClick={onToolsClick}/>
<div style={{ display: 'flex', flex: 1 }}>
{props.renderHeader(props.themeId, _('Manage your plugins'))}
</div>
<ToolsButton tooltip={_('Plugin tools')} iconName="fas fa-cog" level={ButtonLevel.Primary} onClick={onToolsClick}/>
</div>
{renderUserPlugins(pluginItems)}
</div>
);
}
return (
<Root>
{renderSearchArea()}
{renderBottomArea()}
</Root>
);
}

View File

@ -6,7 +6,6 @@ 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';
@ -27,24 +26,18 @@ interface Props {
onPluginSettingsChange(event: any): void;
renderDescription: Function;
maxWidth: number;
}
let repoApi_: RepositoryApi = null;
function repoApi(): RepositoryApi {
if (repoApi_) return repoApi_;
repoApi_ = new RepositoryApi('https://github.com/joplin/plugins', Setting.value('tempDir'));
return repoApi_;
repoApi(): RepositoryApi;
disabled: boolean;
}
export default function(props: Props) {
const [searchStarted, setSearchStarted] = useState(false);
const [manifests, setManifests] = useState<PluginManifest[]>([]);
const asyncSearchQueue = useRef(new AsyncActionQueue(200));
const asyncSearchQueue = useRef(new AsyncActionQueue(10));
const [installingPluginsIds, setInstallingPluginIds] = useState<Record<string, boolean>>({});
const [searchResultCount, setSearchResultCount] = useState(null);
const onInstall = useOnInstallHandler(setInstallingPluginIds, props.pluginSettings, repoApi, props.onPluginSettingsChange);
const onInstall = useOnInstallHandler(setInstallingPluginIds, props.pluginSettings, props.repoApi, props.onPluginSettingsChange, false);
useEffect(() => {
setSearchResultCount(null);
@ -53,7 +46,7 @@ export default function(props: Props) {
setManifests([]);
setSearchResultCount(null);
} else {
const r = await repoApi().search(props.searchQuery);
const r = await props.repoApi().search(props.searchQuery);
setManifests(r);
setSearchResultCount(r.length);
}
@ -107,6 +100,8 @@ export default function(props: Props) {
onChange={onChange}
onSearchButtonClick={onSearchButtonClick}
searchStarted={searchStarted}
placeholder={_('Search for plugins...')}
disabled={props.disabled}
/>
</div>

View File

@ -6,7 +6,13 @@ import Logger from '@joplin/lib/Logger';
const logger = Logger.create('useOnInstallHandler');
export default function(setInstallingPluginIds: Function, pluginSettings: PluginSettings, repoApi: Function, onPluginSettingsChange: Function) {
export interface OnPluginSettingChangeEvent {
value: PluginSettings;
}
type OnPluginSettingChangeHandler = (event: OnPluginSettingChangeEvent)=> void;
export default function(setInstallingPluginIds: Function, pluginSettings: PluginSettings, repoApi: Function, onPluginSettingsChange: OnPluginSettingChangeHandler, isUpdate: boolean) {
return useCallback(async (event: any) => {
const pluginId = event.item.id;
@ -19,7 +25,11 @@ export default function(setInstallingPluginIds: Function, pluginSettings: Plugin
let installError = null;
try {
await PluginService.instance().installPluginFromRepo(repoApi(), pluginId);
if (isUpdate) {
await PluginService.instance().updatePluginFromRepo(repoApi(), pluginId);
} else {
await PluginService.instance().installPluginFromRepo(repoApi(), pluginId);
}
} catch (error) {
installError = error;
logger.error(error);
@ -28,6 +38,7 @@ export default function(setInstallingPluginIds: Function, pluginSettings: Plugin
if (!installError) {
const newSettings = produce(pluginSettings, (draft: PluginSettings) => {
draft[pluginId] = defaultPluginSetting();
if (isUpdate) draft[pluginId].hasBeenUpdated = true;
});
onPluginSettingsChange({ value: newSettings });

View File

@ -41,6 +41,8 @@ interface Props {
onKeyDown?: Function;
onSearchButtonClick: Function;
searchStarted: boolean;
placeholder?: string;
disabled?: boolean;
}
export interface OnChangeEvent {
@ -60,12 +62,13 @@ export default function(props: Props) {
ref={props.inputRef}
value={props.value}
type="text"
placeholder={_('Search...')}
placeholder={props.placeholder || _('Search...')}
onChange={onChange}
onFocus={props.onFocus}
onBlur={props.onBlur}
onKeyDown={props.onKeyDown}
spellCheck={false}
disabled={props.disabled}
/>
<SearchButton onClick={props.onSearchButtonClick}>
<SearchButtonIcon className={iconName}/>

View File

@ -667,6 +667,7 @@ export default class BaseApplication {
const resourceDirName = 'resources';
const resourceDir = `${profileDir}/${resourceDirName}`;
const tempDir = `${profileDir}/tmp`;
const cacheDir = `${profileDir}/cache`;
Setting.setConstant('env', initArgs.env);
Setting.setConstant('profileDir', profileDir);
@ -674,6 +675,7 @@ export default class BaseApplication {
Setting.setConstant('resourceDirName', resourceDirName);
Setting.setConstant('resourceDir', resourceDir);
Setting.setConstant('tempDir', tempDir);
Setting.setConstant('cacheDir', cacheDir);
Setting.setConstant('pluginDir', `${profileDir}/plugins`);
SyncTargetRegistry.addClass(SyncTargetFilesystem);
@ -695,6 +697,7 @@ export default class BaseApplication {
await fs.mkdirp(profileDir, 0o755);
await fs.mkdirp(resourceDir, 0o755);
await fs.mkdirp(tempDir, 0o755);
await fs.mkdirp(cacheDir, 0o755);
// Clean up any remaining watched files (they start with "edit-")
await shim.fsDriver().removeAllThatStartWith(profileDir, 'edit-');

View File

@ -1659,6 +1659,7 @@ Setting.constants_ = {
profileDir: '',
templateDir: '',
tempDir: '',
cacheDir: '',
pluginDir: '',
flagOpenDevTools: false,
syncVersion: 2,

View File

@ -8,6 +8,7 @@ import { filename, dirname, rtrimSlashes } from '../../path-utils';
import Setting from '../../models/Setting';
import Logger from '../../Logger';
import RepositoryApi from './RepositoryApi';
import produce from 'immer';
const compareVersions = require('compare-versions');
const uslug = require('uslug');
const md5File = require('md5-file/promise');
@ -31,12 +32,19 @@ export interface Plugins {
export interface PluginSetting {
enabled: boolean;
deleted: boolean;
// After a plugin has been updated, the user needs to restart the app before
// loading the new version. In the meantime, we set this property to `true`
// so that we know the plugin has been updated. It is used for example to
// disable the Update button.
hasBeenUpdated: boolean;
}
export function defaultPluginSetting(): PluginSetting {
return {
enabled: true,
deleted: false,
hasBeenUpdated: false,
};
}
@ -92,6 +100,10 @@ export default class PluginService extends BaseService {
delete this.plugins_[pluginId];
}
private async deletePluginFiles(plugin: Plugin) {
await shim.fsDriver().remove(plugin.baseDir);
}
public pluginById(id: string): Plugin {
if (!this.plugins_[id]) throw new Error(`Plugin not found: ${id}`);
@ -173,7 +185,7 @@ export default class PluginService extends BaseService {
const fname = filename(path);
const hash = await md5File(path);
const unpackDir = `${Setting.value('tempDir')}/${fname}`;
const unpackDir = `${Setting.value('cacheDir')}/${fname}`;
const manifestFilePath = `${unpackDir}/manifest.json`;
let manifest: any = await this.loadManifestToObject(manifestFilePath);
@ -194,7 +206,7 @@ export default class PluginService extends BaseService {
manifest._package_hash = hash;
await shim.fsDriver().writeFile(manifestFilePath, JSON.stringify(manifest), 'utf8');
await shim.fsDriver().writeFile(manifestFilePath, JSON.stringify(manifest, null, '\t'), 'utf8');
}
return this.loadPluginFromPath(unpackDir);
@ -345,6 +357,10 @@ export default class PluginService extends BaseService {
return plugin;
}
public async updatePluginFromRepo(repoApi: RepositoryApi, pluginId: string): Promise<Plugin> {
return this.installPluginFromRepo(repoApi, pluginId);
}
public async installPlugin(jplPath: string): Promise<Plugin> {
logger.info(`Installing plugin: "${jplPath}"`);
@ -352,6 +368,7 @@ export default class PluginService extends BaseService {
// from where it is now to check that it is valid and to retrieve
// the plugin ID.
const preloadedPlugin = await this.loadPluginFromPath(jplPath);
await this.deletePluginFiles(preloadedPlugin);
const destPath = `${Setting.value('pluginDir')}/${preloadedPlugin.id}.jpl`;
await shim.fsDriver().copy(jplPath, destPath);
@ -402,6 +419,16 @@ export default class PluginService extends BaseService {
return newSettings;
}
// On startup the "hasBeenUpdated" prop can be cleared since the new version
// of the plugin has now been loaded.
public clearUpdateState(settings: PluginSettings): PluginSettings {
return produce(settings, (draft: PluginSettings) => {
for (const pluginId in draft) {
if (draft[pluginId].hasBeenUpdated) draft[pluginId].hasBeenUpdated = false;
}
});
}
public async destroy() {
await this.runner_.waitForSandboxCalls();
}

View File

@ -1,11 +1,15 @@
import shim from '../../shim';
import { PluginManifest } from './utils/types';
const md5 = require('md5');
const compareVersions = require('compare-versions');
export default class RepositoryApi {
// For now, it's assumed that the baseUrl is a GitHub repo URL, such as
// https://github.com/joplin/plugins
// As a base URL, this class can support either a remote repository or a
// local one (a directory path), which is useful for testing.
//
// For now, if the baseUrl is an actual URL it's assumed it's a GitHub repo
// URL, such as https://github.com/joplin/plugins
//
// Later on, other repo types could be supported.
private baseUrl_: string;
@ -17,8 +21,29 @@ export default class RepositoryApi {
this.tempDir_ = tempDir;
}
public async loadManifests() {
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];
});
} catch (error) {
throw new Error(`Could not parse JSON: ${error.message}`);
}
}
private get isLocalRepo(): boolean {
return this.baseUrl_.indexOf('http') !== 0;
}
private get contentBaseUrl(): string {
return `${this.baseUrl_.replace(/github\.com/, 'raw.githubusercontent.com')}/master`;
if (this.isLocalRepo) {
return this.baseUrl_;
} else {
return `${this.baseUrl_.replace(/github\.com/, 'raw.githubusercontent.com')}/master`;
}
}
private fileUrl(relativePath: string): string {
@ -26,7 +51,11 @@ export default class RepositoryApi {
}
private async fetchText(path: string): Promise<string> {
return shim.fetchText(this.fileUrl(path));
if (this.isLocalRepo) {
return shim.fsDriver().readFile(this.fileUrl(path), 'utf8');
} else {
return shim.fetchText(this.fileUrl(path));
}
}
public async search(query: string): Promise<PluginManifest[]> {
@ -60,27 +89,40 @@ export default class RepositoryApi {
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}"`);
if (this.isLocalRepo) {
await shim.fsDriver().copy(fileUrl, targetPath);
} else {
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}`);
public async manifests(): Promise<PluginManifest[]> {
if (!this.manifests_) throw new Error('Manifests have no been loaded!');
return this.manifests_;
}
public async canBeUpdatedPlugins(installedManifests: PluginManifest[]): Promise<string[]> {
const output = [];
for (const manifest of installedManifests) {
const canBe = await this.pluginCanBeUpdated(manifest.id, manifest.version);
if (canBe) output.push(manifest.id);
}
return output;
}
public async pluginCanBeUpdated(pluginId: string, installedVersion: string): Promise<boolean> {
const manifest = (await this.manifests()).find(m => m.id === pluginId);
if (!manifest) return false;
return compareVersions(installedVersion, manifest.version) < 0;
}
}