1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-23 22:36:32 +02:00

Cli: Support managing shared notebooks (#12637)

This commit is contained in:
Henry Heino
2025-07-01 14:47:03 -07:00
committed by GitHub
parent 41553eb963
commit 901fe73c08
18 changed files with 599 additions and 23 deletions

View File

@@ -122,6 +122,8 @@ packages/app-cli/app/command-rmnote.test.js
packages/app-cli/app/command-rmnote.js
packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-share.test.js
packages/app-cli/app/command-share.js
packages/app-cli/app/command-sync.js
packages/app-cli/app/command-testing.js
packages/app-cli/app/command-use.js
@@ -130,6 +132,8 @@ packages/app-cli/app/gui/FolderListWidget.js
packages/app-cli/app/gui/StatusBarWidget.js
packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/setupCommand.js
packages/app-cli/app/utils/initializeCommandService.js
packages/app-cli/app/utils/shimInitCli.js
packages/app-cli/app/utils/testUtils.js
packages/app-cli/tests/HtmlToMd.js
packages/app-cli/tests/MarkupToHtml.js
@@ -449,7 +453,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
@@ -1081,6 +1084,7 @@ packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
packages/lib/commands/index.js
packages/lib/commands/leaveSharedFolder.js
packages/lib/commands/openMasterPasswordDialog.js
packages/lib/commands/permanentlyDeleteNote.js
packages/lib/commands/renderMarkup.test.js

6
.gitignore vendored
View File

@@ -97,6 +97,8 @@ packages/app-cli/app/command-rmnote.test.js
packages/app-cli/app/command-rmnote.js
packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-share.test.js
packages/app-cli/app/command-share.js
packages/app-cli/app/command-sync.js
packages/app-cli/app/command-testing.js
packages/app-cli/app/command-use.js
@@ -105,6 +107,8 @@ packages/app-cli/app/gui/FolderListWidget.js
packages/app-cli/app/gui/StatusBarWidget.js
packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/setupCommand.js
packages/app-cli/app/utils/initializeCommandService.js
packages/app-cli/app/utils/shimInitCli.js
packages/app-cli/app/utils/testUtils.js
packages/app-cli/tests/HtmlToMd.js
packages/app-cli/tests/MarkupToHtml.js
@@ -424,7 +428,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
@@ -1056,6 +1059,7 @@ packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
packages/lib/commands/index.js
packages/lib/commands/leaveSharedFolder.js
packages/lib/commands/openMasterPasswordDialog.js
packages/lib/commands/permanentlyDeleteNote.js
packages/lib/commands/renderMarkup.test.js

View File

@@ -6,7 +6,7 @@ import Folder from '@joplin/lib/models/Folder';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag';
import Setting from '@joplin/lib/models/Setting';
import Setting, { Env } from '@joplin/lib/models/Setting';
import { reg } from '@joplin/lib/registry.js';
import { dirname, fileExtension } from '@joplin/lib/path-utils';
import { splitCommandString } from '@joplin/utils';
@@ -16,6 +16,7 @@ import RevisionService from '@joplin/lib/services/RevisionService';
import shim from '@joplin/lib/shim';
import setupCommand from './setupCommand';
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
import initializeCommandService from './utils/initializeCommandService';
const { cliUtils } = require('./cli-utils.js');
const Cache = require('@joplin/lib/Cache');
const { splitCommandBatch } = require('@joplin/lib/string-utils');
@@ -76,6 +77,12 @@ class Application extends BaseApplication {
}
}
public async loadItemOrFail(type: ModelType | 'folderOrNote', pattern: string) {
const output = await this.loadItem(type, pattern);
if (!output) throw new Error(_('Cannot find "%s".', pattern));
return output;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async loadItems(type: ModelType | 'folderOrNote', pattern: string, options: any = null): Promise<(FolderEntity | NoteEntity)[]> {
if (type === 'folderOrNote') {
@@ -414,6 +421,8 @@ class Application extends BaseApplication {
if (!shim.sharpEnabled()) this.logger().warn('Sharp is disabled - certain image-related features will not be available');
initializeCommandService(this.store(), Setting.value('env') === Env.Dev);
// If we have some arguments left at this point, it's a command
// so execute it.
if (argv.length) {

View File

@@ -13,7 +13,7 @@ describe('command-done', () => {
});
it('should make a note as "done"', async () => {
const note = await Note.save({ title: 'hello', is_todo: 1, todo_completed: 0 });
const note = await Note.save({ title: 'hello', is_todo: 1, todo_completed: 0, parent_id: '' });
const command = setupCommandForTesting(Command);

View File

@@ -26,8 +26,7 @@ class Command extends BaseCommand {
const pattern = args['notebook'];
const force = args.options && args.options.force === true;
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
if (!folder) throw new Error(_('Cannot find "%s".', pattern));
const folder = await app().loadItemOrFail(BaseModel.TYPE_FOLDER, pattern);
const permanent = args.options?.permanent === true || !!folder.deleted_time;
const ellipsizedFolderTitle = substrWithEllipsis(folder.title, 0, 32);

View File

@@ -0,0 +1,179 @@
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import mockShareService, { ApiMock } from '@joplin/lib/testing/share/mockShareService';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
import Folder from '@joplin/lib/models/Folder';
import ShareService from '@joplin/lib/services/share/ShareService';
import BaseItem from '@joplin/lib/models/BaseItem';
import { ModelType } from '@joplin/lib/BaseModel';
import { ShareInvitation, ShareUserStatus, StateShare } from '@joplin/lib/services/share/reducer';
import app from './app';
const Command = require('./command-share');
const setUpCommand = () => {
const output: string[] = [];
const stdout = (content: string) => {
output.push(...content.split('\n'));
};
const command = setupCommandForTesting(Command, stdout);
return { command, output };
};
const shareId = 'test-id';
const defaultFolderShare: StateShare = {
id: shareId,
type: ModelType.Folder,
folder_id: 'some-folder-id-here',
note_id: undefined,
master_key_id: undefined,
user: {
full_name: 'Test user',
email: 'test@localhost',
id: 'some-user-id',
},
};
const mockShareServiceForFolderSharing = (eventHandlerOverrides: Partial<ApiMock>&{ onExec?: undefined }) => {
const invitations: ShareInvitation[] = [];
mockShareService({
getShareInvitations: async () => ({
items: invitations,
}),
getShares: async () => ({ items: [defaultFolderShare] }),
getShareUsers: async (_id: string) => ({ items: [] }),
postShareUsers: async (_id, _body) => { },
postShares: async () => ({ id: shareId }),
...eventHandlerOverrides,
}, ShareService.instance(), app().store());
return {
addInvitation: (invitation: Partial<ShareInvitation>) => {
const defaultInvitation: ShareInvitation = {
share: defaultFolderShare,
id: 'some-invitation-id',
master_key: undefined,
status: ShareUserStatus.Waiting,
can_read: 1,
can_write: 1,
};
invitations.push({ ...defaultInvitation, ...invitation });
},
};
};
describe('command-share', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
BaseItem.shareService_ = ShareService.instance();
});
test('should allow adding a user to a share', async () => {
const folder = await Folder.save({ title: 'folder1' });
let lastShareUserUpdate: unknown|null = null;
mockShareServiceForFolderSharing({
getShares: async () => {
const isShared = !!lastShareUserUpdate;
if (isShared) {
return {
items: [{ ...defaultFolderShare, folder_id: folder.id }],
};
} else {
return { items: [] };
}
},
// Called when a new user is added to a share
postShareUsers: async (_id, body) => {
lastShareUserUpdate = body;
},
});
const { command } = setUpCommand();
// Should share read-write by default
await command.action({
'command': 'add',
'notebook': 'folder1',
'user': 'test@localhost',
options: {},
});
expect(lastShareUserUpdate).toMatchObject({
email: 'test@localhost',
can_write: 1,
can_read: 1,
});
// Should also support sharing as read only
await command.action({
'command': 'add',
'notebook': 'folder1',
'user': 'test2@localhost',
options: {
'read-only': true,
},
});
expect(lastShareUserUpdate).toMatchObject({
email: 'test2@localhost',
can_write: 0,
can_read: 1,
});
});
test.each([
{
label: 'should list a single pending invitation',
invitations: [{ id: 'test', status: ShareUserStatus.Waiting }],
expectedOutput: [
'Incoming shares:',
'\tWaiting: Notebook some-folder-id-here from test@localhost',
'All shared folders:',
'\tNone',
].join('\n'),
},
{
label: 'should list accepted invitations for non-existent folders with [None] as the folder title',
invitations: [
{ id: 'test2', status: ShareUserStatus.Accepted },
],
expectedOutput: [
'Incoming shares:',
'\tAccepted: Notebook [None] from test@localhost',
'All shared folders:',
'\tNone',
].join('\n'),
},
{
label: 'should not list rejected shares',
invitations: [
{ id: 'test3', status: ShareUserStatus.Rejected },
],
expectedOutput: [
'Incoming shares:',
'\tNone',
'All shared folders:',
'\tNone',
].join('\n'),
},
])('share invitations: $label', async ({ invitations, expectedOutput }) => {
const mock = mockShareServiceForFolderSharing({});
for (const invitation of invitations) {
mock.addInvitation(invitation);
}
await ShareService.instance().refreshShareInvitations();
const { command, output } = setUpCommand();
await command.action({
'command': 'list',
options: {},
});
expect(output.join('\n')).toBe(expectedOutput);
});
});

View File

@@ -0,0 +1,297 @@
import { _ } from '@joplin/lib/locale';
import BaseCommand from './base-command';
import app from './app';
import { reg } from '@joplin/lib/registry';
import Logger from '@joplin/utils/Logger';
import ShareService from '@joplin/lib/services/share/ShareService';
import { ModelType } from '@joplin/lib/BaseModel';
import { FolderEntity } from '@joplin/lib/services/database/types';
import { ShareUserStatus } from '@joplin/lib/services/share/reducer';
import Folder from '@joplin/lib/models/Folder';
import invitationRespond from '@joplin/lib/services/share/invitationRespond';
import CommandService from '@joplin/lib/services/CommandService';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
const logger = Logger.create('command-share');
type Args = {
command: string;
// eslint-disable-next-line id-denylist -- The "notebook" identifier comes from the UI.
notebook?: string;
user?: string;
options: {
'read-only'?: boolean;
json?: boolean;
force?: boolean;
};
};
const folderTitle = (folder: FolderEntity|null) => {
return folder ? substrWithEllipsis(folder.title, 0, 32) : _('[None]');
};
const getShareState = () => app().store().getState().shareService;
const getShareFromFolderId = (folderId: string) => {
const shareState = getShareState();
const allShares = shareState.shares;
const share = allShares.find(share => share.folder_id === folderId);
return share;
};
const getShareUsers = (folderId: string) => {
const share = getShareFromFolderId(folderId);
if (!share) {
throw new Error(`No share found for folder ${folderId}`);
}
return getShareState().shareUsers[share.id];
};
class Command extends BaseCommand {
public usage() {
return 'share <command> [notebook] [user]';
}
public description() {
return [
_('Shares or unshares the specified [notebook] with [user]. Requires Joplin Cloud or Joplin Server.'),
_('Commands: `add`, `remove`, `list`, `delete`, `accept`, `leave`, and `reject`.'),
].join('\n');
}
public options() {
return [
['--read-only', _('Don\'t allow the share recipient to write to the shared notebook. Valid only for the `add` subcommand.')],
['-f, --force', _('Do not ask for user confirmation.')],
['--json', _('Prefer JSON output.')],
];
}
public async action(args: Args) {
const commandShareAdd = async (folder: FolderEntity, email: string) => {
await reg.waitForSyncFinishedThenSync();
const share = await ShareService.instance().shareFolder(folder.id);
const permissions = {
can_read: 1,
can_write: args.options['read-only'] ? 0 : 1,
};
logger.debug('Sharing folder', folder.id, 'with', email, 'permissions=', permissions);
await ShareService.instance().addShareRecipient(share.id, share.master_key_id, email, permissions);
await ShareService.instance().refreshShares();
await ShareService.instance().refreshShareUsers(share.id);
await reg.waitForSyncFinishedThenSync();
};
const commandShareRemove = async (folder: FolderEntity, email: string) => {
await ShareService.instance().refreshShares();
const share = getShareFromFolderId(folder.id);
if (!share) {
throw new Error(`No share found for folder ${folder.id}`);
}
await ShareService.instance().refreshShareUsers(share.id);
const shareUsers = getShareUsers(folder.id);
if (!shareUsers) {
throw new Error(`No share found for folder ${folder.id}`);
}
const targetUser = shareUsers.find(user => user.user?.email === email);
if (!targetUser) {
throw new Error(`No recipient found with email ${email}`);
}
await ShareService.instance().deleteShareRecipient(targetUser.id);
this.stdout(_('Removed %s from share.', targetUser.user.email));
};
const commandShareList = async () => {
let folder = null;
if (args.notebook) {
folder = await app().loadItemOrFail(ModelType.Folder, args.notebook);
}
await ShareService.instance().maintenance();
if (folder) {
const share = getShareFromFolderId(folder.id);
await ShareService.instance().refreshShareUsers(share.id);
const shareUsers = getShareUsers(folder.id);
const output = {
folderTitle: folderTitle(folder),
sharedWith: (shareUsers ?? []).map(user => ({
email: user.user.email,
readOnly: user.can_read && !user.can_write,
})),
};
if (args.options.json) {
this.stdout(JSON.stringify(output));
} else {
this.stdout(_('Folder "%s" is shared with:', output.folderTitle));
for (const user of output.sharedWith) {
this.stdout(`\t${user.email}\t${user.readOnly ? _('(Read-only)') : ''}`);
}
}
} else {
const shareState = getShareState();
const output = {
invitations: shareState.shareInvitations.map(invitation => ({
accepted: invitation.status === ShareUserStatus.Accepted,
waiting: invitation.status === ShareUserStatus.Waiting,
rejected: invitation.status === ShareUserStatus.Rejected,
folderId: invitation.share.folder_id,
fromUser: {
email: invitation.share.user?.email,
},
})),
shares: shareState.shares.map(share => ({
isFolder: !!share.folder_id,
isNote: !!share.note_id,
itemId: share.folder_id ?? share.note_id,
fromUser: {
email: share.user?.email,
},
})),
};
if (args.options.json) {
this.stdout(JSON.stringify(output));
} else {
this.stdout(_('Incoming shares:'));
let loggedInvitation = false;
for (const invitation of output.invitations) {
let message;
if (invitation.waiting) {
message = _('Waiting: Notebook %s from %s', invitation.folderId, invitation.fromUser.email);
}
if (invitation.accepted) {
const folder = await Folder.load(invitation.folderId);
message = _('Accepted: Notebook %s from %s', folderTitle(folder), invitation.fromUser.email);
}
if (message) {
this.stdout(`\t${message}`);
loggedInvitation = true;
}
}
if (!loggedInvitation) {
this.stdout(`\t${_('None')}`);
}
this.stdout(_('All shared folders:'));
if (output.shares.length) {
for (const share of output.shares) {
let title;
if (share.isFolder) {
title = folderTitle(await Folder.load(share.itemId));
} else {
title = share.itemId;
}
if (share.fromUser?.email) {
this.stdout(`\t${_('%s from %s', title, share.fromUser?.email)}`);
} else {
this.stdout(`\t${title} - ${share.itemId}`);
}
}
} else {
this.stdout(`\t${_('None')}`);
}
}
}
};
const commandShareAcceptOrReject = async (folderId: string, accept: boolean) => {
await ShareService.instance().maintenance();
const shareState = getShareState();
const invitations = shareState.shareInvitations.filter(invitation => {
return invitation.share.folder_id === folderId && invitation.status === ShareUserStatus.Waiting;
});
if (invitations.length === 0) throw new Error('No such invitation found');
// If there are multiple invitations for the same folder, stop early to avoid
// accepting the wrong invitation.
if (invitations.length > 1) throw new Error('Multiple invitations found with the same ID');
const invitation = invitations[0];
this.stdout(accept ? _('Accepting share...') : _('Rejecting share...'));
await invitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, accept);
};
const commandShareAccept = (folderId: string) => (
commandShareAcceptOrReject(folderId, true)
);
const commandShareReject = (folderId: string) => (
commandShareAcceptOrReject(folderId, false)
);
const commandShareDelete = async (folder: FolderEntity) => {
const force = args.options.force;
const ok = force ? true : await this.prompt(
_('Unshare notebook "%s"? This may cause other users to lose access to the notebook.', folderTitle(folder)),
{ booleanAnswerDefault: 'n' },
);
if (!ok) return;
logger.info('Unsharing folder', folder.id);
await ShareService.instance().unshareFolder(folder.id);
await reg.scheduleSync();
};
if (args.command === 'add' || args.command === 'remove' || args.command === 'delete') {
if (!args.notebook) throw new Error('[notebook] is required');
const folder = await app().loadItemOrFail(ModelType.Folder, args.notebook);
if (args.command === 'delete') {
return commandShareDelete(folder);
} else {
if (!args.user) throw new Error('[user] is required');
const email = args.user;
if (args.command === 'add') {
return commandShareAdd(folder, email);
} else if (args.command === 'remove') {
return commandShareRemove(folder, email);
}
}
}
if (args.command === 'leave') {
const folder = args.notebook ? await app().loadItemOrFail(ModelType.Folder, args.notebook) : null;
await ShareService.instance().maintenance();
return CommandService.instance().execute(
'leaveSharedFolder', folder?.id, { force: args.options.force },
);
}
if (args.command === 'list') {
return commandShareList();
}
if (args.command === 'accept') {
return commandShareAccept(args.notebook);
}
if (args.command === 'reject') {
return commandShareReject(args.notebook);
}
throw new Error(`Unknown subcommand: ${args.command}`);
}
}
module.exports = Command;

View File

@@ -22,7 +22,7 @@ const Setting = require('@joplin/lib/models/Setting').default;
const Revision = require('@joplin/lib/models/Revision').default;
const Logger = require('@joplin/utils/Logger').default;
const FsDriverNode = require('@joplin/lib/fs-driver-node').default;
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const shimInitCli = require('./utils/shimInitCli').default;
const shim = require('@joplin/lib/shim').default;
const { _ } = require('@joplin/lib/locale');
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
@@ -73,7 +73,7 @@ function appVersion() {
return p.version;
}
shimInit({ sharp, keytar, appVersion, nodeSqlite });
shimInitCli({ sharp, keytar, appVersion, nodeSqlite });
const logger = new Logger();
Logger.initializeGlobalLogger(logger);

View File

@@ -0,0 +1,14 @@
import CommandService from '@joplin/lib/services/CommandService';
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
import libCommands from '@joplin/lib/commands/index';
import { State } from '@joplin/lib/reducer';
import { Store } from 'redux';
export default function initializeCommandService(store: Store<State>, devMode: boolean) {
CommandService.instance().initialize(store, devMode, stateToWhenClauseContext);
for (const command of libCommands) {
CommandService.instance().registerDeclaration(command.declaration);
CommandService.instance().registerRuntime(command.declaration.name, command.runtime());
}
}

View File

@@ -0,0 +1,32 @@
import shim, { ShowMessageBoxOptions } from '@joplin/lib/shim';
import type { ShimInitOptions } from '@joplin/lib/shim-init-node';
import app from '../app';
import { _ } from '@joplin/lib/locale';
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const shimInitCli = (options: ShimInitOptions) => {
shimInit(options);
shim.showMessageBox = async (message: string, options: ShowMessageBoxOptions) => {
const gui = app()?.gui();
let answers = options.buttons ?? [_('Ok'), _('Cancel')];
if (options.type === 'error' || options.type === 'info') {
answers = [];
}
message += answers.length ? `(${answers.join(', ')})` : '';
const answer = await gui.prompt(options.title ?? '', `${message} `, { answers });
if (answers.includes(answer)) {
return answers.indexOf(answer);
} else if (answer) {
return answers.findIndex(a => a.startsWith(answer));
}
return -1;
};
};
export default shimInitCli;

View File

@@ -15,4 +15,7 @@ export const setupApplication = async () => {
// such notebook.
await Folder.save({ title: 'default' });
await app().refreshCurrentFolder();
// Some tests also need access to the Redux store
app().initRedux();
};

View File

@@ -55,6 +55,7 @@
"node-rsa": "1.1.1",
"open": "8.4.2",
"proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1",
"sharp": "0.33.5",
"sprintf-js": "1.1.3",

View File

@@ -7,7 +7,6 @@ import * as editAlarm from './editAlarm';
import * as exportPdf from './exportPdf';
import * as gotoAnything from './gotoAnything';
import * as hideModalMessage from './hideModalMessage';
import * as leaveSharedFolder from './leaveSharedFolder';
import * as linkToNote from './linkToNote';
import * as moveToFolder from './moveToFolder';
import * as newFolder from './newFolder';
@@ -56,7 +55,6 @@ const index: any[] = [
exportPdf,
gotoAnything,
hideModalMessage,
leaveSharedFolder,
linkToNote,
moveToFolder,
newFolder,

View File

@@ -2,6 +2,7 @@
import * as deleteNote from './deleteNote';
import * as historyBackward from './historyBackward';
import * as historyForward from './historyForward';
import * as leaveSharedFolder from './leaveSharedFolder';
import * as openMasterPasswordDialog from './openMasterPasswordDialog';
import * as permanentlyDeleteNote from './permanentlyDeleteNote';
import * as renderMarkup from './renderMarkup';
@@ -14,6 +15,7 @@ const index: any[] = [
deleteNote,
historyBackward,
historyForward,
leaveSharedFolder,
openMasterPasswordDialog,
permanentlyDeleteNote,
renderMarkup,

View File

@@ -1,8 +1,8 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import ShareService from '@joplin/lib/services/share/ShareService';
import { CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService';
import { _ } from '../locale';
import ShareService from '../services/share/ShareService';
import Logger from '@joplin/utils/Logger';
import shim from '@joplin/lib/shim';
import shim from '../shim';
const logger = Logger.create('leaveSharedFolder');
@@ -11,10 +11,16 @@ export const declaration: CommandDeclaration = {
label: () => _('Leave notebook...'),
};
interface Options {
force?: boolean;
}
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, folderId: string = null) => {
const answer = await shim.showConfirmationDialog(_('This will remove the notebook from your collection and you will no longer have access to its content. Do you wish to continue?'));
execute: async (_context: CommandContext, folderId: string = null, { force = false }: Options = {}) => {
const answer = force ? true : await shim.showConfirmationDialog(
_('This will remove the notebook from your collection and you will no longer have access to its content. Do you wish to continue?'),
);
if (!answer) return;
try {

View File

@@ -98,7 +98,7 @@ function setupProxySettings(options: any) {
proxySettings.proxyUrl = options.proxyUrl;
}
interface ShimInitOptions {
export interface ShimInitOptions {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
sharp: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@@ -3,20 +3,33 @@ import reducer, { State, defaultState } from '../../reducer';
import ShareService from '../../services/share/ShareService';
import { encryptionService } from '../test-utils';
import JoplinServerApi, { ExecOptions } from '../../JoplinServerApi';
import { ShareInvitation, StateShare } from '../../services/share/reducer';
import { ShareInvitation, StateShare, StateShareUser } from '../../services/share/reducer';
const testReducer = (state = defaultState, action: unknown) => {
return reducer(state, action);
};
type Query = Record<string, unknown>;
type OnShareGetListener = (query: Query)=> Promise<{ items: Partial<StateShare>[] }>;
type OnSharePostListener = (query: Query)=> Promise<{ id: string }>;
type OnInvitationGetListener = (query: Query)=> Promise<{ items: Partial<ShareInvitation>[] }>;
interface ShareStateResponse {
items: Partial<StateShare>[];
}
interface ShareInvitationResponse {
items: Partial<ShareInvitation>[];
}
interface ShareUsersResponse {
items: Partial<StateShareUser>[];
}
type Json = Record<string, unknown>;
type OnShareGetListener = (query: Json)=> Promise<ShareStateResponse>;
type OnSharePostListener = (query: Json)=> Promise<{ id: string }>;
type OnInvitationGetListener = (query: Json)=> Promise<ShareInvitationResponse>;
type OnShareUsersGetListener = (shareId: string)=> Promise<ShareUsersResponse>;
type OnShareUsersPostListener = (shareId: string, body: Json)=> Promise<void>;
type OnApiExecListener = (
method: string,
path: string,
query: Query,
query: Json,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needs to interface with old code from before the rule was applied
body: any,
headers: Record<string, unknown>,
@@ -27,6 +40,8 @@ export type ApiMock = {
getShares: OnShareGetListener;
postShares: OnSharePostListener;
getShareInvitations: OnInvitationGetListener;
getShareUsers?: OnShareUsersGetListener;
postShareUsers?: OnShareUsersPostListener;
onUnhandled?: OnApiExecListener;
onExec?: undefined;
@@ -37,6 +52,8 @@ export type ApiMock = {
getShareInvitations?: undefined;
getShares?: undefined;
postShares?: undefined;
getShareUsers?: undefined;
postShareUsers?: undefined;
};
// Initializes a share service with mocks
@@ -57,6 +74,16 @@ const mockShareService = (apiCallHandler: ApiMock, service?: ShareService, store
return apiCallHandler.getShareInvitations(query);
}
const shareUsersMatch = path.match(/^api\/shares\/([^/]+)\/users$/);
const shareId = shareUsersMatch?.[1];
if (shareId) {
if (method === 'GET' && apiCallHandler.getShareUsers) {
return apiCallHandler.getShareUsers(shareId);
}
if (method === 'POST' && apiCallHandler.postShareUsers) {
return apiCallHandler.postShareUsers(shareId, body);
}
}
if (apiCallHandler.onUnhandled) {
return apiCallHandler.onUnhandled(method, path, query, body, headers, options);

View File

@@ -32561,6 +32561,7 @@ __metadata:
node-rsa: "npm:1.1.1"
open: "npm:8.4.2"
proper-lockfile: "npm:4.1.2"
redux: "npm:4.2.1"
server-destroy: "npm:1.0.1"
sharp: "npm:0.33.5"
sprintf-js: "npm:1.1.3"