343 changes: 343 additions & 0 deletions packages/app-desktop/gui/SyncWizard/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
import * as React from 'react';
import { useState, useRef, useCallback } from 'react';
import { _ } from '@joplin/lib/locale';
import DialogButtonRow from '../DialogButtonRow';
import Dialog from '../Dialog';
import styled from 'styled-components';
import DialogTitle from '../DialogTitle';
import SyncTargetRegistry, { SyncTargetInfo } from '@joplin/lib/SyncTargetRegistry';
import useElementSize from '@joplin/lib/hooks/useElementSize';
import Button, { ButtonLevel } from '../Button/Button';
import bridge from '../../services/bridge';
import StyledInput from '../style/StyledInput';
import Setting from '../../../lib/models/Setting';
import SyncTargetJoplinCloud from '../../../lib/SyncTargetJoplinCloud';
import StyledLink from '../style/StyledLink';

interface Props {
themeId: number;
dispatch: Function;
}

const StyledRoot = styled.div`
min-width: 500px;
max-width: 1200px;
`;

const SyncTargetDescription = styled.div`
${props => props.height ? `height: ${props.height}px` : ''};
margin-bottom: 1.3em;
line-height: ${props => props.theme.lineHeight};
font-size: 16px;
`;

const CreateAccountLink = styled(StyledLink)`
font-size: 16px;
`;

const ContentRoot = styled.div`
background-color: ${props => props.theme.backgroundColor3};
padding: 1em;
padding-right: 0;
`;

const SelfHostingMessage = styled.div`
color: ${props => props.theme.color};
padding-right: 1em;
font-style: italic;
margin-top: 1em;
opacity: 0.6;
`;

const SyncTargetBoxes = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
`;

const SyncTargetTitle = styled.p`
display: flex;
flex-direction: row;
font-weight: bold;
font-size: 1.7em;
align-items: center;
white-space: nowrap;
`;

const SyncTargetLogo = styled.img`
height: 1.3em;
margin-right: 0.4em;
`;

const SyncTargetBox = styled.div`
display: flex;
flex: 1;
flex-direction: column;
font-family: ${props => props.theme.fontFamily};
color: ${props => props.theme.color};
background-color: ${props => props.theme.backgroundColor};
border: 1px solid ${props => props.theme.dividerColor};
border-radius: 8px;
padding: 0.8em 2.2em 2em 2.2em;
margin-right: 1em;
max-width: 400px;
opacity: ${props => props.faded ? 0.5 : 1};
`;

const FeatureList = styled.div`
margin-bottom: 1em;
`;

const FeatureIcon = styled.i`
display: inline-flex;
width: 16px;
justify-content: center;
color: ${props => props.theme.color4};
position: absolute;
`;

const FeatureLine = styled.div`
margin-bottom: .5em;
opacity: ${props => props.enabled ? 1 : 0.5};
position: relative;
font-size: 16px;
`;

const FeatureLabel = styled.div`
margin-left: 24px;
line-height: ${props => props.theme.lineHeight};
`;

const SelectButton = styled(Button)`
padding: 10px 10px;
height: auto;
min-height: auto;
max-height: fit-content;
font-size: 1em;
`;

const JoplinCloudLoginForm = styled.div`
display: flex;
flex-direction: column;
`;

const FormLabel = styled.label`
font-weight: bold;
margin: 1em 0 0.6em 0;
`;

const syncTargetNames: string[] = [
'joplinCloud',
'dropbox',
'onedrive',
'nextcloud',
'webdav',
'amazon_s3',
'joplinServer',
];


const logosImageNames: Record<string, string> = {
'dropbox': 'Dropbox.svg',
'joplinCloud': 'JoplinCloud.svg',
'onedrive': 'OneDrive.svg',
};

export default function(props: Props) {
const [showJoplinCloudForm, setShowJoplinCloudForm] = useState(false);
const joplinCloudDescriptionRef = useRef(null);
const [joplinCloudEmail, setJoplinCloudEmail] = useState('');
const [joplinCloudPassword, setJoplinCloudPassword] = useState('');
const [joplinCloudLoginInProgress, setJoplinCloudLoginInProgress] = useState(false);

function closeDialog(dispatch: Function) {
dispatch({
type: 'DIALOG_CLOSE',
name: 'syncWizard',
});
}

const onButtonRowClick = useCallback(() => {
closeDialog(props.dispatch);
}, [props.dispatch]);

const { height: descriptionHeight } = useElementSize(joplinCloudDescriptionRef);

function renderFeature(enabled: boolean, label: string) {
const className = enabled ? 'fas fa-check' : 'fas fa-times';
return (
<FeatureLine enabled={enabled} key={label}><FeatureIcon className={className}></FeatureIcon> <FeatureLabel>{label}</FeatureLabel></FeatureLine>
);
}

function renderFeatures(name: string) {
return (
<FeatureList>
{[
renderFeature(true, _('Sync your notes')),
renderFeature(name === 'joplinCloud', _('Publish notes to the internet')),
renderFeature(name === 'joplinCloud', _('Collaborate on notebooks with others')),
]}
</FeatureList>
);
}

const onJoplinCloudEmailChange = useCallback((event: any) => {
setJoplinCloudEmail(event.target.value);
}, []);

const onJoplinCloudPasswordChange = useCallback((event: any) => {
setJoplinCloudPassword(event.target.value);
}, []);

const onJoplinCloudLoginClick = useCallback(async () => {
setJoplinCloudLoginInProgress(true);

try {
const result = await SyncTargetJoplinCloud.checkConfig({
password: () => joplinCloudPassword,
path: () => Setting.value('sync.10.path'),
userContentPath: () => Setting.value('sync.10.userContentPath'),
username: () => joplinCloudEmail,
});

if (result.ok) {
Setting.setValue('sync.target', 10);
Setting.setValue('sync.10.username', joplinCloudEmail);
Setting.setValue('sync.10.password', joplinCloudPassword);
await Setting.saveAll();

alert(_('Thank you! Your Joplin Cloud account is now setup and ready to use.'));

closeDialog(props.dispatch);

props.dispatch({
type: 'NAV_GO',
routeName: 'Main',
});
} else {
alert(_('There was an error setting up your Joplin Cloud account. Please verify your email and password and try again. Error was:\n\n%s', result.errorMessage));
}
} finally {
setJoplinCloudLoginInProgress(false);
}
}, [joplinCloudEmail, joplinCloudPassword, props.dispatch]);

const onJoplinCloudCreateAccountClick = useCallback(() => {
bridge().openExternal('https://joplinapp.org/plans/');
}, []);

function renderJoplinCloudLoginForm() {
return (
<JoplinCloudLoginForm>
<div>{_('Login below.')} <CreateAccountLink href="#" onClick={onJoplinCloudCreateAccountClick}>{_('Or create an account.')}</CreateAccountLink></div>
<FormLabel>Email</FormLabel>
<StyledInput type="email" onChange={onJoplinCloudEmailChange}/>
<FormLabel>Password</FormLabel>
<StyledInput type="password" onChange={onJoplinCloudPasswordChange}/>
<SelectButton mt="1.3em" disabled={joplinCloudLoginInProgress} level={ButtonLevel.Primary} title={_('Login')} onClick={onJoplinCloudLoginClick}/>
</JoplinCloudLoginForm>
);
}

const onSelectButtonClick = useCallback(async (name: string) => {
if (name === 'joplinCloud') {
setShowJoplinCloudForm(true);
} else {
Setting.setValue('sync.target', name === 'dropbox' ? 7 : 3);
await Setting.saveAll();
closeDialog(props.dispatch);
props.dispatch({
type: 'NAV_GO',
routeName: name === 'dropbox' ? 'DropboxLogin' : 'OneDriveLogin',
});
}
}, [props.dispatch]);

function renderSelectArea(info: SyncTargetInfo) {
if (info.name === 'joplinCloud' && showJoplinCloudForm) {
return renderJoplinCloudLoginForm();
} else {
return (
<SelectButton
level={ButtonLevel.Primary}
title={_('Select')}
onClick={() => onSelectButtonClick(info.name)}
disabled={joplinCloudLoginInProgress}
/>
);
}
}

function renderSyncTarget(info: SyncTargetInfo) {
const key = `syncTarget_${info.name}`;
const height = info.name !== 'joplinCloud' ? descriptionHeight : null;

const logoImageName = logosImageNames[info.name];
const logoImageSrc = logoImageName ? `${bridge().buildDir()}/images/syncTargetLogos/${logoImageName}` : '';
const logo = logoImageSrc ? <SyncTargetLogo src={logoImageSrc}/> : null;
const descriptionComp = <SyncTargetDescription height={height} ref={info.name === 'joplinCloud' ? joplinCloudDescriptionRef : null}>{info.description}</SyncTargetDescription>;
const featuresComp = showJoplinCloudForm && info.name === 'joplinCloud' ? null : renderFeatures(info.name);

return (
<SyncTargetBox id={key} key={key} faded={showJoplinCloudForm && info.name !== 'joplinCloud'}>
<SyncTargetTitle>{logo}{info.label}</SyncTargetTitle>
{descriptionComp}
{featuresComp}
{renderSelectArea(info)}
</SyncTargetBox>
);
}

const onSelfHostingClick = useCallback(() => {
closeDialog(props.dispatch);

props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: {
defaultSection: 'sync',
},
});
}, [props.dispatch]);

function renderContent() {
const boxes: any[] = [];

for (const name of syncTargetNames) {
const info = SyncTargetRegistry.infoByName(name);
if (info.supportsSelfHosted) continue;
boxes.push(renderSyncTarget(info));
}

const selfHostingMessage = showJoplinCloudForm ? null : <SelfHostingMessage>Self-hosting? Joplin also supports various self-hosting options such as Nextcloud, WebDAV, AWS S3 and Joplin Server. <a href="#" onClick={onSelfHostingClick}>Click here to select one</a>.</SelfHostingMessage>;

return (
<ContentRoot>
<SyncTargetBoxes>
{boxes}
</SyncTargetBoxes>
{selfHostingMessage}
</ContentRoot>
);
}

function renderDialogWrapper() {
return (
<StyledRoot>
<DialogTitle title={_('Joplin can synchronise your notes using various providers. Select one from the list below.')} justifyContent="center"/>
{renderContent()}
<DialogButtonRow
themeId={props.themeId}
onClick={onButtonRowClick}
okButtonShow={false}
cancelButtonLabel={_('Close')}
/>
</StyledRoot>
);
}

return (
<Dialog renderContent={renderDialogWrapper}/>
);
}
2 changes: 1 addition & 1 deletion packages/app-mobile/components/screens/ConfigScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const { BaseScreenComponent } = require('../base-screen.js');
const { Dropdown } = require('../Dropdown.js');
const { themeStyle } = require('../global-style.js');
const shared = require('@joplin/lib/components/shared/config-shared.js');
const SyncTargetRegistry = require('@joplin/lib/SyncTargetRegistry');
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
const RNFS = require('react-native-fs');

class ConfigScreenComponent extends BaseScreenComponent {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-mobile/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import SearchEngine from '@joplin/lib/services/searchengine/SearchEngine';
const WelcomeUtils = require('@joplin/lib/WelcomeUtils');
const { themeStyle } = require('./components/global-style.js');

const SyncTargetRegistry = require('@joplin/lib/SyncTargetRegistry.js');
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
const SyncTargetFilesystem = require('@joplin/lib/SyncTargetFilesystem.js');
const SyncTargetNextcloud = require('@joplin/lib/SyncTargetNextcloud.js');
const SyncTargetWebDAV = require('@joplin/lib/SyncTargetWebDAV.js');
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/BaseApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const fs = require('fs-extra');
import JoplinError from './JoplinError';
const EventEmitter = require('events');
const syswidecas = require('./vendor/syswide-cas');
const SyncTargetRegistry = require('./SyncTargetRegistry.js');
import SyncTargetRegistry from './SyncTargetRegistry';
const SyncTargetFilesystem = require('./SyncTargetFilesystem.js');
const SyncTargetNextcloud = require('./SyncTargetNextcloud.js');
const SyncTargetWebDAV = require('./SyncTargetWebDAV.js');
Expand Down
8 changes: 8 additions & 0 deletions packages/lib/BaseSyncTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export default class BaseSyncTarget {
return false;
}

public static description(): string {
return '';
}

public static supportsSelfHosted(): boolean {
return true;
}

public option(name: string, defaultValue: any = null) {
return this.options_ && name in this.options_ ? this.options_[name] : defaultValue;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/lib/SyncTargetAmazonS3.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class SyncTargetAmazonS3 extends BaseSyncTarget {
return `${_('AWS S3')} (Beta)`;
}

static description() {
return 'A service offered by Amazon Web Services (AWS) that provides object storage through a web service interface.';
}

async isAuthenticated() {
return true;
}
Expand Down
8 changes: 8 additions & 0 deletions packages/lib/SyncTargetDropbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ class SyncTargetDropbox extends BaseSyncTarget {
return _('Dropbox');
}

static description() {
return 'A file hosting service that offers cloud storage and file synchronization';
}

static supportsSelfHosted() {
return false;
}

authRouteName() {
return 'DropboxLogin';
}
Expand Down
8 changes: 8 additions & 0 deletions packages/lib/SyncTargetJoplinCloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export default class SyncTargetJoplinCloud extends BaseSyncTarget {
return _('Joplin Cloud');
}

public static description() {
return _('Joplin\'s own sync service. Also gives access to Joplin-specific features such as publishing notes or collaborating on notebooks with others.');
}

public static supportsSelfHosted(): boolean {
return false;
}

public async isAuthenticated() {
return true;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/lib/SyncTargetJoplinServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
return 'joplinServer';
}

public static description() {
return 'Besides synchronisation and improved performances, Joplin Server also gives access to Joplin-specific sharing features.';
}

public static label() {
return `${_('Joplin Server')} (Beta)`;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/lib/SyncTargetNextcloud.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class SyncTargetNextcloud extends BaseSyncTarget {
return _('Nextcloud');
}

static description() {
return 'A suite of client-server software for creating and using file hosting services.';
}

async isAuthenticated() {
return true;
}
Expand Down
8 changes: 8 additions & 0 deletions packages/lib/SyncTargetOneDrive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export default class SyncTargetOneDrive extends BaseSyncTarget {
return _('OneDrive');
}

public static description() {
return 'A file hosting service operated by Microsoft as part of its web version of Office.';
}

public static supportsSelfHosted(): boolean {
return false;
}

async isAuthenticated() {
return !!this.api().auth();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,68 @@
class SyncTargetRegistry {
static classById(syncTargetId) {
export interface SyncTargetInfo {
id: number;
name: string;
label: string;
supportsSelfHosted: boolean;
supportsConfigCheck: boolean;
description: string;
classRef: any;
}

export default class SyncTargetRegistry {

private static reg_: Record<number, SyncTargetInfo> = {};

public static classById(syncTargetId: number) {
const info = SyncTargetRegistry.reg_[syncTargetId];
if (!info) throw new Error(`Invalid id: ${syncTargetId}`);
return info.classRef;
}

static addClass(SyncTargetClass) {
public static infoByName(name: string): SyncTargetInfo {
for (const [, info] of Object.entries(this.reg_)) {
if (info.name === name) return info;
}
throw new Error(`Unknown name: ${name}`);
}

public static addClass(SyncTargetClass: any) {
this.reg_[SyncTargetClass.id()] = {
id: SyncTargetClass.id(),
name: SyncTargetClass.targetName(),
label: SyncTargetClass.label(),
classRef: SyncTargetClass,
description: SyncTargetClass.description(),
supportsSelfHosted: SyncTargetClass.supportsSelfHosted(),
supportsConfigCheck: SyncTargetClass.supportsConfigCheck(),
};
}

static allIds() {
public static allIds() {
return Object.keys(this.reg_);
}

static nameToId(name) {
public static nameToId(name: string) {
for (const n in this.reg_) {
if (!this.reg_.hasOwnProperty(n)) continue;
if (this.reg_[n].name === name) return this.reg_[n].id;
}
throw new Error(`Name not found: ${name}. Was the sync target registered?`);
}

static idToMetadata(id) {
public static idToMetadata(id: number) {
for (const n in this.reg_) {
if (!this.reg_.hasOwnProperty(n)) continue;
if (this.reg_[n].id === id) return this.reg_[n];
}
throw new Error(`ID not found: ${id}`);
}

static idToName(id) {
public static idToName(id: number) {
return this.idToMetadata(id).name;
}

static idAndLabelPlainObject(os) {
const output = {};
public static idAndLabelPlainObject(os: string) {
const output: Record<string, string> = {};
for (const n in this.reg_) {
if (!this.reg_.hasOwnProperty(n)) continue;
const info = this.reg_[n];
Expand All @@ -52,7 +74,3 @@ class SyncTargetRegistry {
return output;
}
}

SyncTargetRegistry.reg_ = {};

module.exports = SyncTargetRegistry;
4 changes: 4 additions & 0 deletions packages/lib/SyncTargetWebDAV.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class SyncTargetWebDAV extends BaseSyncTarget {
return _('WebDAV');
}

static description() {
return 'The WebDAV protocol allows users to create, change and move documents on a server. There are many WebDAV compatible servers, including SeaFile, Nginx or Apache.';
}

async isAuthenticated() {
return true;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/components/shared/config-shared.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const Setting = require('../../models/Setting').default;
const SyncTargetRegistry = require('../../SyncTargetRegistry');
const SyncTargetRegistry = require('../../SyncTargetRegistry').default;
const ObjectUtils = require('../../ObjectUtils');
const { _ } = require('../../locale');
const { createSelector } = require('reselect');
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/components/shared/dropbox-login-shared.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const shim = require('../../shim').default;
const SyncTargetRegistry = require('../../SyncTargetRegistry');
const SyncTargetRegistry = require('../../SyncTargetRegistry').default;
const { reg } = require('../../registry.js');
const { _ } = require('../../locale');
const Setting = require('../../models/Setting').default;
Expand Down
38 changes: 38 additions & 0 deletions packages/lib/hooks/useElementSize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import shim from '../shim';
const { useCallback, useEffect, useState } = shim.react();
import useEventListener from './useEventListener';

interface Size {
width: number;
height: number;
}

function useElementSize(elementRef: any): Size {
const [size, setSize] = useState({
width: 0,
height: 0,
});

// Prevent too many rendering using useCallback
const updateSize = useCallback(() => {
const node = elementRef?.current;
if (node) {
setSize({
width: node.offsetWidth || 0,
height: node.offsetHeight || 0,
});
}
}, [elementRef]);

// Initial size on mount
useEffect(() => {
updateSize();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEventListener('resize', updateSize);

return size;
}

export default useElementSize;
41 changes: 41 additions & 0 deletions packages/lib/hooks/useEventListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import shim from '../shim';
const { useEffect, useRef } = shim.react();

function useEventListener(
eventName: any,
handler: any,
element?: any
) {
// Create a ref that stores handler
const savedHandler = useRef();

useEffect(() => {
// Define the listening target
const targetElement = element?.current || window;
if (!(targetElement && targetElement.addEventListener)) {
return null;
}

// Update saved handler if necessary
if (savedHandler.current !== handler) {
savedHandler.current = handler;
}

// Create event listener that calls handler function stored in ref
const eventListener = (event: Event) => {
// eslint-disable-next-line no-extra-boolean-cast
if (!!savedHandler?.current) {
savedHandler.current(event);
}
};

targetElement.addEventListener(eventName, eventListener);

// Remove event listener on cleanup
return () => {
targetElement.removeEventListener(eventName, eventListener);
};
}, [eventName, element, handler]);
}

export default useEventListener;
14 changes: 13 additions & 1 deletion packages/lib/models/Setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { _, supportedLocalesToLanguages, defaultLocale } from '../locale';
import eventManager from '../eventManager';
import BaseModel from '../BaseModel';
import Database from '../database';
const SyncTargetRegistry = require('../SyncTargetRegistry.js');
import SyncTargetRegistry from '../SyncTargetRegistry';
import time from '../time';
import FileHandler, { SettingValues } from './settings/FileHandler';
const { sprintf } = require('sprintf-js');
Expand Down Expand Up @@ -55,6 +55,7 @@ export interface SettingItem {
needRestart?: boolean;
autoSave?: boolean;
storage?: SettingStorage;
hideLabel?: boolean;
}

interface SettingItems {
Expand Down Expand Up @@ -306,6 +307,17 @@ class Setting extends BaseModel {
appTypes: [AppType.Desktop],
storage: SettingStorage.File,
},

'sync.openSyncWizard': {
value: null,
type: SettingItemType.Button,
public: true,
appTypes: [AppType.Desktop],
label: () => _('Open Sync Wizard...'),
hideLabel: true,
section: 'sync',
},

'sync.target': {
value: SyncTargetRegistry.nameToId('dropbox'),
type: SettingItemType.Int,
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/registry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Logger from './Logger';
import Setting from './models/Setting';
import shim from './shim';
const SyncTargetRegistry = require('./SyncTargetRegistry.js');
import SyncTargetRegistry from './SyncTargetRegistry';

class Registry {

Expand Down
2 changes: 1 addition & 1 deletion packages/lib/services/synchronizer/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { SqlQuery } from '../../database';
import JoplinDatabase from '../../JoplinDatabase';
import BaseItem from '../../models/BaseItem';
import Setting from '../../models/Setting';
const SyncTargetRegistry = require('../../SyncTargetRegistry');
import SyncTargetRegistry from '../../SyncTargetRegistry';

async function clearSyncContext() {
const syncTargetIds = SyncTargetRegistry.allIds();
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/testing/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const { FileApiDriverWebDav } = require('../file-api-driver-webdav.js');
const { FileApiDriverDropbox } = require('../file-api-driver-dropbox.js');
const { FileApiDriverOneDrive } = require('../file-api-driver-onedrive.js');
const { FileApiDriverAmazonS3 } = require('../file-api-driver-amazon-s3.js');
const SyncTargetRegistry = require('../SyncTargetRegistry.js');
import SyncTargetRegistry from '../SyncTargetRegistry';
const SyncTargetMemory = require('../SyncTargetMemory.js');
const SyncTargetFilesystem = require('../SyncTargetFilesystem.js');
const SyncTargetNextcloud = require('../SyncTargetNextcloud.js');
Expand Down