1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-11 18:24:43 +02:00

Desktop: Allows a shared notebook recipient to leave the notebook

This commit is contained in:
Laurent Cozic 2021-10-13 18:02:54 +01:00
parent 1f005656a2
commit 73545484c9
11 changed files with 90 additions and 14 deletions

View File

@ -261,6 +261,9 @@ packages/app-desktop/gui/MainScreen/commands/hideModalMessage.js.map
packages/app-desktop/gui/MainScreen/commands/index.d.ts packages/app-desktop/gui/MainScreen/commands/index.d.ts
packages/app-desktop/gui/MainScreen/commands/index.js packages/app-desktop/gui/MainScreen/commands/index.js
packages/app-desktop/gui/MainScreen/commands/index.js.map packages/app-desktop/gui/MainScreen/commands/index.js.map
packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.d.ts
packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js
packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js.map
packages/app-desktop/gui/MainScreen/commands/moveToFolder.d.ts packages/app-desktop/gui/MainScreen/commands/moveToFolder.d.ts
packages/app-desktop/gui/MainScreen/commands/moveToFolder.js packages/app-desktop/gui/MainScreen/commands/moveToFolder.js
packages/app-desktop/gui/MainScreen/commands/moveToFolder.js.map packages/app-desktop/gui/MainScreen/commands/moveToFolder.js.map

3
.gitignore vendored
View File

@ -244,6 +244,9 @@ packages/app-desktop/gui/MainScreen/commands/hideModalMessage.js.map
packages/app-desktop/gui/MainScreen/commands/index.d.ts packages/app-desktop/gui/MainScreen/commands/index.d.ts
packages/app-desktop/gui/MainScreen/commands/index.js packages/app-desktop/gui/MainScreen/commands/index.js
packages/app-desktop/gui/MainScreen/commands/index.js.map packages/app-desktop/gui/MainScreen/commands/index.js.map
packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.d.ts
packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js
packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js.map
packages/app-desktop/gui/MainScreen/commands/moveToFolder.d.ts packages/app-desktop/gui/MainScreen/commands/moveToFolder.d.ts
packages/app-desktop/gui/MainScreen/commands/moveToFolder.js packages/app-desktop/gui/MainScreen/commands/moveToFolder.js
packages/app-desktop/gui/MainScreen/commands/moveToFolder.js.map packages/app-desktop/gui/MainScreen/commands/moveToFolder.js.map

View File

@ -4,6 +4,7 @@ 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 moveToFolder from './moveToFolder'; import * as moveToFolder from './moveToFolder';
import * as newFolder from './newFolder'; import * as newFolder from './newFolder';
import * as newNote from './newNote'; import * as newNote from './newNote';
@ -36,6 +37,7 @@ const index:any[] = [
exportPdf, exportPdf,
gotoAnything, gotoAnything,
hideModalMessage, hideModalMessage,
leaveSharedFolder,
moveToFolder, moveToFolder,
newFolder, newFolder,
newNote, newNote,

View File

@ -0,0 +1,28 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import Folder from '@joplin/lib/models/Folder';
export const declaration: CommandDeclaration = {
name: 'leaveSharedFolder',
label: () => _('Leave notebook...'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, folderId: string = null) => {
const answer = confirm(_('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;
// In that case, we should only delete the folder but none of its
// children. Deleting the folder tells the server that we want to
// leave the share. The server will then proceed to delete all
// associated user_items. So eventually all the notebook content
// will also be deleted for the current user.
//
// We don't delete the children here because that would delete them
// for the other share participants too.
await Folder.delete(folderId, { deleteChildren: false });
},
enabledCondition: 'joplinServerConnected && folderIsShareRootAndNotOwnedByUser',
};
};

View File

@ -314,10 +314,16 @@ class SidebarComponent extends React.Component<Props, State> {
// that are within a shared notebook. If user wants to do this, // that are within a shared notebook. If user wants to do this,
// they'd have to move the notebook out of the shared notebook // they'd have to move the notebook out of the shared notebook
// first. // first.
if (CommandService.instance().isEnabled('showShareFolderDialog', stateToWhenClauseContext(state, { commandFolderId: itemId }))) { const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });
if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId))); menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
} }
if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId)));
}
menu.append( menu.append(
new MenuItem({ new MenuItem({
label: _('Export'), label: _('Export'),

View File

@ -47,6 +47,7 @@ export default function() {
'toggleSafeMode', 'toggleSafeMode',
'showShareNoteDialog', 'showShareNoteDialog',
'showShareFolderDialog', 'showShareFolderDialog',
'leaveSharedFolder',
'gotoAnything', 'gotoAnything',
'commandPalette', 'commandPalette',
'openMasterPasswordDialog', 'openMasterPasswordDialog',

View File

@ -8,6 +8,10 @@
# ./runForTesting.sh 1 createUsers,createData,reset,e2ee,sync && ./runForTesting.sh 2 reset,e2ee,sync && ./runForTesting.sh 1 # ./runForTesting.sh 1 createUsers,createData,reset,e2ee,sync && ./runForTesting.sh 2 reset,e2ee,sync && ./runForTesting.sh 1
# Without E2EE:
# ./runForTesting.sh 1 createUsers,createData,reset,sync && ./runForTesting.sh 2 reset,sync && ./runForTesting.sh 1
set -e set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
@ -63,7 +67,7 @@ do
elif [[ $CMD == "sync" ]]; then elif [[ $CMD == "sync" ]]; then
echo "sync" >> "$CMD_FILE" echo "sync --use-lock 0" >> "$CMD_FILE"
# elif [[ $CMD == "generatePpk" ]]; then # elif [[ $CMD == "generatePpk" ]]; then

View File

@ -16,6 +16,16 @@ interface FolderEntityWithChildren extends FolderEntity {
children?: FolderEntity[]; children?: FolderEntity[];
} }
export interface DeleteOptions {
deleteChildren?: boolean;
}
const defaultDeleteOptions = (): DeleteOptions => {
return {
deleteChildren: true,
};
};
export default class Folder extends BaseItem { export default class Folder extends BaseItem {
static tableName() { static tableName() {
return 'folders'; return 'folders';
@ -78,9 +88,9 @@ export default class Folder extends BaseItem {
return this.db().exec(query); return this.db().exec(query);
} }
static async delete(folderId: string, options: any = null) { public static async delete(folderId: string, options: DeleteOptions = null) {
options = { options = {
deleteChildren: true, ...defaultDeleteOptions(),
...options, ...options,
}; };

View File

@ -25,8 +25,10 @@ export interface WhenClauseContext {
noteTodoCompleted: boolean; noteTodoCompleted: boolean;
noteIsMarkdown: boolean; noteIsMarkdown: boolean;
noteIsHtml: boolean; noteIsHtml: boolean;
folderIsShareRootAndNotOwnedByUser: boolean;
folderIsShareRootAndOwnedByUser: boolean; folderIsShareRootAndOwnedByUser: boolean;
folderIsShared: boolean; folderIsShared: boolean;
folderIsShareRoot: boolean;
joplinServerConnected: boolean; joplinServerConnected: boolean;
} }
@ -74,6 +76,8 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
noteIsHtml: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false, noteIsHtml: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false,
// Current context folder // Current context folder
folderIsShareRoot: commandFolder ? isRootSharedFolder(commandFolder) : false,
folderIsShareRootAndNotOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && !isSharedFolderOwner(state, commandFolder.id) : false,
folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false, folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false,
folderIsShared: commandFolder ? !!commandFolder.share_id : false, folderIsShared: commandFolder ? !!commandFolder.share_id : false,

View File

@ -74,6 +74,8 @@ export default class ShareService {
return share; return share;
} }
// This allows the notebook owner to stop sharing it. For a recipient to
// leave the shared notebook, see the leaveSharedFolder command.
public async unshareFolder(folderId: string) { public async unshareFolder(folderId: string) {
const folder = await Folder.load(folderId); const folder = await Folder.load(folderId);
if (!folder) throw new Error(`No such folder: ${folderId}`); if (!folder) throw new Error(`No such folder: ${folderId}`);

View File

@ -1,16 +1,33 @@
# Joplin Server sharing feature # Joplin Server sharing feature
## Sharing a file via a public URL ## Sharing a notebook with a user
Joplin Server is essentially a file hosting service and it allows sharing files via public URLs. To do so, an API call is made to `/api/shares` with the ID or path of the file that needs to be shared. This call returns a SHAREID that is then used to access the file via URL. When viewing the file, it will display it according to its mime type. Thus by default a Markdown file will be displayed as plain text. Sharing a notebook is done via synchronisation using the following API objects:
## Sharing a note via a public URL - `item`: any Joplin item such as a note or notebook.
- `user_item`: owned by a user and points to an item. Multiple user_items can point to the same item, which is important to enable sharing.
- `share`: associated with a notebook ID, it specifies which notebook should be shared and by whom
- `share_user`: associated with share and a user. This is essentially an invitation that the sharer sent to recipients. There can be multiple such objects, and they can be accepted or rejected by the recipient.
It is built on top of the file sharing feature. The file corresponding to the note is shared via the above API. Then a separate application, specific to Joplin, read and parse the Markdown file, and display it as note. The process to share is then:
That application works as a viewer - instead of displaying the Markdown file as plain text (by default), it renders it and displays it as HTML. - First, the sharer calls `POST /api/shares` with the notebook ID that needs to be shared.
- Then invitations can be sent by calling `POST /api/share_users` and providing the share ID and recipient email.
- The recipient accept or reject the application by setting the status onn the `share_users` object (which corresponds to an invitation).
The rendering engine is the same as the main applications, which allows us to use the same plugins and settings. Once share is setup, the client recursively goes through all notes, sub-notebooks and resources within the shared notebook, and set their `share_id` property. Basically any item within the notebook should have this property set. Then all these items are synchronized.
On the server, a service is running at regular interval to check the `share_id` property, and generate `user_item` objects for each recipient. Once these objects have been created, the recipient will start receiving the shared notebooks and notes.
### Why is the share_id set on the client and not the server?
Technically, the server would only need to know the root shared folder, and from that can be find out its children. This approach was tried but it makes the system much more complex because some information is lost after sync - in particular when notes or notebooks are moved out of folders, when resources are attached or removed, etc. Keeping track of all this is possible but complex and innefficient.
On the other hand, all that information is present on the client. Whenever a notes is moved out a shared folder, or whenever a resources is attached, the changes are tracked, and that can be used to easily assign a `share_id` property. Once this is set, it makes the whole system more simple and reliable.
## Publishing a note via a public URL
This is done by posting a note ID to `/api/shares`.
### Attached resources ### Attached resources
@ -29,7 +46,3 @@ Any linked note will **not** be shared, due to the following reasons:
It should be possible to have multiple share links for a given note. For example: I share a note with one person, then the same note with a different person. I revoke the share for one person, but I sill want the other person to access the note. It should be possible to have multiple share links for a given note. For example: I share a note with one person, then the same note with a different person. I revoke the share for one person, but I sill want the other person to access the note.
So when a share link is created for a note, the API always return a new link. So when a share link is created for a note, the API always return a new link.
## Sharing a note with a user
TBD