1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-26 22:41:17 +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-rmnote.js
packages/app-cli/app/command-set.js packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.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-sync.js
packages/app-cli/app/command-testing.js packages/app-cli/app/command-testing.js
packages/app-cli/app/command-use.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/gui/StatusBarWidget.js
packages/app-cli/app/services/plugins/PluginRunner.js packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/setupCommand.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/app/utils/testUtils.js
packages/app-cli/tests/HtmlToMd.js packages/app-cli/tests/HtmlToMd.js
packages/app-cli/tests/MarkupToHtml.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/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.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/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.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/historyBackward.js
packages/lib/commands/historyForward.js packages/lib/commands/historyForward.js
packages/lib/commands/index.js packages/lib/commands/index.js
packages/lib/commands/leaveSharedFolder.js
packages/lib/commands/openMasterPasswordDialog.js packages/lib/commands/openMasterPasswordDialog.js
packages/lib/commands/permanentlyDeleteNote.js packages/lib/commands/permanentlyDeleteNote.js
packages/lib/commands/renderMarkup.test.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-rmnote.js
packages/app-cli/app/command-set.js packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.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-sync.js
packages/app-cli/app/command-testing.js packages/app-cli/app/command-testing.js
packages/app-cli/app/command-use.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/gui/StatusBarWidget.js
packages/app-cli/app/services/plugins/PluginRunner.js packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/setupCommand.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/app/utils/testUtils.js
packages/app-cli/tests/HtmlToMd.js packages/app-cli/tests/HtmlToMd.js
packages/app-cli/tests/MarkupToHtml.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/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.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/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.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/historyBackward.js
packages/lib/commands/historyForward.js packages/lib/commands/historyForward.js
packages/lib/commands/index.js packages/lib/commands/index.js
packages/lib/commands/leaveSharedFolder.js
packages/lib/commands/openMasterPasswordDialog.js packages/lib/commands/openMasterPasswordDialog.js
packages/lib/commands/permanentlyDeleteNote.js packages/lib/commands/permanentlyDeleteNote.js
packages/lib/commands/renderMarkup.test.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 BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note'; import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag'; 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 { reg } from '@joplin/lib/registry.js';
import { dirname, fileExtension } from '@joplin/lib/path-utils'; import { dirname, fileExtension } from '@joplin/lib/path-utils';
import { splitCommandString } from '@joplin/utils'; import { splitCommandString } from '@joplin/utils';
@@ -16,6 +16,7 @@ import RevisionService from '@joplin/lib/services/RevisionService';
import shim from '@joplin/lib/shim'; import shim from '@joplin/lib/shim';
import setupCommand from './setupCommand'; import setupCommand from './setupCommand';
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types'; import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
import initializeCommandService from './utils/initializeCommandService';
const { cliUtils } = require('./cli-utils.js'); const { cliUtils } = require('./cli-utils.js');
const Cache = require('@joplin/lib/Cache'); const Cache = require('@joplin/lib/Cache');
const { splitCommandBatch } = require('@joplin/lib/string-utils'); 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 // 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)[]> { public async loadItems(type: ModelType | 'folderOrNote', pattern: string, options: any = null): Promise<(FolderEntity | NoteEntity)[]> {
if (type === 'folderOrNote') { 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'); 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 // If we have some arguments left at this point, it's a command
// so execute it. // so execute it.
if (argv.length) { if (argv.length) {

View File

@@ -13,7 +13,7 @@ describe('command-done', () => {
}); });
it('should make a note as "done"', async () => { 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); const command = setupCommandForTesting(Command);

View File

@@ -26,8 +26,7 @@ class Command extends BaseCommand {
const pattern = args['notebook']; const pattern = args['notebook'];
const force = args.options && args.options.force === true; const force = args.options && args.options.force === true;
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern); const folder = await app().loadItemOrFail(BaseModel.TYPE_FOLDER, pattern);
if (!folder) throw new Error(_('Cannot find "%s".', pattern));
const permanent = args.options?.permanent === true || !!folder.deleted_time; const permanent = args.options?.permanent === true || !!folder.deleted_time;
const ellipsizedFolderTitle = substrWithEllipsis(folder.title, 0, 32); 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 Revision = require('@joplin/lib/models/Revision').default;
const Logger = require('@joplin/utils/Logger').default; const Logger = require('@joplin/utils/Logger').default;
const FsDriverNode = require('@joplin/lib/fs-driver-node').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 shim = require('@joplin/lib/shim').default;
const { _ } = require('@joplin/lib/locale'); const { _ } = require('@joplin/lib/locale');
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default; const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
@@ -73,7 +73,7 @@ function appVersion() {
return p.version; return p.version;
} }
shimInit({ sharp, keytar, appVersion, nodeSqlite }); shimInitCli({ sharp, keytar, appVersion, nodeSqlite });
const logger = new Logger(); const logger = new Logger();
Logger.initializeGlobalLogger(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. // such notebook.
await Folder.save({ title: 'default' }); await Folder.save({ title: 'default' });
await app().refreshCurrentFolder(); 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", "node-rsa": "1.1.1",
"open": "8.4.2", "open": "8.4.2",
"proper-lockfile": "4.1.2", "proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1", "server-destroy": "1.0.1",
"sharp": "0.33.5", "sharp": "0.33.5",
"sprintf-js": "1.1.3", "sprintf-js": "1.1.3",

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; import { CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService';
import { _ } from '@joplin/lib/locale'; import { _ } from '../locale';
import ShareService from '@joplin/lib/services/share/ShareService'; import ShareService from '../services/share/ShareService';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import shim from '@joplin/lib/shim'; import shim from '../shim';
const logger = Logger.create('leaveSharedFolder'); const logger = Logger.create('leaveSharedFolder');
@@ -11,10 +11,16 @@ export const declaration: CommandDeclaration = {
label: () => _('Leave notebook...'), label: () => _('Leave notebook...'),
}; };
interface Options {
force?: boolean;
}
export const runtime = (): CommandRuntime => { export const runtime = (): CommandRuntime => {
return { return {
execute: async (_context: CommandContext, folderId: string = null) => { execute: async (_context: CommandContext, folderId: string = null, { force = false }: Options = {}) => {
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?')); 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; if (!answer) return;
try { try {

View File

@@ -98,7 +98,7 @@ function setupProxySettings(options: any) {
proxySettings.proxyUrl = options.proxyUrl; proxySettings.proxyUrl = options.proxyUrl;
} }
interface ShimInitOptions { export interface ShimInitOptions {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
sharp: any; sharp: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // 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 ShareService from '../../services/share/ShareService';
import { encryptionService } from '../test-utils'; import { encryptionService } from '../test-utils';
import JoplinServerApi, { ExecOptions } from '../../JoplinServerApi'; 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) => { const testReducer = (state = defaultState, action: unknown) => {
return reducer(state, action); return reducer(state, action);
}; };
type Query = Record<string, unknown>; interface ShareStateResponse {
type OnShareGetListener = (query: Query)=> Promise<{ items: Partial<StateShare>[] }>; items: Partial<StateShare>[];
type OnSharePostListener = (query: Query)=> Promise<{ id: string }>; }
type OnInvitationGetListener = (query: Query)=> Promise<{ items: Partial<ShareInvitation>[] }>; 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 = ( type OnApiExecListener = (
method: string, method: string,
path: 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needs to interface with old code from before the rule was applied
body: any, body: any,
headers: Record<string, unknown>, headers: Record<string, unknown>,
@@ -27,6 +40,8 @@ export type ApiMock = {
getShares: OnShareGetListener; getShares: OnShareGetListener;
postShares: OnSharePostListener; postShares: OnSharePostListener;
getShareInvitations: OnInvitationGetListener; getShareInvitations: OnInvitationGetListener;
getShareUsers?: OnShareUsersGetListener;
postShareUsers?: OnShareUsersPostListener;
onUnhandled?: OnApiExecListener; onUnhandled?: OnApiExecListener;
onExec?: undefined; onExec?: undefined;
@@ -37,6 +52,8 @@ export type ApiMock = {
getShareInvitations?: undefined; getShareInvitations?: undefined;
getShares?: undefined; getShares?: undefined;
postShares?: undefined; postShares?: undefined;
getShareUsers?: undefined;
postShareUsers?: undefined;
}; };
// Initializes a share service with mocks // Initializes a share service with mocks
@@ -57,6 +74,16 @@ const mockShareService = (apiCallHandler: ApiMock, service?: ShareService, store
return apiCallHandler.getShareInvitations(query); 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) { if (apiCallHandler.onUnhandled) {
return apiCallHandler.onUnhandled(method, path, query, body, headers, options); return apiCallHandler.onUnhandled(method, path, query, body, headers, options);

View File

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