Browse Source

Dashboard widget for NC20

pull/600/head
korelstar 7 months ago
parent
commit
937c08754b
  1. 11
      appinfo/routes.php
  2. 1
      css/global.scss
  3. 6
      lib/AppInfo/Application.php
  4. 21
      lib/AppInfo/BeforeTemplateRenderedListener.php
  5. 67
      lib/AppInfo/DashboardWidget.php
  6. 32
      lib/Controller/NotesController.php
  7. 36
      lib/Controller/PageController.php
  8. 16
      lib/Service/Note.php
  9. 9
      lib/Service/NoteUtil.php
  10. 20
      lib/Service/NotesService.php
  11. 26
      package-lock.json
  12. 4
      package.json
  13. 13
      src/NotesService.js
  14. 121
      src/components/Dashboard.vue
  15. 11
      src/dashboard.js
  16. 12
      webpack.js

11
appinfo/routes.php

@ -13,6 +13,12 @@ return ['routes' => [
'verb' => 'GET',
'postfix' => 'welcome',
],
[
'name' => 'page#create',
'url' => '/new',
'verb' => 'GET',
'postfix' => 'new',
],
[
'name' => 'page#index',
'url' => '/note/{id}',
@ -28,6 +34,11 @@ return ['routes' => [
'url' => '/notes',
'verb' => 'GET',
],
[
'name' => 'notes#dashboard',
'url' => '/notes/dashboard',
'verb' => 'GET',
],
[
'name' => 'notes#get',
'url' => '/notes/{id}',

1
css/global.scss

@ -0,0 +1 @@
@include icon-black-white('notes', 'notes', 1);

6
lib/AppInfo/Application.php

@ -8,6 +8,7 @@ use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
class Application extends App implements IBootstrap {
public const APP_ID = 'notes';
@ -19,6 +20,11 @@ class Application extends App implements IBootstrap {
public function register(IRegistrationContext $context): void {
$context->registerCapability(Capabilities::class);
$context->registerDashboardWidget(DashboardWidget::class);
$context->registerEventListener(
BeforeTemplateRenderedEvent::class,
BeforeTemplateRenderedListener::class
);
}
public function boot(IBootContext $context): void {

21
lib/AppInfo/BeforeTemplateRenderedListener.php

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace OCA\Notes\AppInfo;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
class BeforeTemplateRenderedListener implements IEventListener {
public function handle(Event $event): void {
if (!($event instanceof BeforeTemplateRenderedEvent)) {
return;
}
if (!$event->isLoggedIn()) {
return;
}
\OCP\Util::addStyle('notes', 'global');
}
}

67
lib/AppInfo/DashboardWidget.php

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace OCA\Notes\AppInfo;
use OCP\Dashboard\IWidget;
use OCP\IL10N;
use OCP\IURLGenerator;
class DashboardWidget implements IWidget {
/** @var IURLGenerator */
private $url;
/** @var IL10N */
private $l10n;
public function __construct(
IURLGenerator $url,
IL10N $l10n
) {
$this->url = $url;
$this->l10n = $l10n;
}
/**
* @inheritDoc
*/
public function getId(): string {
return 'notes';
}
/**
* @inheritDoc
*/
public function getTitle(): string {
return $this->l10n->t('Notes');
}
/**
* @inheritDoc
*/
public function getOrder(): int {
return 30;
}
/**
* @inheritDoc
*/
public function getIconClass(): string {
return 'icon-notes';
}
/**
* @inheritDoc
*/
public function getUrl(): ?string {
return $this->url->linkToRouteAbsolute('notes.page.index');
}
/**
* @inheritDoc
*/
public function load(): void {
\OCP\Util::addScript('notes', 'notes-dashboard');
}
}

32
lib/Controller/NotesController.php

@ -114,6 +114,38 @@ class NotesController extends Controller {
}
/**
* @NoAdminRequired
*/
public function dashboard() : JSONResponse {
return $this->helper->handleErrorResponse(function () {
$maxItems = 7;
$userId = $this->helper->getUID();
$notes = $this->notesService->getTopNotes($userId, $maxItems + 1);
$hasMoreNotes = count($notes) > $maxItems;
$notes = array_slice($notes, 0, $maxItems);
$items = array_map(function ($note) {
$excerpt = '';
try {
$excerpt = $note->getExcerpt();
} catch (\Throwable $e) {
}
return [
'id' => $note->getId(),
'title' => $note->getTitle(),
'category' => $note->getCategory(),
'favorite' => $note->getFavorite(),
'excerpt' => $excerpt,
];
}, $notes);
return [
'items' => $items,
'hasMoreItems' => $hasMoreNotes,
];
});
}
/**
* @NoAdminRequired
*/

36
lib/Controller/PageController.php

@ -4,22 +4,41 @@ declare(strict_types=1);
namespace OCA\Notes\Controller;
use OCA\Notes\Service\NotesService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
class PageController extends Controller {
public function __construct(string $AppName, IRequest $request) {
/** @NotesService */
private $notesService;
/** @var IUserSession */
private $userSession;
/** @IURLGenerator */
private $urlGenerator;
public function __construct(
string $AppName,
IRequest $request,
NotesService $notesService,
IUserSession $userSession,
IURLGenerator $urlGenerator
) {
parent::__construct($AppName, $request);
$this->notesService = $notesService;
$this->userSession = $userSession;
$this->urlGenerator = $urlGenerator;
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @return TemplateResponse
*/
public function index() : TemplateResponse {
$devMode = !is_file(dirname(__FILE__).'/../../js/notes-main.js');
@ -35,4 +54,15 @@ class PageController extends Controller {
return $response;
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function create() : RedirectResponse {
$note = $this->notesService->create($this->userSession->getUser()->getUID(), '', '');
$note->setContent('');
$url = $this->urlGenerator->linkToRoute('notes.page.index', [ 'id' => $note->getId() ]);
return new RedirectResponse($url);
}
}

16
lib/Service/Note.php

@ -54,6 +54,22 @@ class Note {
return $content;
}
public function getExcerpt(int $maxlen = 100) : string {
$excerpt = trim($this->noteUtil->stripMarkdown($this->getContent()));
$title = $this->getTitle();
if (!empty($title)) {
$length = strlen($title);
if (strncasecmp($excerpt, $title, $length) === 0) {
$excerpt = substr($excerpt, $length);
}
}
$excerpt = trim($excerpt);
if (strlen($excerpt) > $maxlen) {
$excerpt = substr($excerpt, 0, $maxlen) . '…';
}
return str_replace("\n", "\u{2003}", $excerpt);
}
public function getModified() : int {
return $this->file->getMTime();
}

9
lib/Service/NoteUtil.php

@ -127,6 +127,15 @@ class NoteUtil {
return trim($str);
}
public function stripMarkdown(string $str) : string {
// prepare content: remove markdown characters and empty spaces
$str = preg_replace("/^\s*[*+-]\s+/mu", "", $str); // list item
$str = preg_replace("/^#+\s+(.*?)\s*#*$/mu", "$1", $str); // headline
$str = preg_replace("/^(=+|-+)$/mu", "", $str); // separate line for headline
$str = preg_replace("/(\*+|_+)(.*?)\\1/mu", "$2", $str); // emphasis
return $str;
}
/**
* Finds a folder and creates it if non-existent
* @param string $path path to the folder

20
lib/Service/NotesService.php

@ -37,6 +37,20 @@ class NotesService {
return [ 'notes' => $notes, 'categories' => $data['categories'] ];
}
public function getTopNotes(string $userId, int $count) : array {
$notes = $this->getAll($userId)['notes'];
usort($notes, function (Note $a, Note $b) {
$favA = $a->getFavorite();
$favB = $b->getFavorite();
if ($favA === $favB) {
return $b->getModified() - $a->getModified();
} else {
return $favA > $favB ? -1 : 1;
}
});
return array_slice($notes, 0, $count);
}
public function get(string $userId, int $id) : Note {
$notesFolder = $this->getNotesFolder($userId);
$note = new Note($this->getFileById($notesFolder, $id), $notesFolder, $this->noteUtil);
@ -89,11 +103,7 @@ class NotesService {
}
public function getTitleFromContent(string $content) : string {
// prepare content: remove markdown characters and empty spaces
$content = preg_replace("/^\s*[*+-]\s+/mu", "", $content); // list item
$content = preg_replace("/^#+\s+(.*?)\s*#*$/mu", "$1", $content); // headline
$content = preg_replace("/^(=+|-+)$/mu", "", $content); // separate line for headline
$content = preg_replace("/(\*+|_+)(.*?)\\1/mu", "$2", $content); // emphasis
$content = $this->noteUtil->stripMarkdown($content);
return $this->noteUtil->getSafeTitle($content);
}

26
package-lock.json

@ -2731,6 +2731,16 @@
}
}
},
"@nextcloud/vue-dashboard": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@nextcloud/vue-dashboard/-/vue-dashboard-1.0.1.tgz",
"integrity": "sha512-QKOf0qm4UYobuC3djLYwICSuj29H6xnm24IHetc0AMsrfhwPNN1/CC5IWEl1uKCCvwI0NA02HXCVFLtUErlgyg==",
"requires": {
"@nextcloud/vue": "^2.3.0",
"core-js": "^3.6.4",
"vue": "^2.6.11"
}
},
"@nextcloud/webpack-vue-config": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@nextcloud/webpack-vue-config/-/webpack-vue-config-1.4.1.tgz",
@ -11328,6 +11338,16 @@
}
}
},
"webpack-merge": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.1.4.tgz",
"integrity": "sha512-LSmRD59mxREGkCBm9PCW3AaV4doDqxykGlx1NvioEE0FgkT2GQI54Wyvg39ptkiq2T11eRVoV39udNPsQvK+QQ==",
"dev": true,
"requires": {
"clone-deep": "^4.0.1",
"wildcard": "^2.0.0"
}
},
"webpack-sources": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
@ -11370,6 +11390,12 @@
"string-width": "^1.0.2 || 2"
}
},
"wildcard": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz",
"integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==",
"dev": true
},
"word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",

4
package.json

@ -19,6 +19,7 @@
"@nextcloud/moment": "^1.1.1",
"@nextcloud/router": "^1.2.0",
"@nextcloud/vue": "^2.6.6",
"@nextcloud/vue-dashboard": "^1.0.1",
"@nextcloud/webpack-vue-config": "^1.4.1",
"easymde": "^2.12.0",
"markdown-it": "^11.0.1",
@ -65,6 +66,7 @@
"vue-loader": "^15.9.3",
"vue-template-compiler": "^2.6.12",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.12"
"webpack-cli": "^3.3.12",
"webpack-merge": "^5.1.4"
}
}

13
src/NotesService.js

@ -45,6 +45,19 @@ export const setSettings = settings => {
})
}
export const getDashboardData = () => {
return axios
.get(url('/notes/dashboard'))
.then(response => {
return response.data
})
.catch(err => {
console.error(err)
handleSyncError(t('notes', 'Fetching notes for dashboard has failed.'), err)
throw err
})
}
export const fetchNotes = () => {
const lastETag = store.state.sync.etag
const lastModified = store.state.sync.lastModified

121
src/components/Dashboard.vue

@ -0,0 +1,121 @@
<template>
<DashboardWidget :items="items"
:loading="loading"
:show-more-text="t('notes', 'notes')"
:show-more-url="showMoreUrl"
>
<template v-slot:default="{ item }">
<DashboardWidgetItem
:target-url="getItemTargetUrl(item)"
:main-text="item.title"
:sub-text="subtext(item)"
>
<template v-slot:avatar>
<div
class="note-item"
:class="{ 'note-item-favorite': item.favorite, 'note-item-no-favorites': !hasFavorites }"
/>
</template>
</DashboardWidgetItem>
</template>
<template v-slot:empty-content>
<EmptyContent icon="icon-notes">
<template #desc>
<p class="notes-empty-content-label">
{{ t('notes', 'No notes yet') }}
</p>
<p>
<a :href="createNoteUrl" class="button">{{ t('note', 'New note') }}</a>
</p>
</template>
</EmptyContent>
</template>
</DashboardWidget>
</template>
<script>
import { DashboardWidget, DashboardWidgetItem } from '@nextcloud/vue-dashboard'
import { EmptyContent } from '@nextcloud/vue'
import { generateUrl } from '@nextcloud/router'
import { getDashboardData, categoryLabel } from '../NotesService'
export default {
name: 'Dashboard',
components: {
DashboardWidget,
DashboardWidgetItem,
EmptyContent,
},
data() {
return {
loading: true,
items: [],
hasMoreItems: false,
}
},
computed: {
showMoreUrl() {
return this.hasMoreItems ? generateUrl('/apps/notes') : null
},
hasFavorites() {
return this.items.length > 0 && this.items[0].favorite
},
createNoteUrl() {
return generateUrl('/apps/notes/new')
},
getItemTargetUrl() {
return (note) => {
return generateUrl('/apps/notes/note/' + note.id)
}
},
},
created() {
this.loadDashboardData()
},
methods: {
loadDashboardData() {
getDashboardData().then(data => {
this.items = data.items
this.hasMoreItems = data.hasMoreItems
this.loading = false
})
},
subtext(item) {
return item.excerpt ? item.excerpt : categoryLabel(item.category)
},
},
}
</script>
<style scoped>
.note-item-favorite {
background: var(--icon-star-dark-fc0);
}
.note-item {
width: 44px;
height: 44px;
line-height: 44px;
flex-shrink: 0;
background-size: 50%;
background-repeat: no-repeat;
background-position: center;
}
.note-item-no-favorites {
display: none;
}
.notes-empty-content-label {
margin-bottom: 20px;
}
</style>

11
src/dashboard.js

@ -0,0 +1,11 @@
import Vue from 'vue'
import Dashboard from './components/Dashboard'
Vue.mixin({ methods: { t, n } })
document.addEventListener('DOMContentLoaded', () => {
OCA.Dashboard.register('notes', (el) => {
const View = Vue.extend(Dashboard)
new View().$mount(el)
})
})

12
webpack.js

@ -1 +1,11 @@
module.exports = require('@nextcloud/webpack-vue-config')
const webpackConfig = require('@nextcloud/webpack-vue-config')
const path = require('path')
const { merge } = require('webpack-merge')
const config = {
entry: {
dashboard: path.join(__dirname, 'src', 'dashboard.js'),
},
}
module.exports = merge(webpackConfig, config)
Loading…
Cancel
Save