Compare commits
10
Commits
6312c18a91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd4716faf4 | ||
|
|
34d54b5883 | ||
|
|
9446f128c9 | ||
|
|
2b3e96e50a | ||
|
|
a9b7fd11af | ||
|
|
5bba3a93cd | ||
|
|
e5432a0a38 | ||
|
|
e1a3fd12f0 | ||
|
|
00dbbe3421 | ||
|
|
aa35d28f60 |
No files matched your search
@@ -1,2 +1,3 @@
|
|||||||
/vendor/
|
/vendor/
|
||||||
|
node_modules/
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# AToCal — Add To Calendar
|
# AToCal — Add To Calendar
|
||||||
|
|
||||||
|
> **Vibe-coded:** AToCal is written by Mistral AI's coding agent, not by hand.
|
||||||
|
> The maintainer sets the goals, makes the product decisions and tries the app
|
||||||
|
> out in the browser; there has been no independent human code review and
|
||||||
|
> no security audit.
|
||||||
|
|
||||||
A Nextcloud app that lets remote applications create events in the
|
A Nextcloud app that lets remote applications create events in the
|
||||||
authenticated user's Nextcloud Calendar via a simple link.
|
authenticated user's Nextcloud Calendar via a simple link.
|
||||||
|
|
||||||
@@ -21,8 +26,10 @@ remote app Nextcloud (atocal)
|
|||||||
```
|
```
|
||||||
|
|
||||||
The remote app links the user to the confirm page. The user reviews
|
The remote app links the user to the confirm page. The user reviews
|
||||||
the event and submits the form. The event lands in the user's
|
the event and submits the form. The event lands in a calendar the user
|
||||||
"Personal" calendar and the browser opens the Calendar app.
|
chooses. The user adds invitees/attendees to the event, who receive
|
||||||
|
an invitation, as long as the user owns the calendar.
|
||||||
|
After the event is created the browser opens the Calendar app.
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
@@ -30,6 +37,7 @@ the event and submits the form. The event lands in the user's
|
|||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| GET | `/apps/atocal/confirm` | Show confirmation page |
|
| GET | `/apps/atocal/confirm` | Show confirmation page |
|
||||||
| POST | `/apps/atocal/create` | Create the event |
|
| POST | `/apps/atocal/create` | Create the event |
|
||||||
|
| GET | `/apps/atocal/contacts/search` | Contact search for the invitee input |
|
||||||
|
|
||||||
## Link parameters (GET `/confirm` and form POST `/create`)
|
## Link parameters (GET `/confirm` and form POST `/create`)
|
||||||
|
|
||||||
@@ -41,11 +49,26 @@ the event and submits the form. The event lands in the user's
|
|||||||
| `description` | no | max 10000 chars |
|
| `description` | no | max 10000 chars |
|
||||||
| `location` | no | HTTPS URL |
|
| `location` | no | HTTPS URL |
|
||||||
| `url` | no | HTTPS URL; merged into description |
|
| `url` | no | HTTPS URL; merged into description |
|
||||||
| `organizer` | no | email address |
|
| `organizer` | no | email address; display info only |
|
||||||
|
|
||||||
|
The create form adds `calendarId` (selected calendar) and
|
||||||
|
`attendees` (emails, max 20).
|
||||||
|
|
||||||
Dates carry no timezone and are interpreted in the user's Nextcloud
|
Dates carry no timezone and are interpreted in the user's Nextcloud
|
||||||
timezone (server default as fallback).
|
timezone (server default as fallback).
|
||||||
|
|
||||||
|
## Organizer
|
||||||
|
|
||||||
|
The event's ICS `ORGANIZER` is the Nextcloud user's own email
|
||||||
|
address. Invitations to attendees are scheduled by the CalDAV
|
||||||
|
backend in the calendar owner's name, which only works when the
|
||||||
|
organizer matches the owner.
|
||||||
|
|
||||||
|
The `organizer` link parameter is shown on the confirm page as
|
||||||
|
meeting information and is used as `ORGANIZER` fallback only when
|
||||||
|
the user has no profile email — in that case no working
|
||||||
|
invitations may be sent, so profile emails should be set.
|
||||||
|
|
||||||
## Reminder
|
## Reminder
|
||||||
|
|
||||||
Every created event gets a display alarm 15 minutes before it starts
|
Every created event gets a display alarm 15 minutes before it starts
|
||||||
@@ -61,13 +84,12 @@ https://cloud.example.com/apps/atocal/confirm?summary=Kickoff&start=2026-10-12T1
|
|||||||
|
|
||||||
- Nextcloud 34 or 35
|
- Nextcloud 34 or 35
|
||||||
- Calendar app enabled
|
- Calendar app enabled
|
||||||
- A writable calendar named "Personal"
|
- A writable calendar ("Personal" is preselected if present)
|
||||||
(event creation fails with a friendly error otherwise)
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Copy this directory to the Nextcloud server as
|
Copy this directory to the Nextcloud server as
|
||||||
`custom_apps/atocal`, then:
|
`apps/atocal`, then:
|
||||||
|
|
||||||
```
|
```
|
||||||
occ app:enable atocal
|
occ app:enable atocal
|
||||||
@@ -76,10 +98,18 @@ occ app:enable atocal
|
|||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
appinfo/info.xml app metadata
|
appinfo/info.xml app metadata
|
||||||
appinfo/routes.php routes
|
appinfo/routes.php routes
|
||||||
lib/AppInfo/Application.php container setup
|
css/style.css page styles
|
||||||
|
js/invitees.js invitee input
|
||||||
|
l10n/*.json translations
|
||||||
|
lib/AppInfo/Application.php container setup
|
||||||
lib/Controller/CalendarController.php confirm/create logic
|
lib/Controller/CalendarController.php confirm/create logic
|
||||||
templates/confirm.php confirmation page
|
templates/confirm.php confirmation page
|
||||||
templates/style.css page styles
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
AGPL-3.0-or-later, see LICENSE. AToCal comes without any warranty.
|
||||||
|
|
||||||
|
AToCal is an independent project and is not affiliated with any other product or company of a similar name.
|
||||||
+6
-2
@@ -8,11 +8,15 @@
|
|||||||
</description>
|
</description>
|
||||||
<version>1.0.0</version>
|
<version>1.0.0</version>
|
||||||
<licence>AGPL</licence>
|
<licence>AGPL</licence>
|
||||||
<author>Stefan Tollkühn s.tollkuehn@bnvb-ingenieure.de</author>
|
<author mail="atocal@bnvb-ingenieure.de">Stefan Tollkühn</author>
|
||||||
<namespace>AToCal</namespace>
|
<namespace>AToCal</namespace>
|
||||||
<category>integration</category>
|
<category>integration</category>
|
||||||
|
<category>tools</category>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<nextcloud min-version="34" max-version="35"/>
|
<nextcloud min-version="34" max-version="35"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<screenshot></screenshot>
|
<website>https://github.com/osiktech/atocal</website>
|
||||||
|
<bugs>https://github.com/osiktech/atocal/issues</bugs>
|
||||||
|
<repository type="git">https://github.com/osiktech/atocal</repository>
|
||||||
|
<screenshot>https://private-user-images.githubusercontent.com/21309019/685938995-dbd7ec4c-6adc-4059-bcd6-59cce9d6fcb5.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3OTMwOTQyMDgsIm5iZiI6MTc5MzA5MzkwOCwicGF0aCI6Ii8yMTMwOTAxOS82ODU5Mzg5OTUtZGJkN2VjNGMtNmFkYy00MDU5LWJjZDYtNTljY2U5ZDZmY2I1LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjEwMjclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYxMDI3VDA5MzgyOFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWFlZDE3MDk4OTNhYmU0ZjIzNDBjZjMwNTc5ZjFmZDIyMTFjNjU4MzBmMzkxODllOTA0NTUxYTMyMGIwMTA3MDAmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JnJlc3BvbnNlLWNvbnRlbnQtdHlwZT1pbWFnZSUyRnBuZyJ9.ChinpCO2myfCeA1hW7Eydtlu13WcdIu98nNom9QvDNg</screenshot>
|
||||||
</info>
|
</info>
|
||||||
+148
-144
@@ -10,178 +10,182 @@
|
|||||||
* datalist rendered by the server.
|
* datalist rendered by the server.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var DEBOUNCE_MS = 250;
|
var DEBOUNCE_MS = 250;
|
||||||
var SEPARATOR = /[;,]/;
|
/* Must match the server's attendee parsing: whitespace, comma and
|
||||||
|
* semicolon all separate entries. */
|
||||||
|
var SEPARATOR = /[\s;,]+/;
|
||||||
|
|
||||||
var input = document.getElementById('atocal-attendees');
|
var input = document.getElementById('atocal-attendees');
|
||||||
|
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchUrl = input.dataset.searchUrl;
|
var searchUrl = input.dataset.searchUrl;
|
||||||
|
|
||||||
if (!searchUrl) {
|
if (!searchUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var list = document.createElement('ul');
|
var list = document.createElement('ul');
|
||||||
list.className = 'atocal-suggestions';
|
list.id = 'atocal-suggestions';
|
||||||
list.hidden = true;
|
list.className = 'atocal-suggestions';
|
||||||
list.setAttribute('role', 'listbox');
|
list.hidden = true;
|
||||||
|
list.setAttribute('role', 'listbox');
|
||||||
|
|
||||||
/* The wrapper span provides position: relative */
|
/* The wrapper span provides position: relative */
|
||||||
input.parentNode.appendChild(list);
|
input.parentNode.appendChild(list);
|
||||||
|
|
||||||
/* The native datalist popup would double the custom one */
|
/* The native datalist popup would double the custom one */
|
||||||
input.removeAttribute('list');
|
input.removeAttribute('list');
|
||||||
|
|
||||||
var debounceTimer = null;
|
var debounceTimer = null;
|
||||||
var requestSeq = 0;
|
var requestSeq = 0;
|
||||||
var suggestions = [];
|
var suggestions = [];
|
||||||
var activeIndex = -1;
|
var activeIndex = -1;
|
||||||
|
|
||||||
function fragment() {
|
function fragment() {
|
||||||
var parts = input.value.split(SEPARATOR);
|
var parts = input.value.split(SEPARATOR);
|
||||||
return parts[parts.length - 1].trim();
|
return parts[parts.length - 1].trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
list.hidden = true;
|
requestSeq++;
|
||||||
list.replaceChildren();
|
list.hidden = true;
|
||||||
suggestions = [];
|
list.replaceChildren();
|
||||||
activeIndex = -1;
|
suggestions = [];
|
||||||
input.setAttribute('aria-expanded', 'false');
|
activeIndex = -1;
|
||||||
input.removeAttribute('aria-activedescendant');
|
input.setAttribute('aria-expanded', 'false');
|
||||||
}
|
input.removeAttribute('aria-activedescendant');
|
||||||
|
}
|
||||||
|
|
||||||
function commit(email) {
|
function commit(email) {
|
||||||
var parts = input.value.split(/([;,])/);
|
var parts = input.value.split(/([\s;,]+)/);
|
||||||
parts.pop();
|
parts.pop();
|
||||||
var prefix = parts.join('').replace(/\s+$/, '');
|
var prefix = parts.join('').replace(/\s+$/, '');
|
||||||
|
|
||||||
input.value = prefix === '' ? email + ', ' : prefix + ' ' + email + ', ';
|
input.value = prefix === '' ? email + ', ' : prefix + ' ' + email + ', ';
|
||||||
hide();
|
hide();
|
||||||
input.focus();
|
input.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveSelection(offset) {
|
function moveSelection(offset) {
|
||||||
activeIndex = (activeIndex + offset + suggestions.length) % suggestions.length;
|
activeIndex = (activeIndex + offset + suggestions.length) % suggestions.length;
|
||||||
|
|
||||||
list.childNodes.forEach(function (option, i) {
|
list.childNodes.forEach(function (option, i) {
|
||||||
option.classList.toggle('atocal-active', i === activeIndex);
|
option.classList.toggle('atocal-active', i === activeIndex);
|
||||||
});
|
});
|
||||||
input.setAttribute('aria-activedescendant', 'atocal-suggestion-' + activeIndex);
|
input.setAttribute('aria-activedescendant', 'atocal-suggestion-' + activeIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(contacts) {
|
function render(contacts) {
|
||||||
hide();
|
hide();
|
||||||
|
|
||||||
if (contacts.length === 0) {
|
if (contacts.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
suggestions = contacts;
|
suggestions = contacts;
|
||||||
|
|
||||||
contacts.forEach(function (contact, i) {
|
contacts.forEach(function (contact, i) {
|
||||||
var option = document.createElement('li');
|
var option = document.createElement('li');
|
||||||
option.id = 'atocal-suggestion-' + i;
|
option.id = 'atocal-suggestion-' + i;
|
||||||
option.setAttribute('role', 'option');
|
option.setAttribute('role', 'option');
|
||||||
|
|
||||||
var button = document.createElement('button');
|
var button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.tabIndex = -1;
|
button.tabIndex = -1;
|
||||||
button.addEventListener('mousedown', function (event) {
|
button.addEventListener('mousedown', function (event) {
|
||||||
/* keep focus on the input */
|
/* keep focus on the input */
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
commit(contact.email);
|
commit(contact.email);
|
||||||
});
|
});
|
||||||
|
|
||||||
var name = document.createElement('span');
|
var name = document.createElement('span');
|
||||||
name.className = 'atocal-suggestion-name';
|
name.className = 'atocal-suggestion-name';
|
||||||
name.textContent = contact.name === '' ? contact.email : contact.name;
|
name.textContent = contact.name === '' ? contact.email : contact.name;
|
||||||
|
|
||||||
var email = document.createElement('span');
|
var email = document.createElement('span');
|
||||||
email.className = 'atocal-suggestion-email';
|
email.className = 'atocal-suggestion-email';
|
||||||
email.textContent = contact.email;
|
email.textContent = contact.email;
|
||||||
|
|
||||||
button.appendChild(name);
|
button.appendChild(name);
|
||||||
button.appendChild(email);
|
button.appendChild(email);
|
||||||
option.appendChild(button);
|
option.appendChild(button);
|
||||||
list.appendChild(option);
|
list.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
list.hidden = false;
|
list.hidden = false;
|
||||||
input.setAttribute('aria-expanded', 'true');
|
input.setAttribute('aria-expanded', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
function search(query) {
|
function search(query) {
|
||||||
var seq = ++requestSeq;
|
var seq = ++requestSeq;
|
||||||
|
|
||||||
fetch(searchUrl + '?query=' + encodeURIComponent(query), {
|
fetch(searchUrl + '?query=' + encodeURIComponent(query), {
|
||||||
headers: { Accept: 'application/json' }
|
headers: { Accept: 'application/json' }
|
||||||
})
|
})
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('search failed');
|
throw new Error('search failed');
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(function (contacts) {
|
.then(function (contacts) {
|
||||||
if (seq === requestSeq) {
|
if (seq === requestSeq) {
|
||||||
render(Array.isArray(contacts) ? contacts : []);
|
render(Array.isArray(contacts) ? contacts : []);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
if (seq === requestSeq) {
|
if (seq === requestSeq) {
|
||||||
hide();
|
hide();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addEventListener('input', function () {
|
input.addEventListener('input', function () {
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
|
|
||||||
var query = fragment();
|
var query = fragment();
|
||||||
|
|
||||||
if (query === '') {
|
if (query === '') {
|
||||||
hide();
|
hide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debounceTimer = setTimeout(function () {
|
debounceTimer = setTimeout(function () {
|
||||||
search(query);
|
search(query);
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
input.addEventListener('keydown', function (event) {
|
input.addEventListener('keydown', function (event) {
|
||||||
if (list.hidden) {
|
if (list.hidden) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
moveSelection(1);
|
moveSelection(1);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
moveSelection(-1);
|
moveSelection(-1);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
break;
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
if (activeIndex >= 0) {
|
if (activeIndex >= 0) {
|
||||||
commit(suggestions[activeIndex].email);
|
commit(suggestions[activeIndex].email);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
hide();
|
hide();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('click', function (event) {
|
document.addEventListener('click', function (event) {
|
||||||
if (!list.hidden && event.target !== input && !list.contains(event.target)) {
|
if (!list.hidden && event.target !== input && !list.contains(event.target)) {
|
||||||
hide();
|
hide();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -10,6 +10,7 @@ use OCP\AppFramework\Controller;
|
|||||||
use OCP\AppFramework\Http;
|
use OCP\AppFramework\Http;
|
||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||||
|
use OCP\AppFramework\Http\Attribute\UserRateLimit;
|
||||||
use OCP\AppFramework\Http\RedirectResponse;
|
use OCP\AppFramework\Http\RedirectResponse;
|
||||||
use OCP\AppFramework\Http\DataResponse;
|
use OCP\AppFramework\Http\DataResponse;
|
||||||
use OCP\AppFramework\Http\TemplateResponse;
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
@@ -76,6 +77,19 @@ class CalendarController extends Controller
|
|||||||
*/
|
*/
|
||||||
private const MAX_CONTACT_SUGGESTIONS = 50;
|
private const MAX_CONTACT_SUGGESTIONS = 50;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Short fragments are not distinctive enough for an
|
||||||
|
* address-book search, so they skip it entirely.
|
||||||
|
*/
|
||||||
|
private const MIN_SEARCH_QUERY = 2;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Live-search requests per minute per user. The JS debounce
|
||||||
|
* produces at most four per second while typing.
|
||||||
|
*/
|
||||||
|
private const SEARCH_RATE_LIMIT = 120;
|
||||||
|
private const SEARCH_RATE_PERIOD = 60;
|
||||||
|
|
||||||
private IManager $calendarManager;
|
private IManager $calendarManager;
|
||||||
private IContactsManager $contactsManager;
|
private IContactsManager $contactsManager;
|
||||||
private IAppManager $appManager;
|
private IAppManager $appManager;
|
||||||
@@ -310,8 +324,21 @@ class CalendarController extends Controller
|
|||||||
*/
|
*/
|
||||||
#[NoAdminRequired]
|
#[NoAdminRequired]
|
||||||
#[NoCSRFRequired]
|
#[NoCSRFRequired]
|
||||||
|
#[UserRateLimit(
|
||||||
|
limit: self::SEARCH_RATE_LIMIT,
|
||||||
|
period: self::SEARCH_RATE_PERIOD
|
||||||
|
)]
|
||||||
public function searchContacts(string $query = ''): DataResponse
|
public function searchContacts(string $query = ''): DataResponse
|
||||||
{
|
{
|
||||||
|
/*
|
||||||
|
* An empty or single-character query means a scan of the
|
||||||
|
* whole address book: the most expensive case, skipped
|
||||||
|
* because such fragments match too much anyway.
|
||||||
|
*/
|
||||||
|
if (mb_strlen(trim($query)) < self::MIN_SEARCH_QUERY) {
|
||||||
|
return new DataResponse([]);
|
||||||
|
}
|
||||||
|
|
||||||
return new DataResponse($this->contactSuggestions($query));
|
return new DataResponse($this->contactSuggestions($query));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,27 +871,36 @@ class CalendarController extends Controller
|
|||||||
{
|
{
|
||||||
$timeZone = $this->dateTimeZone->getTimeZone();
|
$timeZone = $this->dateTimeZone->getTimeZone();
|
||||||
|
|
||||||
$date = \DateTimeImmutable::createFromFormat(
|
/*
|
||||||
self::DATETIME_LOCAL,
|
* "!" resets all fields, so no parts of the current date
|
||||||
$value,
|
* leak in. PHP normalizes impossible dates instead of
|
||||||
$timeZone
|
* failing (2026-02-31 rolls into March), so require zero
|
||||||
);
|
* warnings and an exact format round-trip on top of the
|
||||||
|
* parse result.
|
||||||
if ($date === false) {
|
*/
|
||||||
|
foreach (['!' . self::DATETIME_LOCAL, '!Y-m-d\TH:i:s'] as $format) {
|
||||||
$date = \DateTimeImmutable::createFromFormat(
|
$date = \DateTimeImmutable::createFromFormat(
|
||||||
'Y-m-d\TH:i:s',
|
$format,
|
||||||
$value,
|
$value,
|
||||||
$timeZone
|
$timeZone
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$errors = \DateTimeImmutable::getLastErrors();
|
||||||
|
|
||||||
|
if (
|
||||||
|
$date !== false
|
||||||
|
&& ($errors === false
|
||||||
|
|| ($errors['warning_count'] === 0
|
||||||
|
&& $errors['error_count'] === 0))
|
||||||
|
&& $date->format(substr($format, 1)) === $value
|
||||||
|
) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($date === false) {
|
throw new \InvalidArgumentException(
|
||||||
throw new \InvalidArgumentException(
|
$this->l10n->t('Invalid date/time.')
|
||||||
$this->l10n->t('Invalid date/time.')
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Generated
+2148
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"description": "JS test harness for the atocal frontend scripts",
|
||||||
|
"scripts": {
|
||||||
|
"test:js": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,10 @@ $l = $_['l10n'];
|
|||||||
id="atocal-attendees"
|
id="atocal-attendees"
|
||||||
name="attendees"
|
name="attendees"
|
||||||
type="text"
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="atocal-suggestions"
|
||||||
|
aria-expanded="false"
|
||||||
list="atocal-contact-emails"
|
list="atocal-contact-emails"
|
||||||
data-search-url="<?= $h($_['contactsSearchUrl']) ?>"
|
data-search-url="<?= $h($_['contactsSearchUrl']) ?>"
|
||||||
value="<?= $h($_['attendees']) ?>"
|
value="<?= $h($_['attendees']) ?>"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use OCP\App\IAppManager;
|
|||||||
use OCP\AppFramework\Http\DataResponse;
|
use OCP\AppFramework\Http\DataResponse;
|
||||||
use OCP\AppFramework\Http\RedirectResponse;
|
use OCP\AppFramework\Http\RedirectResponse;
|
||||||
use OCP\AppFramework\Http\TemplateResponse;
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
|
use OCP\AppFramework\Http\Attribute\UserRateLimit;
|
||||||
use OCP\Calendar\ICalendar;
|
use OCP\Calendar\ICalendar;
|
||||||
use OCP\Calendar\ICalendarEventBuilder;
|
use OCP\Calendar\ICalendarEventBuilder;
|
||||||
use OCP\Calendar\ICreateFromString;
|
use OCP\Calendar\ICreateFromString;
|
||||||
@@ -218,6 +219,35 @@ class CalendarControllerTest extends TestCase
|
|||||||
$this->invoke($c, 'parseLocalDateTime', ['tomorrow']);
|
$this->invoke($c, 'parseLocalDateTime', ['tomorrow']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testParseLocalDateTimeAcceptsLeapDay(): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$date = $this->invoke($c, 'parseLocalDateTime', ['2028-02-29T10:00']);
|
||||||
|
$this->assertSame('2028-02-29 10:00:00 UTC', $date->format('Y-m-d H:i:s T'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideInvalidLocalDateTimes(): iterable
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'impossible day rolls into next month' => ['2026-02-31T10:00'],
|
||||||
|
'leap day in a non-leap year' => ['2027-02-29T10:00'],
|
||||||
|
'month out of range' => ['2026-13-01T10:00'],
|
||||||
|
'day out of range' => ['2026-00-15T10:00'],
|
||||||
|
'hour out of range' => ['2026-02-03T25:00'],
|
||||||
|
'non-padded components' => ['2026-2-3T10:00'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideInvalidLocalDateTimes')]
|
||||||
|
public function testParseLocalDateTimeRejectsInvalidValues(string $value): void
|
||||||
|
{
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->invoke($c, 'parseLocalDateTime', [$value]);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* validateEventData
|
* validateEventData
|
||||||
*/
|
*/
|
||||||
@@ -1056,6 +1086,34 @@ class CalendarControllerTest extends TestCase
|
|||||||
$this->assertCount(50, $response->getData());
|
$this->assertCount(50, $response->getData());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* searchContacts(): trivial queries must not reach the
|
||||||
|
* address book
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function testSearchContactsSkipsShortQueries(): void
|
||||||
|
{
|
||||||
|
$this->contactsManager->expects($this->never())->method('search');
|
||||||
|
|
||||||
|
$c = $this->newController();
|
||||||
|
|
||||||
|
$this->assertSame([], $c->searchContacts('')->getData());
|
||||||
|
$this->assertSame([], $c->searchContacts('a')->getData());
|
||||||
|
$this->assertSame([], $c->searchContacts(' b ')->getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSearchContactsIsRateLimited(): void
|
||||||
|
{
|
||||||
|
$method = new \ReflectionMethod(CalendarController::class, 'searchContacts');
|
||||||
|
$attributes = $method->getAttributes(UserRateLimit::class);
|
||||||
|
|
||||||
|
$this->assertCount(1, $attributes);
|
||||||
|
|
||||||
|
$limit = $attributes[0]->newInstance();
|
||||||
|
$this->assertSame(120, $limit->getLimit());
|
||||||
|
$this->assertSame(60, $limit->getPeriod());
|
||||||
|
}
|
||||||
|
|
||||||
public function testSearchContactsTruncatesQuery(): void
|
public function testSearchContactsTruncatesQuery(): void
|
||||||
{
|
{
|
||||||
$this->contactsManager->expects($this->once())->method('search')
|
$this->contactsManager->expects($this->once())->method('search')
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 250;
|
||||||
|
const SEARCH_URL = '/apps/atocal/contact/search';
|
||||||
|
const BOB = { name: 'Bob', email: 'bob@example.com' };
|
||||||
|
const ZOE = { name: 'Zoe', email: 'zoe@example.com' };
|
||||||
|
|
||||||
|
let input;
|
||||||
|
let list;
|
||||||
|
|
||||||
|
function response(contacts) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(contacts),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Promise we can settle manually, to emulate an in-flight request */
|
||||||
|
function pendingResponse() {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function type(fragment) {
|
||||||
|
input.value = fragment;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Let the debounce fire and the promise chain settle */
|
||||||
|
async function settle(ms = DEBOUNCE_MS) {
|
||||||
|
await vi.advanceTimersByTimeAsync(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
document.body.innerHTML =
|
||||||
|
'<span><input id="atocal-attendees" data-search-url="' + SEARCH_URL + '"'
|
||||||
|
+ ' role="combobox" aria-autocomplete="list"'
|
||||||
|
+ ' aria-controls="atocal-suggestions" aria-expanded="false"></span>';
|
||||||
|
|
||||||
|
await import('../../js/invitees.js');
|
||||||
|
|
||||||
|
input = document.getElementById('atocal-attendees');
|
||||||
|
list = document.querySelector('.atocal-suggestions');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
/* Empty fragment hides the list and cancels pending debounces */
|
||||||
|
type('');
|
||||||
|
await settle(0);
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invitee suggestions', () => {
|
||||||
|
it('marks the input as a combobox controlling the list', () => {
|
||||||
|
expect(input.getAttribute('role')).toBe('combobox');
|
||||||
|
expect(input.getAttribute('aria-autocomplete')).toBe('list');
|
||||||
|
|
||||||
|
/* aria-controls must resolve to the list built by the widget */
|
||||||
|
const controlled = document.getElementById(
|
||||||
|
input.getAttribute('aria-controls')
|
||||||
|
);
|
||||||
|
expect(controlled).toBe(list);
|
||||||
|
expect(input.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders matching contacts', async () => {
|
||||||
|
fetch.mockReturnValue(response([BOB]));
|
||||||
|
|
||||||
|
type('bo');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(list.hidden).toBe(false);
|
||||||
|
expect(list.querySelectorAll('[role="option"]')).toHaveLength(1);
|
||||||
|
expect(list.textContent).toContain('bob@example.com');
|
||||||
|
expect(input.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores responses that arrive after the field was cleared', async () => {
|
||||||
|
const inFlight = pendingResponse();
|
||||||
|
fetch.mockReturnValue(inFlight.promise);
|
||||||
|
|
||||||
|
type('bo');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
/* User clears the field while the request is still in flight */
|
||||||
|
type('');
|
||||||
|
|
||||||
|
inFlight.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([BOB]),
|
||||||
|
});
|
||||||
|
await settle(0);
|
||||||
|
|
||||||
|
expect(list.hidden).toBe(true);
|
||||||
|
expect(list.children).toHaveLength(0);
|
||||||
|
expect(input.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores responses that arrive after a contact was picked', async () => {
|
||||||
|
const second = pendingResponse();
|
||||||
|
fetch
|
||||||
|
.mockReturnValueOnce(response([BOB]))
|
||||||
|
.mockReturnValueOnce(second.promise);
|
||||||
|
|
||||||
|
type('bo');
|
||||||
|
await settle();
|
||||||
|
expect(list.hidden).toBe(false);
|
||||||
|
|
||||||
|
/* Typing more triggers a second request */
|
||||||
|
type('bob');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
/* User picks the visible suggestion while the request is in flight */
|
||||||
|
list.querySelector('button').dispatchEvent(
|
||||||
|
new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(input.value).toBe('bob@example.com, ');
|
||||||
|
expect(list.hidden).toBe(true);
|
||||||
|
|
||||||
|
/* The stale response must not reopen the list */
|
||||||
|
second.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([ZOE]),
|
||||||
|
});
|
||||||
|
await settle(0);
|
||||||
|
|
||||||
|
expect(list.hidden).toBe(true);
|
||||||
|
expect(list.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches only the last whitespace-separated fragment', async () => {
|
||||||
|
fetch.mockReturnValue(response([BOB]));
|
||||||
|
|
||||||
|
type('alice@example.com bo');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
SEARCH_URL + '?query=bo',
|
||||||
|
expect.objectContaining({ headers: { Accept: 'application/json' } })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps whitespace-separated entries when picking a contact', async () => {
|
||||||
|
fetch.mockReturnValue(response([BOB]));
|
||||||
|
|
||||||
|
type('alice@example.com bo');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
list.querySelector('button').dispatchEvent(
|
||||||
|
new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(input.value).toBe('alice@example.com bob@example.com, ');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['tests/js/**/*.test.mjs'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user