feat: share a single note

Signed-off-by: Luka Trovic <luka@nextcloud.com>
This commit is contained in:
Luka Trovic 2023-11-08 09:15:51 +01:00 committed by Julius Härtl
parent e851a336ed
commit a7a15f6fc7
No known key found for this signature in database
GPG Key ID: 4C614C6ED2CDE6DF
9 changed files with 230 additions and 4 deletions

View File

@ -9,6 +9,10 @@ use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\EventDispatcher\GenericEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Share\Events\BeforeShareCreatedEvent;
use OCP\Share\IShare;
class Application extends App implements IBootstrap {
public const APP_ID = 'notes';
@ -26,6 +30,20 @@ class Application extends App implements IBootstrap {
BeforeTemplateRenderedEvent::class,
BeforeTemplateRenderedListener::class
);
if (\OCA\OpenProject\Service\class_exists(BeforeShareCreatedEvent::class)) {
$context->registerEventListener(
BeforeShareCreatedEvent::class,
BeforeShareCreatedListener::class
);
}
// FIXME: Remove once Nextcloud 28 is the minimum supported version
\OCP\Server::get(IEventDispatcher::class)->addListener('OCP\Share::preShare', function (GenericEvent $event) {
/** @var IShare $share */
$share = $event->getSubject();
$modernListener = \OCP\Server::get(BeforeShareCreatedListener::class);
$modernListener->overwriteShareTarget($share);
}, 1000);
}
public function boot(IBootContext $context): void {

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace OCA\Notes\AppInfo;
use OCA\Notes\Service\NoteUtil;
use OCA\Notes\Service\SettingsService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\File;
use OCP\Share\Events\BeforeShareCreatedEvent;
use OCP\Share\IShare;
class BeforeShareCreatedListener implements IEventListener {
private SettingsService $settings;
private NoteUtil $noteUtil;
public function __construct(SettingsService $settings, NoteUtil $noteUtil) {
$this->settings = $settings;
$this->noteUtil = $noteUtil;
}
public function handle(Event $event): void {
if (!($event instanceof BeforeShareCreatedEvent)) {
return;
}
$this->overwriteShareTarget($event->getShare());
}
public function overwriteShareTarget(IShare $share): void {
$itemType = $share->getNode() instanceof File ? 'file' : 'folder';
$fileSourcePath = $share->getNode()->getPath();
$itemTarget = $share->getTarget();
$uidOwner = $share->getSharedBy();
$ownerPath = $this->noteUtil->getRoot()->getUserFolder($uidOwner)->getPath();
$ownerNotesPath = $ownerPath . '/' . $this->settings->get($uidOwner, 'notesPath');
$receiver = $share->getSharedWith();
$receiverPath = $this->noteUtil->getRoot()->getUserFolder($receiver)->getPath();
$receiverNotesInternalPath = $this->settings->get($receiver, 'notesPath');
$receiverNotesPath = $receiverPath . '/' . $receiverNotesInternalPath;
$this->noteUtil->getOrCreateFolder($receiverNotesPath);
if ($itemType !== 'file' || strpos($fileSourcePath, $ownerNotesPath) !== 0) {
return;
}
$share->setTarget('/' . $receiverNotesInternalPath . $itemTarget);
}
}

View File

@ -4,11 +4,13 @@ declare(strict_types=1);
namespace OCA\Notes\Controller;
use OCA\Files\Event\LoadSidebar;
use OCA\Notes\AppInfo\Application;
use OCA\Notes\Service\NotesService;
use OCA\Notes\Service\SettingsService;
use OCA\Text\Event\LoadEditor;
use OCA\Viewer\Event\LoadViewer;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\ContentSecurityPolicy;
@ -66,6 +68,14 @@ class PageController extends Controller {
$this->eventDispatcher->dispatchTyped(new LoadEditor());
}
if (class_exists(LoadSidebar::class)) {
$this->eventDispatcher->dispatchTyped(new LoadSidebar());
}
if (\OCP\Server::get(IAppManager::class)->isEnabledForUser('viewer') && class_exists(LoadViewer::class)) {
$this->eventDispatcher->dispatchTyped(new LoadViewer());
}
$this->initialState->provideInitialState(
'config',
(array)\OCP\Server::get(SettingsService::class)->getPublic($this->userSession->getUser()->getUID())

View File

@ -105,6 +105,9 @@ class Note {
if (!in_array('readonly', $exclude)) {
$data['readonly'] = $this->getReadOnly();
}
$data['internalPath'] = $this->noteUtil->getPathForUser($this->file);
$data['shareTypes'] = $this->noteUtil->getShareTypes($this->file);
$data['isShared'] = (bool) count($data['shareTypes']);
$data['error'] = false;
$data['errorType'] = '';
if (!in_array('content', $exclude)) {

View File

@ -4,10 +4,14 @@ declare(strict_types=1);
namespace OCA\Notes\Service;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\IDBConnection;
use OCP\IUserSession;
use OCP\Share\IManager;
use OCP\Share\IShare;
class NoteUtil {
private const MAX_TITLE_LENGTH = 100;
@ -15,23 +19,34 @@ class NoteUtil {
private IDBConnection $db;
private IRootFolder $root;
private TagService $tagService;
private IManager $shareManager;
private IUserSession $userSession;
public function __construct(
Util $util,
IRootFolder $root,
IDBConnection $db,
TagService $tagService
TagService $tagService,
IManager $shareManager,
IUserSession $userSession
) {
$this->util = $util;
$this->root = $root;
$this->db = $db;
$this->tagService = $tagService;
$this->shareManager = $shareManager;
$this->userSession = $userSession;
}
public function getRoot() : IRootFolder {
return $this->root;
}
public function getPathForUser(File $file) {
$userFolder = $this->root->getUserFolder($this->userSession->getUser()->getUID());
return $userFolder->getRelativePath($file->getPath());
}
public function getTagService() : TagService {
return $this->tagService;
}
@ -203,4 +218,29 @@ class NoteUtil {
throw new NoteNotWritableException();
}
}
public function getShareTypes(File $file): array {
$userId = $file->getOwner()->getUID();
$requestedShareTypes = [
IShare::TYPE_USER,
IShare::TYPE_GROUP,
IShare::TYPE_LINK,
IShare::TYPE_REMOTE,
IShare::TYPE_EMAIL,
IShare::TYPE_ROOM,
IShare::TYPE_DECK,
IShare::TYPE_SCIENCEMESH,
];
$shareTypes = [];
foreach ($requestedShareTypes as $shareType) {
$shares = $this->shareManager->getSharesBy($userId, $shareType, $file, false, 1, 0);
if (count($shares)) {
$shareTypes[] = $shareType;
}
}
return $shareTypes;
}
}

View File

@ -150,6 +150,7 @@ export const refreshNote = (noteId, lastETag) => {
return response.headers.etag
}
const currentContent = store.getters.getNote(noteId).content
store.commit('setNoteAttribute', { noteId, attribute: 'internalPath', value: response.data.internalPath })
// only update if local content has not changed
if (oldContent === currentContent) {
_updateLocalNote(response.data)
@ -290,6 +291,7 @@ export const autotitleNote = noteId => {
.put(url('/notes/' + noteId + '/autotitle'))
.then((response) => {
store.commit('setNoteAttribute', { noteId, attribute: 'title', value: response.data })
refreshNote(noteId)
})
.catch(err => {
console.error(err)

View File

@ -26,11 +26,21 @@
fill-color="var(--color-text-lighter)"
/>
</template>
<template v-if="isShared" #indicator>
<ShareVariantIcon :size="16" fill-color="#0082c9" />
</template>
<template #actions>
<NcActionButton :icon="actionFavoriteIcon" @click="onToggleFavorite">
{{ actionFavoriteText }}
</NcActionButton>
<NcActionButton @click="onToggleSharing">
<template #icon>
<ShareVariantIcon :size="20" />
</template>
{{ t('notes', 'Share') }}
</NcActionButton>
<NcActionButton v-if="!showCategorySelect" @click="showCategorySelect = true">
<template #icon>
<FolderIcon :size="20" />
@ -90,6 +100,8 @@ import StarIcon from 'vue-material-design-icons/Star.vue'
import { categoryLabel, routeIsNewNote } from '../Util.js'
import { showError } from '@nextcloud/dialogs'
import { setFavorite, setTitle, fetchNote, deleteNote, setCategory } from '../NotesService.js'
import ShareVariantIcon from 'vue-material-design-icons/ShareVariant.vue'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
export default {
name: 'NoteItem',
@ -104,6 +116,7 @@ export default {
NcActionSeparator,
NcActionInput,
PencilIcon,
ShareVariantIcon,
},
props: {
@ -122,6 +135,7 @@ export default {
newTitle: '',
renaming: false,
showCategorySelect: false,
isShareCreated: false,
}
},
@ -129,6 +143,9 @@ export default {
isSelected() {
return this.$store.getters.getSelectedNote() === this.note.id
},
isShared() {
return this.note.isShared || this.isShareCreated
},
title() {
return this.note.title + (this.note.unsaved ? ' *' : '')
@ -167,6 +184,15 @@ export default {
]
},
},
mounted() {
subscribe('files_sharing:share:created', this.onShareCreated)
},
destroyed() {
unsubscribe('files_sharing:share:created', this.onShareCreated)
},
methods: {
onMenuChange(state) {
this.actionsOpen = state
@ -251,6 +277,23 @@ export default {
this.actionsOpen = false
}
},
onToggleSharing() {
if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
emit('toggle-navigation', { open: false })
setTimeout(() => {
window.dispatchEvent(new Event('resize'))
}, 200)
window.OCA.Files.Sidebar.setActiveTab('sharing')
window.OCA.Files.Sidebar.open(this.note.internalPath)
}
},
async onShareCreated(event) {
const { share } = event
if (share.fileSource === this.note.id) {
this.isShareCreated = true
}
},
},
}
</script>

View File

@ -96,7 +96,7 @@ import {
isMobile,
} from '@nextcloud/vue'
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import EditIcon from 'vue-material-design-icons/LeadPencil.vue'
import EyeIcon from 'vue-material-design-icons/Eye.vue'
@ -193,6 +193,8 @@ export default {
document.addEventListener('fullscreenchange', this.onDetectFullscreen)
document.addEventListener('keydown', this.onKeyPress)
document.addEventListener('visibilitychange', this.onVisibilityChange)
subscribe('files_versions:restore:requested', this.onFileRestoreRequested)
subscribe('files_versions:restore:restored', this.onFileRestored)
},
destroyed() {
@ -203,6 +205,8 @@ export default {
document.removeEventListener('keydown', this.onKeyPress)
document.removeEventListener('visibilitychange', this.onVisibilityChange)
this.onUpdateTitle(null)
unsubscribe('files_versions:restore:requested', this.onFileRestoreRequested)
unsubscribe('files_versions:restore:restored', this.onFileRestored)
},
methods: {
@ -392,6 +396,25 @@ export default {
conflictSolutionRemote(this.note)
this.showConflict = false
},
async onFileRestoreRequested(event) {
const { fileInfo } = event
if (fileInfo.id !== this.note.id) {
return
}
this.loading = true
},
async onFileRestored(version) {
if (version.fileId !== this.note.id) {
return
}
this.refreshNote()
this.loading = false
},
},
}
</script>

View File

@ -10,7 +10,7 @@ import {
} from '@nextcloud/vue'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { queueCommand } from '../NotesService.js'
import { queueCommand, refreshNote } from '../NotesService.js'
import { routeIsNewNote } from '../Util.js'
import store from '../store.js'
@ -55,12 +55,15 @@ export default {
created() {
this.fetchData()
subscribe('files:file:updated', this.fileUpdated)
subscribe('files_versions:restore:requested', this.onFileRestoreRequested)
subscribe('files_versions:restore:restored', this.onFileRestored)
},
destroyed() {
this?.editor?.destroy()
unsubscribe('files:file:updated', this.fileUpdated)
unsubscribe('files_versions:restore:requested', this.onFileRestoreRequested)
unsubscribe('files_versions:restore:restored', this.onFileRestored)
},
methods: {
@ -141,6 +144,38 @@ export default {
.replaceAll(/\s/gmu, ' ')
return title.length > 0 ? title : t('notes', 'New note')
},
async onFileRestoreRequested(event) {
const { fileInfo } = event
if (fileInfo.id !== this.note.id) {
return
}
this.loading = true
},
async onFileRestored(version) {
if (version.fileId !== this.note.id) {
return
}
const etag = await refreshNote(parseInt(this.noteId), this.etag)
if (etag) {
this.etag = etag
}
const autoResolve = setInterval(() => {
const el = document.querySelector('[data-cy="resolveServerVersion"]')
if (el) {
el.click()
clearInterval(autoResolve)
}
}, 200)
this.loading = false
},
},
}
</script>