enh(references): Implement a reference provider and a front-end widget for bookmarks

Signed-off-by: Marcel Klehr <mklehr@gmx.net>
This commit is contained in:
Marcel Klehr 2023-03-12 16:58:14 +01:00
parent 4fc23b3f6d
commit 41c030ddf1
9 changed files with 501 additions and 19 deletions

View File

@ -23,6 +23,7 @@ use OCA\Bookmarks\Flow\CreateBookmark;
use OCA\Bookmarks\Hooks\BeforeTemplateRenderedListener;
use OCA\Bookmarks\Hooks\UserGroupListener;
use OCA\Bookmarks\Middleware\ExceptionMiddleware;
use OCA\Bookmarks\Reference\BookmarkReferenceProvider;
use OCA\Bookmarks\Search\Provider;
use OCA\Bookmarks\Service\TreeCacheManager;
use OCP\AppFramework\App;
@ -30,6 +31,7 @@ use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\Collaboration\Resources\IProviderManager;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Group\Events\UserAddedEvent;
@ -45,6 +47,14 @@ class Application extends App implements IBootstrap {
public function __construct() {
parent::__construct(self::APP_ID);
// TODO move this back to ::register after fixing the autoload issue
// (and use a listener class)
$container = $this->getContainer();
$eventDispatcher = $container->get(IEventDispatcher::class);
$eventDispatcher->addListener(RenderReferenceEvent::class, function () {
Util::addScript(self::APP_ID, self::APP_ID . '-references');
});
}
public function register(IRegistrationContext $context): void {
@ -64,6 +74,8 @@ class Application extends App implements IBootstrap {
$context->registerDashboardWidget(Recent::class);
$context->registerDashboardWidget(Frequent::class);
$context->registerReferenceProvider(BookmarkReferenceProvider::class);
$context->registerEventListener(CreateEvent::class, TreeCacheManager::class);
$context->registerEventListener(UpdateEvent::class, TreeCacheManager::class);
$context->registerEventListener(BeforeDeleteEvent::class, TreeCacheManager::class);

View File

@ -0,0 +1,163 @@
<?php
/*
* @copyright Copyright (c) 2022 Julien Veyssier <eneiluj@posteo.net>
*
* @author Julien Veyssier <eneiluj@posteo.net>
* @author The Nextcloud Bookmarks contributors
*
* This file is licensed under the Affero General Public License version 3 or later. See the COPYING file.
*/
namespace OCA\Bookmarks\Reference;
use OCA\Bookmarks\AppInfo\Application;
use OCA\Bookmarks\Exception\UrlParseError;
use OCA\Bookmarks\Service\Authorizer;
use OCA\Bookmarks\Service\BookmarkService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Collaboration\Reference\ADiscoverableReferenceProvider;
use OCP\Collaboration\Reference\IReference;
use OCP\Collaboration\Reference\ISearchableReferenceProvider;
use OCP\Collaboration\Reference\Reference;
use OCP\IL10N;
use OCP\IURLGenerator;
use Psr\Log\LoggerInterface;
class BookmarkReferenceProvider extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider {
private ?string $userId;
private IL10N $l10n;
private IURLGenerator $urlGenerator;
private BookmarkService $bookmarkService;
private Authorizer $authorizer;
public function __construct(IL10N $l10n, ?string $userId, IURLGenerator $urlGenerator, BookmarkService $bookmarkService, Authorizer $authorizer) {
$this->userId = $userId;
$this->l10n = $l10n;
$this->urlGenerator = $urlGenerator;
$this->bookmarkService = $bookmarkService;
$this->authorizer = $authorizer;
}
/**
* @inheritDoc
*/
public function getId(): string {
return Application::APP_ID . '-ref-bookmarks';
}
/**
* @inheritDoc
*/
public function getTitle(): string {
return $this->l10n->t('Bookmarks');
}
/**
* @inheritDoc
*/
public function getOrder(): int {
return 10;
}
/**
* @inheritDoc
*/
public function getIconUrl(): string {
return $this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath(Application::APP_ID, 'bookmarks-black.svg')
);
}
/**
* @inheritDoc
*/
public function getSupportedSearchProviderIds(): array {
return [
'bookmarks',
];
}
/**
* @inheritDoc
*/
public function matchReference(string $referenceText): bool {
\OC::$server->get(LoggerInterface::class)->warning('MATCH REFERENCE');
$start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_ID);
$startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_ID);
// link example: https://nextcloud.local/index.php/apps/deck/#/board/2/card/11
$noIndexMatch = preg_match('/^' . preg_quote($start, '/') . '\/bookmarks\/[0-9]+$/', $referenceText) !== false;
$indexMatch = preg_match('/^' . preg_quote($startIndex, '/') . '\/bookmarks\/[0-9]+$/', $referenceText) !== false;
if ($noIndexMatch || $indexMatch) {
return true;
}
try {
$this->bookmarkService->findByUrl($this->userId, $referenceText);
return true;
} catch (UrlParseError|DoesNotExistException $e) {
return false;
}
}
/**
* @inheritDoc
*/
public function resolveReference(string $referenceText): ?IReference {
if (!$this->matchReference($referenceText)) {
return null;
}
$id = $this->getBookmarkId($referenceText);
if ($id === null) {
try {
$bookmark = $this->bookmarkService->findByUrl($this->userId, $referenceText);
} catch (UrlParseError|DoesNotExistException $e) {
return null;
}
}else{
$bookmark = $this->bookmarkService->findById((int)$id);
}
if ($bookmark === null) {
return null;
}
if (!Authorizer::hasPermission(Authorizer::PERM_READ, $this->authorizer->getUserPermissionsForBookmark($this->userId, (int)$id))) {
return null;
}
/** @var IReference $reference */
$reference = new Reference($referenceText);
$reference->setRichObject(Application::APP_ID . '-bookmark', [
'id' => $id,
'bookmark' => $bookmark->toArray(),
]);
return $reference;
}
private function getBookmarkId(string $url): ?string {
$start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_ID);
$startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_ID);
preg_match('/^' . preg_quote($start, '/') . '\/bookmarks\/([0-9]+)$/', $url, $matches);
if ($matches && count($matches) > 1) {
return $matches[1];
}
preg_match('/^' . preg_quote($startIndex, '/') . '\/bookmarks\/([0-9]+)$/', $url, $matches2);
if ($matches2 && count($matches2) > 1) {
return $matches2[1];
}
return null;
}
public function getCachePrefix(string $referenceId): string {
$id = $this->getBookmarkId($referenceId);
return $id ?? $referenceId;
}
public function getCacheKey(string $referenceId): ?string {
return $this->userId ?? '';
}
}

View File

@ -413,6 +413,18 @@ class BookmarkService {
}
}
/**
* @param int $id
* @return Bookmark|null
*/
public function findById(int $id) : ?Bookmark {
try {
return $this->bookmarkMapper->find($id);
} catch (DoesNotExistException|MultipleObjectsReturnedException $e) {
return null;
}
}
/**
* @param $userId
* @param string $url

71
package-lock.json generated
View File

@ -14,9 +14,11 @@
"@nextcloud/dialogs": "^3.2.0",
"@nextcloud/event-bus": "^3.0.2",
"@nextcloud/initial-state": "^2.0.0",
"@nextcloud/l10n": "^2.1.0",
"@nextcloud/router": "^2.0.0",
"@nextcloud/vue": "^7.8.0",
"@nextcloud/vue-dashboard": "^2.0.1",
"@nextcloud/vue-richtext": "^2.1.0-beta.6",
"async-parallel": "^1.2.3",
"clone-deep": "^4.0.1",
"humanize-duration": "^3.28.0",
@ -2075,6 +2077,15 @@
"npm": "^7.0.0 || ^8.0.0"
}
},
"node_modules/@nextcloud/dialogs/node_modules/@nextcloud/l10n": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-1.6.0.tgz",
"integrity": "sha512-aKGlgrwN9OiafN791sYus0shfwNeU3PlrH6Oi9ISma6iJSvN6a8aJM8WGKCJ9pqBaTR5PrDuckuM/WnybBWb6A==",
"dependencies": {
"core-js": "^3.6.4",
"node-gettext": "^3.0.0"
}
},
"node_modules/@nextcloud/dialogs/node_modules/@nextcloud/typings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@nextcloud/typings/-/typings-1.5.0.tgz",
@ -2182,12 +2193,18 @@
"integrity": "sha512-xmNP30v/RnkJ2z1HcuEo7YfcLJJa+FdWTwgNldXHOlMeMbl/ESpsGkWL2sULrhYurz64L0JpfwEdi/cHcmyuZQ=="
},
"node_modules/@nextcloud/l10n": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-1.6.0.tgz",
"integrity": "sha512-aKGlgrwN9OiafN791sYus0shfwNeU3PlrH6Oi9ISma6iJSvN6a8aJM8WGKCJ9pqBaTR5PrDuckuM/WnybBWb6A==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-2.1.0.tgz",
"integrity": "sha512-rToqXwxcsDTcijvSdgyJAKuOuW7XggDYH00/t3GN5HzO1lNNnVtOj7cc5WmiTknciM+En2oVSMFIUPs6HehjVQ==",
"dependencies": {
"core-js": "^3.6.4",
"@nextcloud/router": "^2.0.0",
"dompurify": "^2.4.1",
"escape-html": "^1.0.3",
"node-gettext": "^3.0.0"
},
"engines": {
"node": "^16.0.0",
"npm": "^7.0.0 || ^8.0.0"
}
},
"node_modules/@nextcloud/logger": {
@ -2378,6 +2395,15 @@
"semver": "^7.3.5"
}
},
"node_modules/@nextcloud/vue-dashboard/node_modules/@nextcloud/l10n": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-1.6.0.tgz",
"integrity": "sha512-aKGlgrwN9OiafN791sYus0shfwNeU3PlrH6Oi9ISma6iJSvN6a8aJM8WGKCJ9pqBaTR5PrDuckuM/WnybBWb6A==",
"dependencies": {
"core-js": "^3.6.4",
"node-gettext": "^3.0.0"
}
},
"node_modules/@nextcloud/vue-dashboard/node_modules/@nextcloud/router": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-1.2.0.tgz",
@ -2540,6 +2566,28 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/@nextcloud/vue-richtext": {
"version": "2.1.0-beta.6",
"resolved": "https://registry.npmjs.org/@nextcloud/vue-richtext/-/vue-richtext-2.1.0-beta.6.tgz",
"integrity": "sha512-LIhBCpFEfimUCHlPuhRADwTDXwOf4SASaQLYowofwvFfqTBjYi/TZdQfP4UBPaVFP2aKssOxuZ3HT83Z77ROAw==",
"dependencies": {
"@nextcloud/axios": "^2.0.0",
"@nextcloud/event-bus": "^3.0.2",
"@nextcloud/initial-state": "^2.0.0",
"@nextcloud/router": "^2.0.0",
"@nextcloud/vue": "^7.5.0",
"clone": "^2.1.2",
"vue": "^2.7.8",
"vue-material-design-icons": "^5.1.2"
},
"engines": {
"node": ">=14.0.0",
"npm": ">=7.0.0"
},
"peerDependencies": {
"vue": "^2.7.8"
}
},
"node_modules/@nextcloud/vue-select": {
"version": "3.22.2",
"resolved": "https://registry.npmjs.org/@nextcloud/vue-select/-/vue-select-3.22.2.tgz",
@ -2585,21 +2633,6 @@
"node-gettext": "^3.0.0"
}
},
"node_modules/@nextcloud/vue/node_modules/@nextcloud/l10n": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-2.0.1.tgz",
"integrity": "sha512-vTzsYUdNNJhDmdQS5pOhv+tdqGhjzI+iWZMQgM19rcpFo9MDFrcK/bQBIgcQypJjqSmNlP62M9CMVTm4fIwlsA==",
"dependencies": {
"@nextcloud/router": "^2.0.0",
"dompurify": "^2.4.1",
"escape-html": "^1.0.3",
"node-gettext": "^3.0.0"
},
"engines": {
"node": "^16.0.0",
"npm": "^7.0.0 || ^8.0.0"
}
},
"node_modules/@nextcloud/vue/node_modules/@nextcloud/typings": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@nextcloud/typings/-/typings-1.6.0.tgz",

View File

@ -26,9 +26,11 @@
"@nextcloud/dialogs": "^3.2.0",
"@nextcloud/event-bus": "^3.0.2",
"@nextcloud/initial-state": "^2.0.0",
"@nextcloud/l10n": "^2.1.0",
"@nextcloud/router": "^2.0.0",
"@nextcloud/vue": "^7.8.0",
"@nextcloud/vue-dashboard": "^2.0.1",
"@nextcloud/vue-richtext": "^2.1.0-beta.6",
"async-parallel": "^1.2.3",
"clone-deep": "^4.0.1",
"humanize-duration": "^3.28.0",

View File

@ -0,0 +1,174 @@
<!--
- @author 2022 Julien Veyssier <julien-nc@posteo.net>
- @author 2022 Marcel Klehr <mklehr@gmx.net>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="bookmarks-bookmark-reference">
<div class="line">
<a :href="bookmarkLink" target="_blank" class="link">
<BookmarksIcon :size="14" class="title-icon" />
<small>{{ addedDate }}</small>
</a>
</div>
<div class="line title">
<strong>
<a :href="url"
target="_blank"
class="link">
<figure class="icon" :style="{ backgroundImage: 'url(' + iconUrl + ')' }" />
{{ bookmark.title }}
</a>
</strong>
</div>
<div class="line">
<small>{{ content }}</small>
</div>
</div>
</template>
<script>
import BookmarksIcon from './icons/BookmarksIcon.vue'
import { generateUrl } from '@nextcloud/router'
import humanizeDuration from 'humanize-duration'
const MAX_RELATIVE_DATE = 1000 * 60 * 60 * 24 * 7 // one week
export default {
name: 'BookmarkReferenceWidget',
components: {
BookmarksIcon,
},
props: {
richObjectType: {
type: String,
default: '',
},
richObject: {
type: Object,
default: null,
},
accessible: {
type: Boolean,
default: true,
},
},
data() {
return {
shortDescription: true,
}
},
computed: {
bookmark() {
return this.richObject.bookmark
},
bookmarkLink() {
return generateUrl('/apps/bookmarks/bookmarks/{bookmarkId}', { bookmarkId: this.bookmark.id })
},
apiUrl() {
return generateUrl('/apps/bookmarks')
},
iconUrl() {
return (
this.apiUrl
+ '/bookmark/'
+ this.bookmark.id
+ '/favicon'
)
},
imageUrl() {
return (
this.apiUrl
+ '/bookmark/'
+ this.bookmark.id
+ '/image'
)
},
url() {
return this.bookmark.url
},
addedDate() {
const date = new Date(Number(this.bookmark.added) * 1000)
const age = Date.now() - date
if (age < MAX_RELATIVE_DATE) {
const duration = humanizeDuration(age, {
language: OC.getLanguage().split('-')[0],
units: ['d', 'h', 'm', 's'],
largest: 1,
round: true,
})
return this.t('bookmarks', 'Bookmarked {time} ago', { time: duration })
} else {
return this.t('bookmarks', 'Bookmarked on {date}', { date: date.toLocaleDateString() })
}
},
content() {
const length = 250
return this.bookmark?.textContent?.trim()?.slice(0, length) || this.bookmark.description?.trim()?.slice(0, length)
},
},
methods: {
},
}
</script>
<style scoped>
.bookmarks-bookmark-reference {
width: 100%;
white-space: normal;
padding: 12px;
}
.bookmarks-bookmark-reference .editor__content {
width: calc(100% - 24px);
}
.bookmarks-bookmark-reference .link {
text-decoration: underline;
color: var(--color-main-text) !important;
padding: 0 !important;
display: flex;
}
.bookmarks-bookmark-reference .line {
display: flex;
align-items: center;
}
.bookmarks-bookmark-reference .title {
font-size: 2em;
margin-top: 10px;
margin-bottom: 5px;
height: 31px;
overflow-y: hidden;
line-height: 1;
align-items: flex-start;
}
.bookmarks-bookmark-reference .spacer {
flex-grow: 1;
}
.bookmarks-bookmark-reference .icon {
display: inline-block;
height: 25px;
width: 25px;
background-size: cover;
margin-right: 5px;
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<span :aria-hidden="!title"
:aria-label="title"
class="material-design-icon bookmarks-icon"
role="img"
v-bind="$attrs"
@click="$emit('click', $event)">
<svg xmlns="http://www.w3.org/2000/svg"
:fill="fillColor"
:height="size"
:width="size"
viewBox="0 0 32 32">
<g transform="translate(0,16)"
fill="#fff">
<path d="m16-14c0.94487 0 3.9911 7.9919 4.7555 8.5752 0.76441 0.5833 8.9427 1.1565 9.2346 2.1003 0.29198 0.9438-6.0036 6.4562-6.2956 7.4-0.29198 0.9438 2.3984 9.2899 1.6339 9.8732-0.764 0.583-8.383-4.002-9.328-4.002-0.94487 0-8.5641 4.585-9.3285 4.0017-0.7644-0.584 1.9259-8.9297 1.6339-9.8735s-6.5875-6.4562-6.2956-7.4c0.292-0.9438 8.4702-1.517 9.2342-2.1003 0.765-0.5833 3.811-8.5752 4.756-8.5752z"
style="fill:#000000" />
<path opacity=".3"
d="m88-14c0.94487 0 3.9911 7.9919 4.7555 8.5752 0.76441 0.5833 8.9427 1.1565 9.2346 2.1003 0.29198 0.9438-6.0036 6.4562-6.2956 7.4-0.29198 0.9438 2.3984 9.2899 1.6339 9.8732-0.764 0.583-8.383-4.002-9.328-4.002-0.94487 0-8.5641 4.585-9.3285 4.0017-0.76441-0.5833 1.9259-8.9294 1.6339-9.8732-0.29198-0.9438-6.5875-6.4562-6.2956-7.4 0.29198-0.9438 8.4702-1.517 9.2346-2.1003 0.76441-0.5833 3.8106-8.5752 4.7555-8.5752z" />
<path opacity=".7"
d="m34.344 13.406c-0.172 0.088-0.315 0.187-0.344 0.282-0.28187 0.91113 5.5814 6.0441 6.25 7.25 0.06311-0.4005 0.10474-0.73846 0.0625-0.875-0.24735-0.79953-4.7593-4.8544-5.9688-6.6562zm27.312 0c-1.2095 1.8019-5.7214 5.8567-5.9688 6.6562-0.04224 0.13654-0.00061 0.4745 0.0625 0.875 0.66855-1.2059 6.5319-6.3389 6.25-7.25-0.0292-0.09438-0.17213-0.1939-0.34375-0.28125zm-13.656 12.532c-0.94487 0-8.5793 4.5833-9.3438 4-0.03185-0.0243-0.04218-0.07484-0.0625-0.125-0.06113 0.57179-0.08345 1.0136 0.0625 1.125 0.76442 0.5833 8.3989-4 9.3438-4 0.94487 0 8.5793 4.5833 9.3438 4 0.14595-0.11137 0.12363-0.55321 0.0625-1.125-0.02032 0.05016-0.03065 0.1007-0.0625 0.125-0.76441 0.5833-8.3989-4-9.3438-4z"
transform="translate(0,-16)" />
</g>
</svg>
</span>
</template>
<script>
export default {
name: 'BookmarksIcon',
props: {
title: {
type: String,
default: '',
},
fillColor: {
type: String,
default: 'currentColor',
},
size: {
type: Number,
default: 24,
},
},
}
</script>

39
src/references.js Normal file
View File

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023. The Nextcloud Bookmarks contributors.
*
* This file is licensed under the Affero General Public License version 3 or later. See the COPYING file.
*/
// with nc/vue 7.8.0, if we remove this, nothing works...
import {} from '@nextcloud/vue-richtext'
import { registerWidget } from '@nextcloud/vue/dist/Components/NcRichText.js'
import Vue from 'vue'
import BookmarkReferenceWidget from './components/BookmarkReferenceWidget.vue'
import { translate, translatePlural } from '@nextcloud/l10n'
__webpack_nonce__ = btoa(OC.requestToken) // eslint-disable-line
__webpack_public_path__ = OC.linkTo('bookmarks', 'js/') // eslint-disable-line
Vue.prototype.t = translate
Vue.prototype.n = translatePlural
Vue.prototype.OC = window.OC
Vue.prototype.OCA = window.OCA
registerWidget('bookmarks-bookmark', (el, { richObjectType, richObject, accessible }) => {
// trick to change the wrapper element size, otherwise it always is 100%
// which is not very nice with a simple card
el.parentNode.style['max-width'] = '400px'
el.parentNode.style['margin-left'] = '0'
el.parentNode.style['margin-right'] = '0'
const Widget = Vue.extend(BookmarkReferenceWidget)
new Widget({
propsData: {
richObjectType,
richObject,
accessible,
},
}).$mount(el)
})

View File

@ -9,3 +9,4 @@ webpackConfig.entry.flow = path.join(__dirname, 'src', 'flow.js')
webpackConfig.entry.dashboard = path.join(__dirname, 'src', 'dashboard.js')
webpackConfig.entry.talk = path.join(__dirname, 'src', 'talk.js')
webpackConfig.entry.collections = path.join(__dirname, 'src', 'collections.js')
webpackConfig.entry.references = path.join(__dirname, 'src', 'references.js')