Skip to content

Commit 2be5958

Browse files
committedJun 4, 2020
version 1.5.0 (see changelog)
1 parent d689208 commit 2be5958

26 files changed

+1757
-264
lines changed
 

‎CHANGELOG.md

+10
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## 1.5.0 - 2020-06-03
2+
### Changed
3+
- Grouped calendar options into 'Manage Appointment Slots'
4+
- Moved 'Attendee Cancels' options to 'Manage Appointment Slots > Advanced Options'
5+
- Moved 'Copy public link' to 'Public Page [...]' menu
6+
### Added
7+
- Options for additional email text
8+
- Added 'Remove Old Appointments' option
9+
- Iframes support
10+
111
## 1.4.16 - 2020-05-20
212
### Added
313
- Option to add 'robots noindex' meta tag

‎appinfo/info.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<name>Appointments</name>
66
<summary>Book appointments into your calendar via secure online form.</summary>
77
<description><![CDATA[Book appointments into your calendar via secure online form. Attendees can confirm or cancel their appointments via an email link.]]></description>
8-
<version>1.4.16</version>
8+
<version>1.5.0</version>
99
<licence>agpl</licence>
1010
<author mail="sergey@srgdev.com" homepage="https://www.srgdev.com">Sergey Mosin</author>
1111
<namespace>Appointments</namespace>

‎appinfo/routes.php

+5
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,10 @@
1313
['name' => 'page#form', 'url' => '/pub/{token}/form', 'verb' => 'GET'],
1414
['name' => 'page#formpost', 'url' => '/pub/{token}/form', 'verb' => 'POST'],
1515
['name' => 'page#cncf', 'url' => '/pub/{token}/cncf', 'verb' => 'GET'],
16+
17+
['name' => 'page#formemb', 'url' => '/embed/{token}/form', 'verb' => 'GET'],
18+
['name' => 'page#formpostemb', 'url' => '/embed/{token}/form', 'verb' => 'POST'],
19+
['name' => 'page#cncfemb', 'url' => '/embed/{token}/cncf', 'verb' => 'GET'],
20+
1621
]
1722
];

‎css/datepicker.css

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎css/icons.scss

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@include icon-black-white('appt-calendar-clock', 'appointments', 1);
2+
@include icon-black-white('appt-calendar', 'appointments', 1);

‎css/style.scss

+87-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
@import "../../core/css/variables.scss";
2+
@import "icons.scss";
3+
24
.srgdev-icon-override{
35
background-repeat: no-repeat;
46
background-position: center;
5-
@include icon-color('calendar', 'places', '#222', 1, true);
7+
@include icon-color('appt-calendar','appointments', '#222222', 1);
68
background-image: var(--srgdev-dot-img);
9+
opacity: 1 !important;
710
}
811
.srgdev_com_circle .action-button__icon--url{
912
background-repeat: no-repeat;
@@ -232,12 +235,62 @@ $appt_grid_ml:6em;
232235
.srgdev-appt-modal_content {
233236
min-width: 50vw;
234237
text-align: center;
235-
margin: 3em 1em;
238+
margin: 2em 1em;
239+
}
240+
.srgdev-appt-modal-header{
241+
font-size: 110%;
242+
font-weight: bold;
243+
margin-bottom: 1.35em;
236244
}
237245

238246
.srgdev-appt-modal-lbl{
239247
margin: 1em 0;
240248
}
249+
.srgdev-appt-modal-lbl_dim{
250+
margin: .75em 0;
251+
color: var(--color-text-lighter);
252+
}
253+
254+
.srgdev-appt-modal-btn{
255+
margin-top: 1.5em;
256+
}
257+
258+
.srgdev-appt-icon_btn{
259+
display: inline-block;
260+
width: 1.5em;
261+
height: 1.5em;
262+
vertical-align: middle;
263+
margin: 0 0 0 .75em;
264+
cursor: pointer;
265+
transform-origin: center center;
266+
}
267+
.srgdev-appt-icon_btn:hover{
268+
transform: scale(1.075);
269+
}
270+
.srgdev-appt-modal_pop{
271+
position: relative;
272+
display: block;
273+
overflow: hidden;
274+
height: 2.25em;
275+
margin: 0 1em -2em;
276+
}
277+
.srgdev-appt-modal_pop_txt{
278+
position: absolute;
279+
display: block;
280+
right: 0;
281+
bottom: 0;
282+
opacity: 1;
283+
transition: transform .25s;
284+
}
285+
.srgdev-appt-modal_pop_txt:before{
286+
content: "\25CF";
287+
margin-right: .5em;
288+
}
289+
.srgdev-appt-modal_pop_txt[data-pop="0"]{
290+
transition: none;
291+
transform: translateX(100%);
292+
opacity: 0;
293+
}
241294
$srgdev-prog-height:3px;
242295

243296
.srgdev-appt-modal-slider{
@@ -458,6 +511,26 @@ textarea.appt-stn-txt-ta{
458511

459512
}
460513

514+
.srgdev-appt_expando_cont{
515+
display: none;
516+
padding-left: 3.25em;
517+
position: relative;
518+
margin-bottom: 1.5em;
519+
padding-bottom: 1.5em;
520+
}
521+
.srgdev-appt_expando_cont:before{
522+
content: "";
523+
position: absolute;
524+
display: block;
525+
left: 1.125em;
526+
top: -.25em;
527+
bottom: 0;
528+
width: .2em;
529+
background: var(--color-border);
530+
}
531+
.srgdev-appt_expando_cont[data-expand="1"]{
532+
display: block;
533+
}
461534

462535

463536
.srgdev-appt-help-sec{
@@ -485,13 +558,23 @@ textarea.appt-stn-txt-ta{
485558
.srgdev-appt-hs-p-h{
486559
margin: .5em 0 0;
487560
}
488-
.srgdev-appt-hs-code{
561+
.srgdev-appt-hs-code,
562+
.srgdev-appt-hs-code_short{
489563
width: 96%;
490564
margin: .5em 0 .75em;
565+
padding: .75em 0 .75em 1em;
566+
background: #eee;
491567
display: block;
492568
font-size: 95%;
493569
cursor: text;
494570
}
571+
.srgdev-appt-hs-code_short {
572+
display: inline-block;
573+
padding: .5em;
574+
margin: 0;
575+
width: auto;
576+
}
577+
495578
.srgdev-appt-hs-tz-img{
496579
width: 90%;
497580
display: block;
@@ -512,7 +595,7 @@ textarea.appt-stn-txt-ta{
512595
height: 1em;
513596
min-width: 1em;
514597
min-height: 1em;
515-
right: 0;
598+
right: 4%;
516599
top: 50%;
517600
margin-top: -.5em;
518601
background-size: 1em;

‎img/appt-calendar-clock.svg

+3
Loading

‎img/appt-calendar.svg

+3
Loading

‎lib/Backend/BCSabreImpl.php

+144-13
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,59 @@ public function __construct(
3232
$this->utils=$utils;
3333
}
3434

35+
/**
36+
* @inheritDoc
37+
*/
38+
function queryRangePast($calIds,$end,$only_empty,$delete){
39+
40+
$cc=count($calIds);
41+
if($cc===0){
42+
return "0";
43+
}
44+
45+
$parser=new XmlService();
46+
$parser->elementMap['{urn:ietf:params:xml:ns:caldav}calendar-query'] = 'Sabre\\CalDAV\\Xml\\Request\\CalendarQueryReport';
47+
48+
$ots=$end->getTimestamp();
49+
$end->setTimestamp($ots+50400);
50+
51+
try {
52+
$result = $parser->parse($this->makeDavReport(null,$end,$only_empty===true?"TENTATIVE":null));
53+
} catch (ParseException $e) {
54+
\OC::$server->getLogger()->error($e);
55+
return null;
56+
}
57+
58+
$utz=$end->getTimezone();
59+
$cnt=0;
60+
61+
if($delete) {
62+
// let's make easier for the DavListener...
63+
$ses = \OC::$server->getSession();
64+
$ses->set(
65+
BackendUtils::APPT_SES_KEY_HINT,
66+
BackendUtils::APPT_SES_SKIP);
67+
}
68+
69+
for($i=0;$i<$cc;$i++) {
70+
$calId=$calIds[$i];
71+
$urls=$this->backend->calendarQuery($calId, $result->filters);
72+
$objs=$this->backend->getMultipleCalendarObjects($calId,$urls);
73+
foreach ($objs as $obj){
74+
$vo=Reader::read($obj['calendardata']);
75+
$ts=$vo->VEVENT->DTEND->getDateTime($utz)->getTimestamp();
76+
if($ts<=$ots){
77+
if($delete){
78+
// $this->deleteCalendarObject("",$calId,$obj["uri"]);
79+
$this->backend->deleteCalendarObject($calId, $obj["uri"]);
80+
}
81+
$cnt++;
82+
}
83+
}
84+
}
85+
return $cnt;
86+
}
87+
3588
/**
3689
* @inheritDoc
3790
*/
@@ -59,7 +112,7 @@ function queryRange($calId, $start, $end,$no_uri=false){
59112
$parser->elementMap['{urn:ietf:params:xml:ns:caldav}calendar-query'] = 'Sabre\\CalDAV\\Xml\\Request\\CalendarQueryReport';
60113
try {
61114
//$no_url(do not filter status) request is for the schedule generator @see grid.js::addPastAppts()
62-
$result = $parser->parse($this->makeDavReport($start,$end,$no_uri===false));
115+
$result = $parser->parse($this->makeDavReport($start,$end,$no_uri===false?"TENTATIVE":null));
63116
} catch (ParseException $e) {
64117
\OC::$server->getLogger()->error($e);
65118
return null;
@@ -242,20 +295,40 @@ function setAttendee($userId, $calId, $uri, $info)
242295
* @inheritDoc
243296
*/
244297
function confirmAttendee($userId, $calId, $uri){
245-
return $this->confirmCancel($calId,$uri,true);
298+
return $this->confirmCancel($userId, $calId,$uri,true);
246299
}
247300

248301
/**
249302
* @inheritDoc
250303
*/
251304
function cancelAttendee($userId, $calId, $uri){
252-
return $this->confirmCancel($calId,$uri,false);
305+
return $this->confirmCancel($userId, $calId,$uri,false);
253306
}
254307

255-
private function confirmCancel($calId, $uri, $do_confirm){
308+
private function confirmCancel($userId, $calId, $uri, $do_confirm){
256309
$ret=[1,null];
257310
$err='';
311+
312+
// check if we have destination calendar
313+
$cls = $this->utils->getUserSettings(
314+
BackendUtils::KEY_CLS, BackendUtils::CLS_DEF,
315+
$userId, $this->appName);
316+
$dcl_id = $cls[BackendUtils::CLS_DEST_ID];
317+
318+
if ($dcl_id != "-1" && $this->getCalendarById($dcl_id, $userId) === null) {
319+
\OC::$server->getLogger()->error("WARNING: bad CLS_DEST_ID calendar with ID " . $dcl_id . " not found");
320+
$dcl_id = "-1";
321+
}
322+
323+
//correct cal_id for cancellations should be "calculated" in the page controller
258324
$d=$this->getObjectData($calId,$uri);
325+
326+
if($d===null && $do_confirm && $dcl_id!=="-1"){
327+
// check dest calendar
328+
$d=$this->getObjectData($dcl_id,$uri);
329+
// if d!==null then appointment is in the dest calendar and it is probably already confirmed, but we still need the date.
330+
}
331+
259332
if($d===null){
260333
$err="Object does not exist: ".$uri;
261334
}else{
@@ -270,11 +343,37 @@ private function confirmCancel($calId, $uri, $do_confirm){
270343
// Already confirmed
271344
$ret=[0,$date];
272345
}else{
273-
if($this->updateObject($calId,$uri,$newData)===false){
274-
$err="Can not update object: ".$uri;
275-
}else{
276-
// Object Update: SUCCESS
277-
$ret=[0,$date];
346+
347+
if($do_confirm && $dcl_id!="-1"){
348+
// different destination calendar
349+
// ONLY for confirmations (cal_id for cancellations should be "calculated" in the page controller)
350+
351+
// 1. delete from original calendar
352+
$ra=$this->deleteCalendarObject($userId,$calId,$uri);
353+
if($ra[0]!==0){
354+
$err = "Can not delete object: " . $uri .", dcl=".$dcl_id;
355+
}else{
356+
// 2. create new calendar object
357+
if($this->createObject($dcl_id,$uri,$newData)===false){
358+
$err = "Can not create object: " . $uri .", dcl=".$dcl_id;
359+
}else{
360+
// 3. update calendar object - this is bad, but as of now only updateObject() triggers a DavEvent that send emails
361+
if ($this->updateObject($dcl_id, $uri, $newData) === false) {
362+
$err = "Can not update object: " . $uri.", dcl=".$dcl_id;
363+
} else {
364+
// Object Update: SUCCESS
365+
$ret = [0, $date];
366+
}
367+
}
368+
}
369+
}else {
370+
// same calendar
371+
if ($this->updateObject($calId, $uri, $newData) === false) {
372+
$err = "Can not update object: " . $uri;
373+
} else {
374+
// Object Update: SUCCESS
375+
$ret = [0, $date];
376+
}
278377
}
279378
}
280379
}
@@ -335,13 +434,45 @@ private function transformCalInfo($c){
335434
}
336435

337436
/**
338-
* @param \DateTime $start
437+
* @param \DateTime|null $start
339438
* @param \DateTime $end
340-
* @param bool $only_tentative
439+
* @param string|null $status
341440
* @return string
342441
*/
343-
public static function makeDavReport($start,$end,$only_tentative){
344-
return '<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav"><D:prop xmlns:D="DAV:"><C:calendar-data/></D:prop><C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"><C:time-range start="'.$start->format(self::TIME_FORMAT).'" end="'.$end->format(self::TIME_FORMAT).'"/></C:comp-filter><C:comp-filter name="VEVENT"><C:prop-filter name="CATEGORIES"><C:text-match>'.BackendUtils::APPT_CAT.'</C:text-match></C:prop-filter>'.($only_tentative?'<C:prop-filter name="STATUS"><C:text-match>TENTATIVE</C:text-match></C:prop-filter>':'').'<C:prop-filter name="RRULE"><C:is-not-defined/></C:prop-filter></C:comp-filter></C:comp-filter></C:filter></C:calendar-query>';
442+
public static function makeDavReport($start,$end,$status){
443+
// return '<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav"><D:prop xmlns:D="DAV:"><C:calendar-data/></D:prop><C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"><C:time-range '.($start!==null?('start="'.$start->format(self::TIME_FORMAT).'"' ):'').' end="'.$end->format(self::TIME_FORMAT).'"/></C:comp-filter><C:comp-filter name="VEVENT"><C:prop-filter name="CATEGORIES"><C:text-match>'.BackendUtils::APPT_CAT.'</C:text-match></C:prop-filter>'
444+
// .($status!==null?'<C:prop-filter name="STATUS"><C:text-match>'.$status.'</C:text-match></C:prop-filter>':'').
445+
// '<C:prop-filter name="RRULE"><C:is-not-defined/></C:prop-filter></C:comp-filter></C:comp-filter></C:filter></C:calendar-query>';
446+
447+
return '
448+
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
449+
<D:prop xmlns:D="DAV:">
450+
<C:calendar-data/>
451+
</D:prop>
452+
<C:filter>
453+
<C:comp-filter name="VCALENDAR">
454+
<C:comp-filter name="VEVENT">
455+
<C:prop-filter name="CATEGORIES">
456+
<C:text-match>'.BackendUtils::APPT_CAT.'</C:text-match>
457+
</C:prop-filter>
458+
</C:comp-filter>'
459+
.($status!==null?'
460+
<C:comp-filter name="VEVENT">
461+
<C:prop-filter name="STATUS">
462+
<C:text-match>'.$status.'</C:text-match>
463+
</C:prop-filter>
464+
</C:comp-filter>':'').
465+
'<C:comp-filter name="VEVENT">
466+
<C:prop-filter name="RRULE">
467+
<C:is-not-defined/>
468+
</C:prop-filter>
469+
</C:comp-filter>
470+
<C:comp-filter name="VEVENT">
471+
<C:time-range '.($start!==null?('start="'.$start->format(self::TIME_FORMAT).'"' ):'').' end="'.$end->format(self::TIME_FORMAT).'"/>
472+
</C:comp-filter>
473+
</C:comp-filter>
474+
</C:filter>
475+
</C:calendar-query>';
345476
}
346477

347478
}

‎lib/Backend/BackendUtils.php

+44-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
namespace OCA\Appointments\Backend;
88

99
use OCA\Appointments\AppInfo\Application;
10+
use OCA\Appointments\Controller\PageController;
1011
use Sabre\VObject\Reader;
1112

1213
class BackendUtils{
@@ -45,14 +46,28 @@ class BackendUtils{
4546
public const EML_MREQ = 'meReq';
4647
public const EML_MCONF = 'meConfirm';
4748
public const EML_MCNCL = 'meCancel';
49+
public const EML_VLD_TXT = 'vldNote';
50+
public const EML_CNF_TXT = 'cnfNote';
51+
4852
const EML_DEF=array(
4953
self::EML_ICS=>false,
5054
self::EML_SKIP_EVS=>false,
5155
self::EML_AMOD=>false,
5256
self::EML_ADEL=>false,
5357
self::EML_MREQ=>false,
5458
self::EML_MCONF=>false,
55-
self::EML_MCNCL=>false);
59+
self::EML_MCNCL=>false,
60+
self::EML_VLD_TXT=>"",
61+
self::EML_CNF_TXT=>"");
62+
63+
// Calendar Settings
64+
public const KEY_CLS = 'calendar_settings';
65+
public const CLS_DEST_ID= 'destCalId';
66+
public const CLS_ON_CANCEL = 'whenCanceled';
67+
const CLS_DEF=array(
68+
self::CLS_DEST_ID=>'-1',
69+
self::CLS_ON_CANCEL=>'mark'
70+
);
5671

5772
/**
5873
* @param \Sabre\VObject\Document $vo
@@ -167,7 +182,7 @@ function dataSetAttendee($data, $info, $userId){
167182
/**
168183
* @param $data
169184
* @return array [string|null, string|null]
170-
* null=error|""=already confirmed
185+
* null=error|""=already confirmed,
171186
* Localized DateTime string
172187
*/
173188
function dataConfirmAttendee($data){
@@ -210,12 +225,19 @@ function dataConfirmAttendee($data){
210225
*/
211226
function dataCancelAttendee($data){
212227

213-
$vo=$this->getAppointment($data,'CONFIRMED');
228+
$vo=$this->getAppointment($data,'*');
214229
if($vo===null) return [null,null];
215230

216231
/** @var \Sabre\VObject\Component\VEvent $evt*/
217232
$evt=$vo->VEVENT;
218233

234+
if($evt->STATUS->getValue()==='TENTATIVE'){
235+
// Can not cancel tentative appointments
236+
return [null,null];
237+
}
238+
239+
240+
219241
/** @var \Sabre\VObject\Property $a*/
220242
$a=$evt->ATTENDEE[0];
221243

@@ -509,7 +531,26 @@ function getAppointment($data,$status){
509531
* @return array
510532
*/
511533
function getUserSettings($key,$default,$userId,$appName){
534+
512535
$config=\OC::$server->getConfig();
536+
537+
// TODO: remove in future versions
538+
if($key===self::KEY_CLS && empty($config->getUserValue($userId,$appName,self::KEY_CLS))){
539+
// First time access need to transfer...
540+
// PageController::PSN_ON_CANCEL -> BackendUtils::CLS_ON_CANCEL
541+
$a=$this->getUserSettings(
542+
PageController::KEY_PSN,
543+
PageController::PSN_DEF,
544+
$userId,$appName);
545+
546+
$vs='{"'.PageController::PSN_ON_CANCEL.'":"'.$a[PageController::PSN_ON_CANCEL].'"}';
547+
548+
$this->setUserSettings(
549+
self::KEY_CLS,
550+
$vs, self::CLS_DEF,
551+
$userId,$appName);
552+
}
553+
513554
$sa=json_decode(
514555
$config->getUserValue($userId,$appName,$key),
515556
true);

‎lib/Backend/DavListener.php

+20-3
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ public function handle(GenericEvent $event, $eventName): void{
4242

4343
$ses=\OC::$server->getSession();
4444
$hint=$ses->get(BackendUtils::APPT_SES_KEY_HINT);
45-
if($hint===BackendUtils::APPT_SES_SKIP){
45+
if($hint===BackendUtils::APPT_SES_SKIP
46+
|| ($eventName===self::DEL_EVT_NAME && $hint===BackendUtils::APPT_SES_CONFIRM) // <-- booking in to a different calendar NOT deleting
47+
){
4648
// no need for email
4749
return;
4850
}
@@ -89,8 +91,14 @@ public function handle(GenericEvent $event, $eventName): void{
8991

9092
// $event['calendarData']['id'] can be a string or an int
9193
if($cal_id!=$event['calendarData']['id']){
92-
// Not this user's calendar
93-
return;
94+
// Check dest calendar
95+
$cls=$utils->getUserSettings(
96+
BackendUtils::KEY_CLS,BackendUtils::CLS_DEF,
97+
$userId ,$this->appName);
98+
if($cls[BackendUtils::CLS_DEST_ID]!=$event['calendarData']['id']){
99+
// Not this user's calendar
100+
return;
101+
}
94102
}
95103

96104
$hash=$utils->getApptHash($evt->UID->getValue());
@@ -195,6 +203,7 @@ public function handle(GenericEvent $event, $eventName): void{
195203
$om_info=$evt->DESCRIPTION->getValue();
196204
}
197205

206+
198207
if($hint === BackendUtils::APPT_SES_BOOK){
199208
// Just booked, send email to the attendee requesting confirmation...
200209

@@ -217,6 +226,10 @@ public function handle(GenericEvent $event, $eventName): void{
217226
$btn_url.'0'.$btn_tkn
218227
);
219228

229+
if(!empty($eml_settings[BackendUtils::EML_VLD_TXT])){
230+
$tmpl->addBodyText($eml_settings[BackendUtils::EML_VLD_TXT]);
231+
}
232+
220233
if($eml_settings[BackendUtils::EML_MREQ]){
221234
$om_prefix=$this->l10N->t("Appointment pending");
222235
}
@@ -231,6 +244,10 @@ public function handle(GenericEvent $event, $eventName): void{
231244
// TRANSLATORS Main body of email,Ex: Your {{Organization Name}} appointment scheduled for {{Date Time}} is now confirmed.
232245
$tmpl->addBodyText($this->l10N->t('Your %1$s appointment scheduled for %2$s is now confirmed.',[$org_name,$date_time]));
233246

247+
if(!empty($eml_settings[BackendUtils::EML_CNF_TXT])){
248+
$tmpl->addBodyText($eml_settings[BackendUtils::EML_CNF_TXT]);
249+
}
250+
234251
if($eml_settings[BackendUtils::EML_MCONF]) {
235252
$om_prefix = $this->l10N->t("Appointment confirmed");
236253
}

‎lib/Backend/IBackendConnector.php

+11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22
namespace OCA\Appointments\Backend;
33

44
interface IBackendConnector{
5+
6+
7+
/**
8+
* @param string[] $calIds
9+
* @param \DateTime $end
10+
* @param bool $only_empty
11+
* @param bool $delete if false just count
12+
* @return mixed
13+
*/
14+
function queryRangePast($calIds,$end,$only_empty,$delete);
15+
516
/**
617
* @param string $calId
718
* @param \DateTime $start should have user's timezone

‎lib/Controller/PageController.php

+285-36
Large diffs are not rendered by default.

‎scss/datepicker/index.scss

+3
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@
225225
&.#{$namespace}-active-week {
226226
background-color: $calendar-in-range-background-color;
227227
color: $calendar-in-range-color;
228+
.today{
229+
color: $default-color;
230+
}
228231
}
229232
.cell {
230233
&:hover {

‎src/App.vue

+364-152
Large diffs are not rendered by default.

‎src/components/AddApptSection.vue

+211
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
<template>
2+
<div class="appt-gen-wrap">
3+
<label class="datepicker-label">{{t('appointments','Select Dates:')}}</label>
4+
<DatePicker
5+
style="width: 100%"
6+
:editable="false"
7+
:disabled-date="compNotBefore"
8+
:appendToBody="false"
9+
:popup-style="datePickerPopupStyle"
10+
:placeholder="t('appointments','Select Dates')"
11+
v-model="apptWeek"
12+
:lang="lang"
13+
@input="setToStartOfWeek"
14+
:format="weekFormat"
15+
type="week"></DatePicker>
16+
<label for="appt_dur-select" class="select-label">{{t('appointments','Appointment Duration:')}}</label>
17+
<vue-slider
18+
:min="10"
19+
:max="120"
20+
:interval="5"
21+
tooltip="always"
22+
tooltipPlacement="bottom"
23+
:tooltip-formatter="'{value} Min'"
24+
id="appt_dur-select"
25+
class="appt-slider"
26+
v-model="apptDur"></vue-slider>
27+
<div class="srgdev-appt-info-lcont">
28+
<label for="appt_tz-select" class="select-label">{{t('appointments','Timezone:')}}</label>
29+
<a
30+
class="icon-info srgdev-appt-info-link"
31+
@click="$root.$emit('helpWanted','timezone')"><span>Please read</span></a>
32+
</div>
33+
<select v-model="apptTZ" id="appt_tz-select" class="appt-select">
34+
<option value="L">Local (floating)</option>
35+
<option value="C">{{tzName}}</option>
36+
</select>
37+
<button
38+
@click="goApptGen"
39+
:disabled="apptWeek===null"
40+
class="primary srgdev-appt-sb-genbtn">{{t('appointments','Start')}}
41+
</button>
42+
</div>
43+
</template>
44+
45+
<script>
46+
import DatePicker from 'vue2-datepicker'
47+
import '../../css/datepicker.css';
48+
49+
import VueSlider from 'vue-slider-component'
50+
import 'vue-slider-component/theme/default.css'
51+
52+
export default {
53+
name: "AddApptSection",
54+
components: {
55+
VueSlider,
56+
DatePicker
57+
},
58+
props:{
59+
tzName:'',
60+
tzData:''
61+
},
62+
computed:{
63+
lang: function(){
64+
let days=undefined
65+
let months=undefined
66+
const formatLocale={
67+
firstDayOfWeek:window.firstDay||0
68+
}
69+
if(window.Intl && typeof window.Intl === "object"){
70+
days=[]
71+
let d=new Date(1970,1,1)
72+
let f = new Intl.DateTimeFormat([],
73+
{weekday: "short",})
74+
for(let i=1;i<8;i++){
75+
d.setDate(i)
76+
days[i-1]=f.format(d)
77+
}
78+
f = new Intl.DateTimeFormat([],
79+
{month: "short",})
80+
d.setDate(1)
81+
months=[]
82+
for(let i=0;i<12;i++){
83+
d.setMonth(i)
84+
months[i]=f.format(d)
85+
}
86+
formatLocale.monthsShort=months
87+
}
88+
return {days:days,formatLocale:formatLocale}
89+
},
90+
notBeforeDate(){
91+
let d=new Date()
92+
d.setHours(0)
93+
d.setMinutes(0)
94+
d.setTime(this.getStartOfWeek(d).getTime()-90000000)
95+
return d
96+
}
97+
},
98+
watch: {
99+
tzName(val){
100+
this.apptTZ = val === 'UTC' ? "L" : "C";
101+
}
102+
},
103+
104+
data() {
105+
return {
106+
/** @type {Date} */
107+
apptWeek:null,
108+
109+
apptDur:30,
110+
111+
apptTZ:"C",
112+
113+
datePickerPopupStyle:{
114+
top:"75%",
115+
left:"50%",
116+
transform: "translate(-50%,0)"
117+
},
118+
weekFormat: {
119+
// Date to String
120+
stringify: (date,fmt) => {
121+
122+
if(date){
123+
const ts=date.getTime() + 5 * 86400000;
124+
if(window.Intl && typeof window.Intl === "object") {
125+
let f = new Intl.DateTimeFormat([],
126+
{month: "short", day: "2-digit",})
127+
return f.format(date) + ' - ' + f.format(new Date(ts))
128+
}else{
129+
return date.toLocaleDateString()+' - '+(new Date(ts)).toLocaleDateString()
130+
}
131+
}else return ''
132+
}
133+
},
134+
}
135+
},
136+
methods: {
137+
getTimeFormat(){
138+
let date = new Date(0);
139+
if(date.toLocaleTimeString().indexOf("PM")===-1){
140+
return 'HH:mm'
141+
}else{
142+
return 'hh:mm A'
143+
}
144+
},
145+
setToStartOfWeek(){
146+
if(this.apptWeek!==null) {
147+
this.apptWeek=this.getStartOfWeek(this.apptWeek)
148+
}
149+
},
150+
getStartOfWeek(d){
151+
152+
let gd=d.getDay()
153+
if (this.lang.formatLocale.firstDayOfWeek === 1) {
154+
// Sunday (0) is last
155+
if(gd===0) gd=6
156+
else gd--
157+
}else{
158+
gd--
159+
}
160+
return new Date(d.getTime() - gd*86400000)
161+
},
162+
compNotBefore(d){
163+
return d<this.notBeforeDate
164+
},
165+
resetAppt(){
166+
this.apptWeek=null
167+
this.apptDur=30
168+
},
169+
goApptGen(){
170+
let r={
171+
tz: this.apptTZ==="C"?this.tzData:"L",
172+
week:(this.apptWeek.getTime()),
173+
dur:this.apptDur,
174+
}
175+
this.resetAppt()
176+
this.$emit("agDataReady",r)
177+
}
178+
}
179+
}
180+
</script>
181+
182+
<style scoped>
183+
.appt-gen-wrap{
184+
text-align: left;
185+
display: inline-block;
186+
}
187+
.datepicker-label,
188+
.select-label{
189+
display: block;
190+
margin-top: 1em;
191+
}
192+
.datepicker-label{
193+
margin-top: 0;
194+
}
195+
.select-label{
196+
margin-bottom: .25em;
197+
}
198+
.appt-slider{
199+
margin-bottom: 3em;
200+
}
201+
.appt-select {
202+
margin: 0;
203+
width: 100%;
204+
padding: 0 0 0 .25em;
205+
}
206+
/*.appt-genbtn{*/
207+
/* min-width: 80%;*/
208+
/* margin: 2.5em auto 0;*/
209+
/* display: block;*/
210+
/*}*/
211+
</style>

‎src/components/ApptIconButton.vue

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<template>
2+
<div @click="doClick" class="appt_icon_button_cont">
3+
<div role="button"
4+
:class="['appt_icon_button',{'disabled':disabled}]">
5+
<span :class="['aib_icon_wrap',icon,{'icon-loading':loading}]"></span><span class="aib_text_span">{{text}}</span>
6+
</div>
7+
<div class="aib_actions_slot">
8+
<slot name="actions"/>
9+
</div>
10+
</div>
11+
</template>
12+
13+
<script>
14+
export default {
15+
name: "ApptIconButton",
16+
props: {
17+
text: {
18+
type: String,
19+
default: '',
20+
required: true
21+
},
22+
icon: {
23+
type: String,
24+
default: ''
25+
},
26+
disabled: {
27+
type: Boolean,
28+
default: false
29+
},
30+
loading: {
31+
type: Boolean,
32+
default: false
33+
},
34+
},
35+
methods:{
36+
doClick(){
37+
if(!this.loading && !this.disabled){
38+
this.$emit('click')
39+
}
40+
}
41+
}
42+
}
43+
</script>
44+
45+
<style scoped lang="scss">
46+
.appt_icon_button_cont{
47+
margin: .25em 0;
48+
position: relative;
49+
.aib_actions_slot{
50+
position: absolute;
51+
right: 1em;
52+
top: 0;
53+
height: 100%; }
54+
}
55+
.appt_icon_button{
56+
display: inline-block;
57+
vertical-align: middle;
58+
padding: .5em;
59+
cursor: pointer;
60+
position: relative;
61+
62+
.aib_icon_wrap,
63+
.aib_text_span{
64+
display: inline-block;
65+
vertical-align: middle;
66+
}
67+
.aib_icon_wrap{
68+
width: 1.5em;
69+
height: 1.5em;
70+
opacity: .7;
71+
cursor: inherit;
72+
}
73+
.aib_text_span{
74+
margin-left: .75em;
75+
color: var(--color-main-text);
76+
height: 2em;
77+
line-height: 2em;
78+
cursor: inherit;
79+
}
80+
81+
&:hover {
82+
.aib_icon_wrap {
83+
opacity: 1;
84+
transform: scale(1.1);
85+
transform-origin: center;
86+
}
87+
}
88+
89+
&.disabled{
90+
pointer-events: none;
91+
opacity: .75;
92+
};
93+
}
94+
</style>

‎src/components/FormStnSlideBar.vue

-11
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,6 @@
6262
id="srgdev-appt_pps-gdpr"
6363
type="text"
6464
:placeholder="t('appointments','See Tutorial...')">
65-
<label
66-
class="pps-label"
67-
for="srgdev-appt_pps-appt-reset">
68-
{{t('appointments','When Attendee Cancels')}}:</label>
69-
<select
70-
v-model="ppsInfo.whenCanceled"
71-
class="pps-input"
72-
id="srgdev-appt_pps-appt-reset">
73-
<option value="mark">{{t('appointments','Mark the appointment as canceled')}}</option>
74-
<option value="reset">{{t('appointments','Reset (make the timeslot available)')}}</option>
75-
</select>
7665
<div style="padding-top: .25em"
7766
class="srgdev-appt-sb-chb-cont"><input
7867
v-model="ppsInfo.hidePhone"

‎src/components/MailStnSlideBar.vue

+30-2
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@
4646
id="srgdev-appt_emn-me-cancel"
4747
class="checkbox"><label class="srgdev-appt-sb-label-inline" for="srgdev-appt_emn-me-cancel">{{t('appointments','Canceled')}}</label><br>
4848
</div>
49-
<div class="srgdev-appt-info-lcont">
49+
<div
50+
style="margin-bottom: .75em"
51+
class="srgdev-appt-info-lcont">
5052
<input
5153
v-model="emlInfo.skipEVS"
5254
type="checkbox"
@@ -55,6 +57,30 @@
5557
class="icon-info srgdev-appt-info-link"
5658
@click="$root.$emit('helpWanted','emailskipevs')"></a>
5759
</div>
60+
<label
61+
v-show="emlInfo.skipEVS===false"
62+
class="srgdev-appt-sb-label-inline"
63+
for="srgdev-appt_emn-vld-note">
64+
{{t('appointments','Additional VALIDATION email text:')}}</label>
65+
<textarea
66+
v-show="emlInfo.skipEVS===false"
67+
v-model="emlInfo.vldNote"
68+
class="srgdev-appt-sb-textarea"
69+
id="srgdev-appt_emn-vld-note"
70+
></textarea>
71+
<div class="srgdev-appt-info-lcont">
72+
<label
73+
class="srgdev-appt-sb-label-inline"
74+
for="srgdev-appt_emn-cnf-note">
75+
{{t('appointments','Additional CONFIRMATION email text:')}}</label><a
76+
class="icon-info srgdev-appt-info-link"
77+
@click="$root.$emit('helpWanted','emailmoretext')"></a>
78+
</div>
79+
<textarea
80+
v-model="emlInfo.cnfNote"
81+
class="srgdev-appt-sb-textarea"
82+
id="srgdev-appt_emn-cnf-note"
83+
></textarea>
5884
<button
5985
@click="apply"
6086
class="primary srgdev-appt-sb-genbtn">{{t('appointments','Apply')}}
@@ -86,7 +112,9 @@
86112
attDel: false,
87113
meReq: false,
88114
meConfirm: false,
89-
meCancel: false
115+
meCancel: false,
116+
vldNote: "",
117+
cnfNote: ""
90118
}
91119
}
92120
}

‎src/components/NavAccountItem.vue

+10-9
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<template>
2-
<AppNavigationItem
2+
<ApptIconButton
33
:key="curCal.name"
4-
:title="curCal.name"
5-
:icon="calLoading?null:curCal.icon"
4+
:text="curCal.name"
5+
:icon="curCal.rIcon===''?curCal.icon:'srgdev-icon-override'"
66
:style="curCal.rIcon"
7-
:loading="calLoading">
8-
<Actions menuAlign="right" @open="getCalendars" forceMenu slot="counter">
7+
:loading="curCal.isCalLoading">
8+
<Actions menuAlign="right" @open="getCalendars" forceMenu slot="actions">
99
<ActionButton
1010
v-for="(cal,index) in calendars"
1111
@click="setCalendarFromIndex(index)"
@@ -17,23 +17,24 @@
1717
closeAfterClick>
1818
</ActionButton>
1919
</Actions>
20-
</AppNavigationItem>
20+
</ApptIconButton>
2121
</template>
2222

2323
<script>
2424
// noinspection ES6CheckImport
2525
import{
26-
AppNavigationItem,
2726
ActionButton,
2827
Actions,
2928
} from '@nextcloud/vue'
3029
import axios from '@nextcloud/axios'
3130
import {detectColor} from "../utils.js";
31+
import ApptIconButton from "./ApptIconButton";
32+
3233
3334
export default {
3435
name: 'NavAccountItem',
3536
props:[
36-
'curCal','calLoading'
37+
'curCal'
3738
],
3839
data: function() {
3940
return {
@@ -42,7 +43,7 @@
4243
};
4344
},
4445
components: {
45-
AppNavigationItem,
46+
ApptIconButton,
4647
ActionButton,
4748
Actions,
4849
},

‎src/components/ScheduleSlideBar.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<template>
22
<SlideBar :title="this.title" :subtitle="subtitle" @close="function() {
33
resetAppt()
4-
$emit('close')
4+
$emit('back')
55
}">
66
<template slot="main-area">
77
<div class="appt-gen-wrap">

‎src/components/SlideBar.vue

+5-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<header :class="{'app-sidebar-header--with-figure': hasFigure}"
55
class="app-sidebar-header">
66
<!-- close sidebar button -->
7-
<a href="#" class="app-sidebar__close icon-close" :title="t('appointments','close')"
7+
<a href="#" :class="['app-sidebar__close', icon]" :title="t('appointments','close')"
88
@click.prevent="closeSidebar" />
99

1010
<!-- sidebar header illustration/figure -->
@@ -57,6 +57,10 @@
5757
type: String,
5858
default: ''
5959
},
60+
icon: {
61+
type: String,
62+
default: 'icon-close'
63+
},
6064
/**
6165
* Url to the top header background image
6266
* Applied with css

‎src/components/TimeSlotSlideBar.vue

+353
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
<template>
2+
<SlideBar :title="t('appointments','Calendars and Schedule')" :subtitle="t('appointments','Manage appointments and calendar settings')" @close="close">
3+
<template slot="main-area">
4+
<div class="srgdev-appt-sb-main-cont">
5+
<NavAccountItem
6+
v-on="$listeners"
7+
:curCal="curCal"></NavAccountItem>
8+
9+
<ApptIconButton
10+
:disabled="curCal.url===''"
11+
:loading="tzLoading"
12+
@click="openAddAppts"
13+
text="Add Appointment Slots"
14+
icon="icon-add">
15+
<Actions v-show="expando[2]===1" slot="actions">
16+
<ActionButton @click.stop="toggleExpando(2)" icon="icon-triangle-n"></ActionButton>
17+
</Actions>
18+
</ApptIconButton>
19+
<div :data-expand="expando[2]" class="srgdev-appt_expando_cont">
20+
<AddApptSection
21+
v-on="$listeners"
22+
@agDataReady="function() {
23+
toggleExpando(2)
24+
close()
25+
}"
26+
:tz-data="tzData"
27+
:tz-name="tzName">
28+
</AddApptSection>
29+
</div>
30+
<ApptIconButton
31+
:disabled="curCal.url===''"
32+
@click="openRemOld"
33+
text="Remove Old Appointments"
34+
icon="icon-delete">
35+
<Actions v-show="expando[0]===1" slot="actions">
36+
<ActionButton @click.stop="toggleExpando(0)" icon='icon-triangle-n'></ActionButton>
37+
</Actions>
38+
</ApptIconButton>
39+
<div :data-expand="expando[0]" class="srgdev-appt_expando_cont">
40+
<label for="appt_tsb-rem-slider">{{t('appointments','Scheduled before')}}:</label>
41+
<vue-slider
42+
v-model="rsValue"
43+
:marks="rsMarks"
44+
:process="true"
45+
:included="true"
46+
:lazy="true"
47+
tooltip="none"
48+
@change="checkRsMin"
49+
id="appt_tsb-rem-slider"
50+
class="appt-slider"></vue-slider>
51+
<input type="radio"
52+
value="empty"
53+
v-model="remType"
54+
id="appt_tsb-rem-empty"
55+
class="radio"
56+
checked="checked">
57+
<label for="appt_tsb-rem-empty">{{t('appointments','Remove empty slots only')}}</label><br>
58+
<input type="radio"
59+
value="both"
60+
v-model="remType"
61+
id="appt_tsb-rem-both"
62+
class="radio">
63+
<label for="appt_tsb-rem-both">{{t('appointments','Remove empty and booked')}}</label><br>
64+
<button
65+
@click="removeOld"
66+
class="primary srgdev-appt-sb-genbtn">{{t('appointments','Start')}}
67+
</button>
68+
</div>
69+
<ApptIconButton
70+
:disabled="curCal.url===''"
71+
:loading="calInfo.isLoading"
72+
@click="openCalSettings"
73+
text="Advanced Settings"
74+
icon="icon-settings">
75+
<Actions v-show="expando[1]===1" slot="actions">
76+
<ActionButton @click.stop="toggleExpando(1)" icon="icon-triangle-n"></ActionButton>
77+
</Actions>
78+
</ApptIconButton>
79+
<div :data-expand="expando[1]" class="srgdev-appt_expando_cont">
80+
81+
<label
82+
class="tsb-label"
83+
for="appt_tsb-appt-reset">
84+
{{t('appointments','When Attendee Cancels')}}:</label>
85+
<select
86+
v-model="calInfo.whenCanceled"
87+
class="tsb-input"
88+
id="appt_tsb-appt-reset">
89+
<option value="mark">{{t('appointments','Mark the appointment as canceled')}}</option>
90+
<option value="reset">{{t('appointments','Reset (make the timeslot available)')}}</option>
91+
</select>
92+
<div class="srgdev-appt-info-lcont">
93+
<label
94+
class="tsb-label"
95+
for="appt_tsb-dest-cal-id">
96+
{{t('appointments','Calendar for booked appointments')}}:</label><a
97+
style="right: 9%"
98+
class="icon-info srgdev-appt-info-link"
99+
@click="$root.$emit('helpWanted','destcal')"></a>
100+
</div>
101+
<select
102+
v-model="calInfo.destCalId"
103+
class="tsb-input"
104+
id="appt_tsb-dest-cal-id">
105+
<option value="-1">{{curCal.name}}</option>
106+
<option v-for="cal in cals" :value="cal.id">{{cal.name}}</option>
107+
</select>
108+
<button
109+
@click="applyCalSettings"
110+
class="primary srgdev-appt-sb-genbtn">{{t('appointments','Apply')}}
111+
</button>
112+
</div>
113+
</div>
114+
</template>
115+
</SlideBar>
116+
</template>
117+
118+
<script>
119+
import SlideBar from "./SlideBar.vue"
120+
import ApptIconButton from "./ApptIconButton";
121+
import NavAccountItem from "./NavAccountItem";
122+
import AddApptSection from "./AddApptSection";
123+
124+
import{
125+
ActionButton,
126+
Actions,
127+
} from '@nextcloud/vue'
128+
129+
import VueSlider from 'vue-slider-component'
130+
import 'vue-slider-component/theme/default.css'
131+
132+
import axios from '@nextcloud/axios'
133+
import {linkTo} from '@nextcloud/router'
134+
import {detectColor} from "../utils";
135+
136+
137+
export default {
138+
name: "TimeSlotSlideBar",
139+
components: {
140+
SlideBar,
141+
ApptIconButton,
142+
NavAccountItem,
143+
VueSlider,
144+
Actions,
145+
ActionButton,
146+
AddApptSection,
147+
},
148+
props: {
149+
isGridReady:{
150+
type: Boolean,
151+
default: false
152+
},
153+
curCal:{
154+
type: Object,
155+
default: function () {
156+
return {
157+
icon: "icon-calendar-dark",
158+
name: "Select Calendar",
159+
url: "",
160+
rIcon: "",
161+
clr: "",
162+
isCalLoading:false
163+
}
164+
}
165+
},
166+
calInfo: {
167+
type: Object,
168+
default: function () {
169+
return {
170+
whenCanceled:"mark",
171+
destCalId:"-1",
172+
isLoading:false,
173+
isReady:false
174+
}
175+
},
176+
}
177+
178+
},
179+
watch: {
180+
'calInfo.isReady':function (val) {
181+
if(val===true){
182+
this.toggleExpando(1)
183+
}
184+
},
185+
},
186+
187+
computed:{
188+
rsMarks:function(){
189+
const options = {month: 'short', day: '2-digit' };
190+
let d=new Date()
191+
d.setTime(Date.now()-86400000)
192+
const y=d.toLocaleString(undefined,options)
193+
d.setTime(d.getTime()-86400000*6)
194+
const w=d.toLocaleString(undefined,options)
195+
return {
196+
0:'-∞',
197+
58:w,
198+
100:y,
199+
}
200+
}
201+
202+
},
203+
204+
data: function() {
205+
return {
206+
expando:[0,0,0],
207+
rsValue:58,
208+
remType:"empty",
209+
210+
tzName:'',
211+
tzData:'',
212+
tzLoading:false,
213+
214+
cals:[]
215+
};
216+
},
217+
218+
methods: {
219+
220+
removeOld(){
221+
this.$emit("remOldAppts",{type:this.remType,before:this.rsValue==="100"?1:7})
222+
},
223+
openRemOld(){
224+
// this is need to fetch calInfo
225+
if(this.expando[0]===0) {
226+
this.$emit('getCalInfo', 'openNot')
227+
}
228+
this.toggleExpando(0)
229+
},
230+
231+
applyCalSettings(){
232+
this.$emit('setCalInfo',this.calInfo)
233+
},
234+
235+
openCalSettings(){
236+
if(this.expando[1]===1){
237+
// just close
238+
this.toggleExpando(1)
239+
}else{
240+
this.$emit('getCalInfo')
241+
this.getCalList()
242+
}
243+
244+
},
245+
246+
getCalList(){
247+
this.cals.splice(0,this.cals.length)
248+
axios.get('callist')
249+
.then(response=>{
250+
let cals=response.data.split(String.fromCharCode(31))
251+
const curCalId=this.curCal.url // url is id
252+
for(let i=0,l=cals.length;i<l;i++){
253+
let cal=cals[i].split(String.fromCharCode(30))
254+
if(curCalId!==cal[2])
255+
this.cals.push({
256+
name:cal[0],
257+
id:cal[2]
258+
})
259+
}
260+
})
261+
.catch(function (error) {
262+
console.log(error);
263+
})
264+
},
265+
266+
267+
openAddAppts: function(){
268+
269+
if(this.expando[2]===1){
270+
this.toggleExpando(2)
271+
return;
272+
}
273+
274+
this.tzLoading=true
275+
276+
if(!this.isGridReady){
277+
this.$emit('setupGrid')
278+
}
279+
280+
this.tzName="UTC"
281+
this.tzData="UTC"
282+
283+
this.$parent.$parent.getState("get_tz").then(res=>{
284+
if(res!==null && res.toLowerCase()!=='utc') {
285+
let url=linkTo('appointments','ajax/zones.json')
286+
return axios.get(url).then(tzr=>{
287+
if(tzr.status===200) {
288+
let tzd=tzr.data
289+
if(typeof tzd==="object"
290+
&& tzd.hasOwnProperty('aliases')
291+
&& tzd.hasOwnProperty('zones')
292+
){
293+
let tzs=""
294+
if(tzd.zones[res]!==undefined){
295+
tzs=tzd.zones[res].ics.join("\r\n")
296+
}else if(tzd.aliases[res]!==undefined){
297+
let alias=tzd.aliases[res].aliasTo
298+
if(tzd.zones[alias]!==undefined){
299+
res=alias
300+
tzs=tzd.zones[alias].ics.join("\r\n")
301+
}
302+
}
303+
return [res,tzs]
304+
}
305+
}
306+
return null
307+
})
308+
}else return Promise.resolve(null)
309+
}).then((r)=>{
310+
if(r===null || !Array.isArray(r) || r.length!==2 || r[1]===""){
311+
console.error("can't get timezone data")
312+
}else{
313+
this.tzName=r[0]
314+
this.tzData= "BEGIN:VTIMEZONE\r\nTZID:"
315+
+r[0].trim()+"\r\n"+r[1].trim()+"\r\nEND:VTIMEZONE"
316+
}
317+
this.tzLoading=false;
318+
this.toggleExpando(2)
319+
}).catch(err=>{
320+
console.log(err)
321+
this.tzLoading=false;
322+
this.toggleExpando(2)
323+
})
324+
},
325+
326+
327+
toggleExpando(expId){
328+
this.expando.splice(expId, 1, this.expando[expId]^1)
329+
},
330+
checkRsMin(){
331+
if(+this.rsValue<58) this.rsValue="58"
332+
},
333+
close(){
334+
this.$emit('close')
335+
},
336+
}
337+
}
338+
</script>
339+
<style scoped>
340+
#appt_tsb-rem-slider{
341+
margin: .25em 4.5em 3.25em 0;
342+
}
343+
.tsb-label{
344+
display: block;
345+
}
346+
.tsb-input{
347+
margin-top: 0;
348+
display: block;
349+
min-width: 80%;
350+
margin-bottom: 1em;
351+
}
352+
353+
</style>

‎src/grid.js

+33-22
Original file line numberDiff line numberDiff line change
@@ -182,36 +182,47 @@ function _apptGridMaker() {
182182
function addPastAppts(data,clr) {
183183

184184
const btm=DH*12; // 12*5min=1hour
185-
for(let sp,tzo,ets,elm,uTop,d=new Date(),ds,uLen,cID,da=data.split(","),
186-
l=da.length,i=0;i<l;i++){
187-
ds=da[i]
185+
const pd=data.split(String.fromCharCode(31))
186+
for(let pds,j=0,ll=pd.length;j<ll;j++) {
187+
pds=pd[j]
188+
if(pds.length<3) continue
189+
if(j>0){
190+
const sep=pds.indexOf(String.fromCharCode(30))
191+
if(sep===-1) continue
192+
clr=pds.substr(0,sep)
193+
pds=pds.substr(sep+1)
194+
}
195+
for (let sp, tzo, ets, elm, uTop, d = new Date(), ds, uLen, cID, da = pds.split(","),
196+
l = da.length, i = 0; i < l; i++) {
197+
ds = da[i]
188198

189-
sp=ds.indexOf(":",8);
199+
sp = ds.indexOf(":", 8);
190200

191-
//get end time first
192-
d.setTime(ds.substr(sp+2)*1000)
201+
//get end time first
202+
d.setTime(ds.substr(sp + 2) * 1000)
193203

194-
tzo=d.getTimezoneOffset()
195-
if(ds.charAt(0)==="F"){
196-
tzo*=60000
197-
}else{
198-
tzo=0
199-
}
204+
tzo = d.getTimezoneOffset()
205+
if (ds.charAt(0) === "F") {
206+
tzo *= 60000
207+
} else {
208+
tzo = 0
209+
}
200210

201-
ets=d.getTime()+tzo
211+
ets = d.getTime() + tzo
202212

203-
// start
204-
d.setTime(ds.substr(1,sp-1)*1000+tzo)
213+
// start
214+
d.setTime(ds.substr(1, sp - 1) * 1000 + tzo)
205215

206-
uLen=Math.floor((ets-d.getTime())/300000)
216+
uLen = Math.floor((ets - d.getTime()) / 300000)
207217

208-
cID=d.getDay()-1
209-
uTop=Math.floor((((d.getHours()-8)*60)/5)
210-
+((d.getMinutes()/5)))
218+
cID = d.getDay() - 1
219+
uTop = Math.floor((((d.getHours() - 8) * 60) / 5)
220+
+ ((d.getMinutes() / 5)))
211221

212-
if(uTop>=0 && uTop+uLen<=btm){
213-
elm=makeApptElement(uTop,uLen,null,cID,clr)
214-
mData.mc_cols[cID].appendChild(elm)
222+
if (uTop >= 0 && uTop + uLen <= btm) {
223+
elm = makeApptElement(uTop, uLen, null, cID, clr)
224+
mData.mc_cols[cID].appendChild(elm)
225+
}
215226
}
216227
}
217228
}

‎templates/help.php

+27-5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
<div class="srgdev-appt-hs-inner">
22
<h2 class="srgdev-appt-hs-h1">1. Select a Calendar</h2>
3-
<p class="srgdev-appt-hs-p">It is recommended to create a separate calendar.</p>
3+
<p class="srgdev-appt-hs-p"><code class="srgdev-appt-hs-code_short">Manage Appointment Slots &gt; Select a Calendar</code><br>It is recommended to create a separate calendar.</p>
4+
<p class="srgdev-appt-hs-p-h"><strong id="srgdev-sec_destcal">Calendar for booked appointments</strong> - if this calendar is different from the main calendar, confirmed/finalized appointments will be moved here. <em style="font-style: italic;">This calendar is reset every time the main calendar is changed.</em></p>
45
<h2 class="srgdev-appt-hs-h1">2. Enter Organization Info</h2>
56
<p class="srgdev-appt-hs-p">See the "User/Organization Info" section for required Name, Location and Email Address settings.</p>
67
<h2 class="srgdev-appt-hs-h1">3. Add Appointments</h2>
7-
<p class="srgdev-appt-hs-p">Please use the "Add Appointment Slots" dialog.</p>
8+
<p class="srgdev-appt-hs-p">Please use the <code class="srgdev-appt-hs-code_short">Manage Appointment Slots &gt; Add Appointment Slots</code> dialog.</p>
89
<div class="srgdev-appt-hs-p">
910
<span>1. Set "Schedule Generator" settings</span><br>
1011
<span>2. Use "3 Dot" dropdown menus</span><br>
@@ -42,17 +43,38 @@
4243
.srgdev-ncfp-form-header{
4344
border-bottom: 3px solid #961AB1;
4445
}
45-
&lt;/style&gt;
46-
</code>
46+
&lt;/style&gt;</code>
4747
<h2 class="srgdev-appt-hs-h1">5. Email Settings</h2>
4848
<p class="srgdev-appt-hs-p-h"><strong id="srgdev-sec_emailatt">Email Attendee when the appointment is modified and/or deleted</strong> - Attendees will be notified via email when their <strong>upcoming</strong> appointments are updated or deleted in the calendar app or via some other external mechanism. Only changes to Date/Time, Status or Location will trigger the "Modified" notification.</p>
4949
<p class="srgdev-appt-hs-p-h"><strong id="srgdev-sec_emailme">Email Me when an appointment is updated</strong> - A notification email will be sent to you when an appointment is booked via the public page or an upcoming appointment is confirmed or canceled via the email links.</p>
5050
<p class="srgdev-appt-hs-p-h"><strong id="srgdev-sec_emailskipevs">Skip email validation step</strong> - When this option is selected the "<em>... action needed</em>" validation email will NOT be sent to the attendee. Instead the "<em>... Appointment is confirmed</em>" message is going to be sent right away, and the "<em>All done</em>" page is going to be shown when the form is submitted. <span style="font-style: italic">As of now, appointment cancellation link/button is <strong>NOT</strong> included in the confirmation email.</span></p>
5151
<p class="srgdev-appt-hs-p-h"><strong id="srgdev-sec_emaildef"><code>useDefaultEmail</code></strong> - Most instance of NC won't have the particular configuration allowing to send emails on behalf of organizers. Therefore, the default email address as per <a style="color: blue; text-decoration: underline" href="https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/email_configuration.html" target="_blank">Mail Settings</a> is used, and your address is added in the "Reply-To:" header field. If your Nextcloud configuration supports sending out emails for individual users, Admins can override the 'useDefaultEmail' directive like so: <code style="background: #eeeeee; padding: 0 .5em">occ config:app:set appointments useDefaultEmail --value no</code></p>
52+
<p class="srgdev-appt-hs-p-h"><strong id="srgdev-sec_emailmoretext"><code>Additional Email Text</code></strong> - this text is appended as paragraph to the end of validation and confirmation email. Currently only pain text is allowed, HTML will be escaped.</p>
5253
<h2 class="srgdev-appt-hs-h1">6. Share the Public Link</h2>
53-
<p class="srgdev-appt-hs-p">Enable sharing and pass along the public page link. Upcoming appointments will be available on the booking page.</p>
54+
<p class="srgdev-appt-hs-p">Enable sharing and pass along the public page link <code class="srgdev-appt-hs-code_short">Public Page [...] &gt; Show URL/link</code>. Upcoming appointments will be available on the booking page.</p>
5455
<h2 class="srgdev-appt-hs-h1">7. Check Status in the Calendar</h2>
5556
<p class="srgdev-appt-hs-p">Once an appointment is booked it will be visible in the calendar with "⌛ pending" status. The attendee can "✔️ Confirm" or "<span style="text-decoration: line-through">Cancel</span>" the appointment via an email link, the status change will be reflected in the calendar upon page reload.</p>
57+
<h2 class="srgdev-appt-hs-h1">8. iFrame/Embedding</h2>
58+
<div class="srgdev-appt-hs-p">
59+
1. If the iframe is under a different domain use <strong>occ</strong> to set allowed Frame Ancestor Domain:
60+
<code style="white-space: pre" class="srgdev-appt-hs-code">php occ config:app:set appointments "emb_afad_YourUserName" --value "your.domain.com"</code>
61+
2. Email confirm/cancel buttons need to be redirected. (If email validation step is skipped then this is not needed).<br>Use <strong>occ</strong> to set base URL for the host page with <strong>a query parameter available at the end of the URL</strong>:
62+
<code style="white-space: pre" class="srgdev-appt-hs-code">php occ config:app:set appointments "emb_cncf_YourUserName" --value "your.domain.com/page_url?some_param_name="</code>
63+
64+
Example using PHP:
65+
<code style="white-space: pre" class="srgdev-appt-hs-code">...
66+
&lt;?php
67+
$src='PROVIDED_EMBEDDABLE_URL';
68+
if(isset($_GET['some_param_name'])){
69+
// Email Confirm/Cancel button was clicked
70+
$src=substr($src,0,-4).'cncf?d='.urlencode($_GET['some_param_name']);
71+
}
72+
echo '&lt;iframe src = "'.$src.'"&gt;&lt;/iframe&gt;';
73+
?&gt;
74+
...</code>
75+
Nextcloud <strong>occ</strong>: <a style="color: blue; text-decoration: underline" href="https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/occ_command.html">https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/occ_command.html</a><br>
76+
Frame Ancestors: <a style="color: blue; text-decoration: underline" href="https://w3c.github.io/webappsec-csp/#directive-frame-ancestors">https://w3c.github.io/webappsec-csp/#directive-frame-ancestors</a><br>
77+
</div>
5678

5779
</div>
5880

‎templates/public/r404.php

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Not Found</title>
6+
</head>
7+
<body>
8+
404 Not Found
9+
</body>
10+
</html>

0 commit comments

Comments
 (0)
Please sign in to comment.