feat: calendar plugin

This commit is contained in:
Thea Schöbl
2022-01-31 15:57:38 +00:00
committed by Rainer Killinger
parent 080e6fa3e8
commit a57c3029df
54 changed files with 2880 additions and 70 deletions

View File

@@ -0,0 +1,39 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {NgModule} from '@angular/core';
import {ScheduleSyncService} from './schedule/schedule-sync.service';
import {DateFormatPipe, DurationPipe} from 'ngx-moment';
import {CalendarModule} from '../calendar/calendar.module';
import {ScheduleProvider} from '../calendar/schedule.provider';
import {StorageProvider} from '../storage/storage.provider';
import {CalendarService} from '../calendar/calendar.service';
/**
* Schedule Module
*/
@NgModule({
declarations: [],
imports: [CalendarModule],
providers: [
DurationPipe,
DateFormatPipe,
ScheduleProvider,
StorageProvider,
CalendarService,
ScheduleSyncService,
],
})
export class BackgroundModule {}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
export interface ChangesOf<T, P extends Partial<T>> {
new: T;
old?: P;
changes: Array<keyof P>;
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
*
*/
export function hashStringToInt(string_: string): number {
return [...string_].reduce(
(accumulator, current) =>
current.charCodeAt(0) +
(accumulator << 6) +
(accumulator << 16) -
accumulator,
0,
);
}

View File

@@ -0,0 +1,201 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable, OnDestroy} from '@angular/core';
import {
DateSeriesRelevantData,
dateSeriesRelevantKeys,
formatRelevantKeys,
ScheduleProvider,
} from '../../calendar/schedule.provider';
import {SCDateSeries, SCThingType, SCUuid} from '@openstapps/core';
import {Device} from '@capacitor/device';
import {LocalNotifications} from '@capacitor/local-notifications';
import {ThingTranslateService} from '../../../translation/thing-translate.service';
import {DateFormatPipe, DurationPipe} from 'ngx-moment';
import {BackgroundFetch} from '@transistorsoft/capacitor-background-fetch';
import {StorageProvider} from '../../storage/storage.provider';
import {CalendarService} from '../../calendar/calendar.service';
import {flatMap} from 'lodash-es';
import {toICal} from '../../calendar/ical/ical';
import {Subscription} from 'rxjs';
import {ChangesOf} from './changes';
import {hashStringToInt} from './hash';
import {
CALENDAR_NOTIFICATIONS_ENABLED_KEY,
CALENDAR_SYNC_ENABLED_KEY,
CALENDAR_SYNC_SETTINGS_KEY,
} from '../../settings/page/calendar-sync-settings-keys';
@Injectable()
export class ScheduleSyncService implements OnDestroy {
constructor(
private scheduleProvider: ScheduleProvider,
private storageProvider: StorageProvider,
private translator: ThingTranslateService,
private dateFormatPipe: DateFormatPipe,
private durationFormatPipe: DurationPipe,
private calendar: CalendarService,
) {
this.scheduleProvider.uuids$.subscribe(uuids => {
this.uuids = uuids;
void this.syncNativeCalendar();
});
}
uuids: SCUuid[];
uuidSubscription: Subscription;
ngOnDestroy() {
this.uuidSubscription?.unsubscribe();
}
private async isSyncEnabled(): Promise<boolean> {
return await this.storageProvider.get(
`${CALENDAR_SYNC_SETTINGS_KEY}.${CALENDAR_SYNC_ENABLED_KEY}`,
);
}
private async isNotificationsEnabled(): Promise<boolean> {
return await this.storageProvider.get(
`${CALENDAR_SYNC_SETTINGS_KEY}.${CALENDAR_NOTIFICATIONS_ENABLED_KEY}`,
);
}
async enable() {
if ((await Device.getInfo()).platform === 'web') return;
await BackgroundFetch.stop();
if (
[this.isSyncEnabled, this.isNotificationsEnabled].some(
async it => await it(),
)
) {
const status = await BackgroundFetch.configure(
{
minimumFetchInterval: 15,
stopOnTerminate: false,
enableHeadless: true,
},
async taskId => {
await Promise.all([
this.postDifferencesNotification(),
this.syncNativeCalendar(),
]);
await BackgroundFetch.finish(taskId);
},
);
if (status !== BackgroundFetch.STATUS_AVAILABLE) {
if (status === BackgroundFetch.STATUS_DENIED) {
console.error(
'The user explicitly disabled background behavior for this app or for the whole system.',
);
} else if (status === BackgroundFetch.STATUS_RESTRICTED) {
console.error(
'Background updates are unavailable and the user cannot enable them again.',
);
}
} else {
console.info('Starting background fetch.');
await BackgroundFetch.start();
}
}
}
async getDifferences(): Promise<
ChangesOf<SCDateSeries, DateSeriesRelevantData>[]
> {
const partialEvents = this.scheduleProvider.partialEvents$.getValue();
const result = (
await this.scheduleProvider.getDateSeries(partialEvents.map(it => it.uid))
).dates;
return result
.map(it => ({
new: it,
old: partialEvents.find(partialEvent => partialEvent.uid === it.uid),
}))
.map(it => ({
...it,
changes: it.old
? (Object.keys(it.old) as Array<keyof DateSeriesRelevantData>).filter(
key =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
JSON.stringify(it.old![key]) !== JSON.stringify(it.new[key]),
)
: dateSeriesRelevantKeys,
}));
}
private formatChanges(
changes: ChangesOf<SCDateSeries, DateSeriesRelevantData>,
): string[] {
return changes.changes.map(
change =>
`${
this.translator.translator.translatedPropertyNames<SCDateSeries>(
SCThingType.DateSeries,
)?.[change]
}: ${formatRelevantKeys[change](
changes.new[change] as never,
this.dateFormatPipe,
this.durationFormatPipe,
)}`,
);
}
async syncNativeCalendar() {
if (!(await this.isSyncEnabled())) return;
const dateSeries = (await this.scheduleProvider.getDateSeries(this.uuids))
.dates;
const events = flatMap(dateSeries, event =>
toICal(event, this.translator.translator, {
allowRRuleExceptions: false,
excludeCancelledEvents: true,
}),
);
return this.calendar.syncEvents(events);
}
async postDifferencesNotification() {
if (!(await this.isNotificationsEnabled())) return;
const differences = (await this.getDifferences()).filter(
it => it.changes.length > 0,
);
if (differences.length === 0) return;
if ((await Device.getInfo()).platform === 'web') {
// TODO: Implement web notification
} else {
await LocalNotifications.schedule({
notifications: differences.map(it => ({
title: it.new.event.name,
body: this.formatChanges(it).join('\n'),
id: hashStringToInt(it.new.uid),
})),
});
}
}
}

View File

@@ -0,0 +1,226 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, Input, OnInit} from '@angular/core';
import {
getICalExport,
getNativeCalendarExport,
ICalEvent,
serializeICal,
toICal,
toICalUpdates,
} from './ical/ical';
import moment from 'moment';
import {Share} from '@capacitor/share';
import {Directory, Encoding, Filesystem} from '@capacitor/filesystem';
import {Device} from '@capacitor/device';
import {CalendarService} from './calendar.service';
import {Dialog} from '@capacitor/dialog';
import {SCDateSeries} from '@openstapps/core';
import {ThingTranslateService} from '../../translation/thing-translate.service';
import {TranslateService} from '@ngx-translate/core';
import {NewShareData, NewShareNavigator} from './new-share';
interface ICalInfo {
title: string;
events: ICalEvent[];
cancelledEvents: ICalEvent[];
}
@Component({
selector: 'add-event-review-modal',
templateUrl: 'add-event-review-modal.html',
styleUrls: ['add-event-review-modal.scss'],
})
export class AddEventReviewModalComponent implements OnInit {
moment = moment;
@Input() dismissAction: () => void;
@Input() dateSeries: SCDateSeries[];
iCalEvents: ICalInfo[];
includeCancelled = true;
isWeb = true;
constructor(
readonly calendarService: CalendarService,
readonly translator: ThingTranslateService,
readonly translateService: TranslateService,
) {}
ngOnInit() {
Device.getInfo().then(it => {
this.isWeb = it.platform === 'web';
});
this.iCalEvents = this.dateSeries.map(event => ({
title:
this.translator.translator.translatedAccess(event).event.name() ??
'error',
events: toICal(event, this.translator.translator, {
allowRRuleExceptions: true,
excludeCancelledEvents: false,
}),
cancelledEvents: toICalUpdates(event, this.translator.translator),
}));
}
async toCalendar() {
await Dialog.confirm({
title: this.translateService.instant(
'schedule.toCalendar.reviewModal.dialogs.toCalendarConfirm.TITLE',
),
message: this.translateService.instant(
'schedule.toCalendar.reviewModal.dialogs.toCalendarConfirm.DESCRIPTION',
),
});
await this.calendarService.syncEvents(
getNativeCalendarExport(this.dateSeries, this.translator.translator),
);
this.dismissAction();
}
async download() {
const blob = new Blob(
[
serializeICal(
getICalExport(
this.dateSeries,
this.translator.translator,
this.includeCancelled,
),
),
],
{
type: 'text/calendar',
},
);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${
this.dateSeries.length === 1
? this.dateSeries[0].event.name
: 'stapps_calendar'
}.ics`;
a.click();
}
async export() {
const info = await Device.getInfo();
if (info.platform === 'web') {
const blob = new Blob(
[
serializeICal(
getICalExport(
this.dateSeries,
this.translator.translator,
this.includeCancelled,
),
),
],
{
type: 'text/calendar',
},
);
const file = new File([blob], 'calendar.ics', {type: blob.type});
const shareData: NewShareData = {
files: [file],
title: this.translateService.instant(
'schedule.toCalendar.reviewModal.shareData.TITLE',
),
text: this.translateService.instant(
'schedule.toCalendar.reviewModal.shareData.TEXT',
),
};
if (!(navigator as unknown as NewShareNavigator).canShare) {
return Dialog.alert({
title: this.translateService.instant(
'schedule.toCalendar.reviewModal.dialogs.cannotShare.TITLE',
),
message: this.translateService.instant(
'schedule.toCalendar.reviewModal.dialogs.cannotShare.DESCRIPTION',
),
});
}
console.log(
(navigator as unknown as NewShareNavigator).canShare(shareData),
);
if (!(navigator as unknown as NewShareNavigator).canShare(shareData)) {
return Dialog.alert({
title: this.translateService.instant(
'schedule.toCalendar.reviewModal.dialogs.unsupportedFileType.TITLE',
),
message: this.translateService.instant(
'schedule.toCalendar.reviewModal.dialogs.unsupportedFileType.DESCRIPTION',
),
});
}
try {
await (navigator as unknown as NewShareNavigator).share(shareData);
} catch (error) {
console.log(error);
return Dialog.alert({
title: this.translateService.instant(
'schedule.toCalendar.reviewModal.dialogs.failedShare.TITLE',
),
message: this.translateService.instant(
'schedule.toCalendar.reviewModal.dialogs.failedShare.DESCRIPTION',
),
});
}
} else {
const result = await Filesystem.writeFile({
path: `${
this.dateSeries.length === 1
? this.dateSeries[0].event.name
: this.translateService.instant(
'schedule.toCalendar.reviewModal.shareData.FILE_TYPE',
)
}.ics`,
data: serializeICal(
getICalExport(
this.dateSeries,
this.translator.translator,
this.includeCancelled,
),
),
encoding: Encoding.UTF8,
directory: Directory.Cache,
});
await Share.share({
title: this.translateService.instant(
'schedule.toCalendar.reviewModal.shareData.TITLE',
),
text: this.translateService.instant(
'schedule.toCalendar.reviewModal.shareData.TEXT',
),
url: result.uri,
dialogTitle: this.translateService.instant(
'schedule.toCalendar.reviewModal.shareData.TITLE',
),
});
}
}
}

View File

@@ -0,0 +1,81 @@
<!--
~ Copyright (C) 2022 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<div>
<ion-card-header>
<ion-card-title>{{
'schedule.toCalendar.reviewModal.TITLE' | translate
}}</ion-card-title>
<ion-button fill="clear" (click)="dismissAction()">
<ion-label>{{ 'modal.DISMISS' | translate }}</ion-label>
</ion-button>
</ion-card-header>
<ion-card-content>
<ion-list lines="none">
<ion-item-group *ngFor="let event of iCalEvents">
<ion-item-divider>
<ion-label>{{ event.title }}</ion-label>
<ion-note slot="start" *ngIf="event.events.length > 1">
<ion-icon name="warning"></ion-icon>
</ion-note>
</ion-item-divider>
<ion-item *ngFor="let iCalEvent of event.events">
<ion-label>
<s *ngIf="iCalEvent.cancelled; else date"
><ng-container [ngTemplateOutlet]="date"></ng-container>
</s>
<ng-template #date>
{{ moment(iCalEvent.start) | amDateFormat: 'll, HH:mm' }}
</ng-template>
</ion-label>
<ion-note *ngIf="iCalEvent.rrule">
{{ iCalEvent.rrule.interval }}
{{ iCalEvent.rrule.freq | sentencecase }}
</ion-note>
<ion-icon *ngIf="iCalEvent.rrule" name="repeat"></ion-icon>
</ion-item>
</ion-item-group>
</ion-list>
</ion-card-content>
<div class="horizontal-flex">
<ion-item lines="none">
<ion-label>{{
'schedule.toCalendar.reviewModal.INCLUDE_CANCELLED' | translate
}}</ion-label>
<ion-checkbox [(ngModel)]="includeCancelled" slot="end"></ion-checkbox>
</ion-item>
</div>
<div class="horizontal-flex">
<ion-button fill="clear" (click)="export()">
{{ 'share' | translate }}
<ion-icon slot="end" name="share"></ion-icon>
</ion-button>
<ion-button
fill="outline"
(click)="download()"
*ngIf="isWeb; else exportButton"
>
{{ 'schedule.toCalendar.reviewModal.DOWNLOAD' | translate }}
<ion-icon slot="end" name="download"></ion-icon>
</ion-button>
<ng-template #exportButton>
<ion-button fill="outline" (click)="toCalendar()">
{{ 'schedule.toCalendar.reviewModal.EXPORT' | translate }}
<ion-icon slot="end" name="calendar"></ion-icon>
</ion-button>
</ng-template>
</div>
</div>

View File

@@ -0,0 +1,29 @@
div {
height: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
ion-card-header {
ion-button {
position: absolute;
right: 0;
top: 0;
}
}
ion-card-content {
height: 100%;
overflow: scroll;
padding-left: 0;
padding-right: 0;
}
}
.horizontal-flex {
height: fit-content;
display: flex;
flex-direction: row;
justify-content: end;
align-items: center;
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
export interface CalendarInfo {
id: number;
name: string;
displayname: string;
isPrimary: boolean;
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {NgModule} from '@angular/core';
import {AddEventReviewModalComponent} from './add-event-review-modal.component';
import {Calendar} from '@awesome-cordova-plugins/calendar/ngx';
import {CalendarService} from './calendar.service';
import {ScheduleProvider} from './schedule.provider';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {FormsModule} from '@angular/forms';
import {CommonModule} from '@angular/common';
import {MomentModule} from 'ngx-moment';
@NgModule({
declarations: [AddEventReviewModalComponent],
imports: [
IonicModule.forRoot(),
TranslateModule.forChild(),
ThingTranslateModule.forChild(),
FormsModule,
CommonModule,
MomentModule,
],
exports: [],
providers: [Calendar, CalendarService, ScheduleProvider],
})
export class CalendarModule {}

View File

@@ -0,0 +1,101 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Calendar} from '@awesome-cordova-plugins/calendar/ngx';
import {Injectable} from '@angular/core';
import {ICalEvent} from './ical/ical';
import moment, {duration, unitOfTime} from 'moment';
import {Dialog} from '@capacitor/dialog';
import {CalendarInfo} from './calendar-info';
const CALENDAR_NAME = 'StApps';
const RECURRENCE_PATTERNS: Partial<
Record<unitOfTime.Diff, string | undefined>
> = {
year: 'yearly',
month: 'monthly',
week: 'weekly',
day: 'daily',
};
@Injectable()
export class CalendarService {
// eslint-disable-next-line @typescript-eslint/no-empty-function
constructor(readonly calendar: Calendar) {}
async createCalendar(): Promise<CalendarInfo | undefined> {
await this.calendar.createCalendar({
calendarName: CALENDAR_NAME,
calendarColor: '#ff8740',
});
return this.findCalendar(CALENDAR_NAME);
}
async listCalendars(): Promise<CalendarInfo[] | undefined> {
return this.calendar.listCalendars();
}
async findCalendar(name: string): Promise<CalendarInfo | undefined> {
return (await this.listCalendars())?.find(
(calendar: CalendarInfo) => calendar.name === name,
);
}
async purge(): Promise<CalendarInfo | undefined> {
if (await this.findCalendar(CALENDAR_NAME)) {
await this.calendar.deleteCalendar(CALENDAR_NAME);
}
return await this.createCalendar();
}
async syncEvents(events: ICalEvent[]) {
const calendar = await this.purge();
if (!calendar) {
return Dialog.alert({
title: 'Error',
message: 'Could not create calendar',
});
}
for (const iCalEvent of events) {
// TODO: change to use non-interactive version after testing is complete
const start = iCalEvent.rrule ? iCalEvent.rrule.from : iCalEvent.start;
await this.calendar.createEventWithOptions(
iCalEvent.recurrenceSequence
? `(${iCalEvent.recurrenceSequence}/${iCalEvent.recurrenceSequenceAmount}) ${iCalEvent.name}`
: iCalEvent.name,
iCalEvent.geo,
iCalEvent.description,
new Date(start),
moment(start).add(duration(iCalEvent.duration)).toDate(),
{
id: `${iCalEvent.uuid}-${start}`,
url: iCalEvent.url,
calendarName: calendar.name,
calendarId: calendar.id,
...(iCalEvent.rrule
? {
recurrence: RECURRENCE_PATTERNS[iCalEvent.rrule.freq],
recurrenceInterval: iCalEvent.rrule.interval,
recurrenceEndDate: new Date(iCalEvent.rrule.until),
}
: {}),
},
);
}
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {findRRules, RRule} from './ical';
import moment, {unitOfTime} from 'moment';
import {shuffle} from 'lodash-es';
import {SCISO8601Date} from '@openstapps/core';
/**
*
*/
function expandRRule(rule: RRule): SCISO8601Date[] {
const initial = moment(rule.from);
const interval = rule.interval ?? 1;
return shuffle(
Array.from({
length:
Math.floor(
moment(rule.until).diff(initial, rule.freq, true) / interval,
) + 1,
}).map((_, i) =>
initial
.clone()
.add(interval * i, rule.freq ?? 'day')
.toISOString(),
),
);
}
describe('iCal', () => {
it('should find simple recurrence patterns', () => {
for (const freq of ['day', 'week', 'month', 'year'] as unitOfTime.Diff[]) {
for (const interval of [1, 2, 3]) {
const pattern: RRule = {
freq: freq,
interval: interval,
from: moment('2021-09-01T10:00').toISOString(),
until: moment('2021-09-01T10:00')
.add(4 * interval, freq)
.toISOString(),
};
expect(findRRules(expandRRule(pattern))).toEqual([pattern]);
}
}
});
it('should find missing recurrence patterns', () => {
const pattern: SCISO8601Date = moment('2021-09-01T10:00').toISOString();
expect(findRRules([pattern])).toEqual([pattern]);
});
it('should find mixed recurrence patterns', () => {
const singlePattern: SCISO8601Date =
moment('2021-09-01T09:00').toISOString();
const weeklyPattern: RRule = {
freq: 'week',
interval: 1,
from: moment('2021-09-03T10:00').toISOString(),
until: moment('2021-09-03T10:00').add(4, 'weeks').toISOString(),
};
expect(
findRRules(shuffle([singlePattern, ...expandRRule(weeklyPattern)])),
).toEqual([singlePattern, weeklyPattern]);
});
});

View File

@@ -0,0 +1,414 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
SCDateSeries,
SCISO8601Date,
SCISO8601Duration,
SCThingTranslator,
SCThingWithCategories,
SCUuid,
} from '@openstapps/core';
import {
difference,
flatMap,
isObject,
last,
mapValues,
minBy,
size,
} from 'lodash-es';
import moment, {unitOfTime} from 'moment';
export interface ICalEvent {
name?: string;
uuid: SCUuid;
categories?: string[];
description?: string;
cancelled?: boolean;
recurrenceId?: SCISO8601Date;
geo?: string;
/**
* The sequence index if the series had to be split into multiple rrules
*/
recurrenceSequence?: number;
recurrenceSequenceAmount?: number;
rrule?: RRule;
dates?: SCISO8601Date[];
exceptionDates?: SCISO8601Date[];
start: SCISO8601Date;
sequence?: number;
duration?: SCISO8601Duration;
url?: string;
}
export type ICalKeyValuePair = `${Uppercase<string>}${':' | '='}${string}`;
export type ICalLike = ICalKeyValuePair[];
/**
*
*/
function timeDist(
current: SCISO8601Date,
next: SCISO8601Date | undefined,
recurrence: unitOfTime.Diff,
): number | undefined {
if (!next) {
return undefined;
}
const diff = moment(next).diff(moment(current), recurrence, true);
return Math.floor(diff) === diff ? diff : undefined;
}
export interface RRule {
freq: unitOfTime.Diff; // 'SECONDLY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
interval: number;
from: SCISO8601Date;
until: SCISO8601Date;
}
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
export interface MergedRRule {
rrule?: RRule;
exceptions?: SCISO8601Date[];
date?: SCISO8601Date;
}
/**
* Merge compatible RRules to a single RRule with exceptions
*/
export function mergeRRules(
rules: Array<RRule | SCISO8601Date>,
allowExceptions = true,
): MergedRRule[] {
if (!allowExceptions)
return rules.map(it => (typeof it === 'string' ? {date: it} : {rrule: it}));
/*map(groupBy(rules, it => `${it.freq}@${it.interval}`), it => {
});*/
return rules.map(it =>
typeof it === 'string' ? {date: it} : {rrule: it},
) /* TODO */;
}
/**
* Find RRules in a list of dates
*/
export function findRRules(
dates: SCISO8601Date[],
): Array<RRule | SCISO8601Date> {
const sorted = dates.sort((a, b) => moment(a).unix() - moment(b).unix());
const output: Optional<RRule, 'freq'>[] = [
{
from: sorted[0],
until: sorted[0],
interval: -1,
},
];
for (let i = 0; i < sorted.length; i++) {
const current = sorted[i];
const next = sorted[i + 1] as SCISO8601Date | undefined;
const element = last(output);
const units: unitOfTime.Diff[] = element?.freq
? [element.freq]
: ['day', 'week', 'month', 'year'];
const freq = minBy(
units.map(recurrence => ({
recurrence: recurrence,
dist: timeDist(current, next, recurrence),
})),
it => it.dist,
)?.recurrence;
const interval = freq ? timeDist(current, next, freq) : undefined;
if (element?.interval === -1) {
element.freq = freq;
element.interval = interval ?? -1;
}
if (!freq || element?.freq !== freq || element.interval !== interval) {
if (element) {
element.until = current;
}
if (next) {
output.push({
from: next,
until: next,
interval: -1,
});
}
} else {
element.until = current;
}
}
return output.map(it => (it.freq ? (it as RRule) : it.from));
}
/**
*
*/
export function strikethrough(text: string): string {
return `\u274C ${[...text].join('\u0336')}\u0336`;
}
/**
*
*/
function getICalData(
dateSeries: SCDateSeries,
translator: SCThingTranslator,
): Pick<ICalEvent, 'name' | 'uuid' | 'categories' | 'description' | 'geo'> {
const translated = translator.translatedAccess(dateSeries);
return {
name: translated.event()?.name,
uuid: dateSeries.uid,
categories: [
'stapps',
...((translated.event() as SCThingWithCategories<string, never>)
?.categories ?? []),
],
description: translated.event()?.description ?? translated.description(),
geo: translated.inPlace()?.name,
};
}
export interface ToICalOptions {
allowRRuleExceptions?: boolean;
excludeCancelledEvents?: boolean;
}
/**
*
*/
export function toICal(
dateSeries: SCDateSeries,
translator: SCThingTranslator,
options: ToICalOptions = {},
): ICalEvent[] {
const rrules = findRRules(
options.excludeCancelledEvents
? difference(dateSeries.dates, dateSeries.exceptions ?? [])
: dateSeries.dates,
);
return mergeRRules(rrules, options.allowRRuleExceptions).map(
(it, i, array) => ({
...getICalData(dateSeries, translator),
dates: dateSeries.dates,
rrule: it.rrule,
recurrenceSequence: array.length > 1 ? i + 1 : undefined,
recurrenceSequenceAmount: array.length > 1 ? array.length : undefined,
exceptionDates: it.exceptions,
start: it.rrule?.from ?? it.date ?? dateSeries.dates[0],
sequence: 0,
duration: dateSeries.duration,
}),
);
}
/**
*
*/
export function toICalUpdates(
dateSeries: SCDateSeries,
translator: SCThingTranslator,
): ICalEvent[] {
return (
dateSeries.exceptions?.map(exception => ({
...getICalData(dateSeries, translator),
sequence: 1,
recurrenceId: exception,
cancelled: true,
start: exception,
})) ?? []
);
}
/**
* Convert an ISO8601 date to a string in the format YYYYMMDDTHHMMSSZ
*/
export function iso8601ToICalDateTime<T extends SCISO8601Date | undefined>(
date: T,
): T extends SCISO8601Date ? string : undefined {
return (
date ? `${moment(date).utc().format('YYYYMMDDTHHmmss')}Z` : undefined
) as never;
}
/**
* Convert an ISO8601 date to a string in the format YYYYMMDD
*/
export function iso8601ToICalDate(date: SCISO8601Date): string {
return `${moment(date).utc().format('YYYYMMDD')}`;
}
/**
* Recursively stringify all linebreaks to \n strings
*/
function stringifyLinebreaks<T extends string | unknown[] | unknown>(
value: T,
): T {
if (typeof value === 'string') {
return value.replace(/\r?\n|\r/g, '\\n') as T;
}
if (Array.isArray(value)) {
return value.map(stringifyLinebreaks) as T;
}
if (isObject(value)) {
return mapValues(value, stringifyLinebreaks) as T;
}
return value;
}
/**
* Sanitize an ICal object to not contain line breaks and convert dates to iCal format
*/
export function normalizeICalDates(iCal: ICalEvent): ICalEvent {
return {
...iCal,
dates: iCal.dates?.filter(it => it !== iCal.start).map(iso8601ToICalDate),
exceptionDates: iCal.exceptionDates?.map(iso8601ToICalDate),
start: iso8601ToICalDateTime(iCal.start),
recurrenceId: iso8601ToICalDateTime(iCal.recurrenceId),
};
}
const REPEAT_FREQUENCIES: Partial<Record<unitOfTime.Diff, string>> = {
day: 'DAILY',
week: 'WEEKLY',
month: 'MONTHLY',
year: 'YEARLY',
};
/**
*
*/
export function serializeICalLike(iCal: ICalLike): string {
return iCal.map(stringifyLinebreaks).join('\r\n');
}
/**
* Removes all strings that are either undefined or end with 'undefined'
*/
function withoutNullishStrings<T extends string>(
array: Array<T | `${string}${undefined}` | undefined>,
): T[] {
return array.filter(it => it && !it.endsWith('undefined')) as T[];
}
/**
*
*/
export function serializeRRule(rrule?: RRule): string | undefined {
return rrule
? `FREQ=${
REPEAT_FREQUENCIES[rrule.freq ?? 's']
};UNTIL=${iso8601ToICalDateTime(rrule.until)};INTERVAL=${rrule.interval}`
: undefined;
}
/**
* Convert an iCal event to a string
*/
export function serializeICalEvent(iCal: ICalEvent): ICalLike {
const normalized = normalizeICalDates(iCal);
return withoutNullishStrings<ICalKeyValuePair>([
'BEGIN:VEVENT',
`DTSTART:${normalized.start}`,
`DURATION:${normalized.duration}`,
`DTSTAMP:${moment().utc().format('YYYYMMDDTHHmmss')}Z`,
`UID:${normalized.uuid}`,
`RECURRENCE-ID:${normalized.recurrenceId}`,
`CATEGORIES:${normalized.categories?.join(',')}`,
`SUMMARY:${normalized.name}`,
`DESCRIPTION:${normalized.description}`,
`STATUS:${normalized.cancelled === true ? 'CANCELLED' : 'CONFIRMED'}`,
`URL:${normalized.url}`,
// `RDATE;VALUE=DATE:${normalized.dates.join(',')}`,
size(normalized.exceptionDates) > 0
? `EXDATE;VALUE=DATE:${normalized.exceptionDates?.join(',')}`
: undefined,
`RRULE:${serializeRRule(normalized.rrule)}`,
'END:VEVENT',
]);
}
/**
* Convert an iCal object to a string
*/
export function serializeICal(iCal: ICalEvent[]): string {
return serializeICalLike([
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//StApps//NONSGML StApps Calendar//EN',
'NAME:StApps',
'X-WR-CALNAME:StApps',
'X-WR-CALDESC:StApps Calendar',
'X-WR-TIMEZONE:Europe/Berlin',
'LOCATION;LANGUAGE=en:Germany',
'CALSCALE:GREGORIAN',
'COLOR:#FF0000',
'METHOD:PUBLISH',
...flatMap(iCal, serializeICalEvent),
'END:VCALENDAR',
]);
}
/**
* Get transform date series for purpose of native calendar export
*/
export function getNativeCalendarExport(
dateSeries: SCDateSeries[],
translator: SCThingTranslator,
): ICalEvent[] {
return flatMap(dateSeries, event =>
toICal(event, translator, {
allowRRuleExceptions: false,
excludeCancelledEvents: true,
}),
);
}
/**
* Get transform date series for purpose of iCal file export
*/
export function getICalExport(
dateSeries: SCDateSeries[],
translator: SCThingTranslator,
includeCancelled: boolean,
): ICalEvent[] {
return [
...flatMap(dateSeries, event =>
toICal(event, translator, {
allowRRuleExceptions: false,
excludeCancelledEvents: !includeCancelled,
}),
),
...(includeCancelled
? flatMap(dateSeries, event => toICalUpdates(event, translator))
: []),
];
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
export interface NewShareData {
files?: File[];
title?: string;
text?: string;
url?: string;
}
// web share api is relatively new
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share
export interface NewShareNavigator {
canShare: (options: NewShareData) => boolean;
share: (options: NewShareData) => Promise<void>;
}

View File

@@ -24,8 +24,54 @@ import {
SCThingType,
SCUuid,
} from '@openstapps/core';
import {BehaviorSubject, Subscription} from 'rxjs';
import {BehaviorSubject, Observable, Subscription} from 'rxjs';
import {DataProvider} from '../data/data.provider';
import {map} from 'rxjs/operators';
import {pick} from 'lodash-es';
import {DateFormatPipe, DurationPipe} from 'ngx-moment';
/**
*
*/
export function toDateSeriesRelevantData(
dateSeries: SCDateSeries,
): DateSeriesRelevantData {
return pick(dateSeries, ...dateSeriesRelevantKeys);
}
export type DateSeriesRelevantKeys =
| 'uid'
| 'dates'
| 'exceptions'
| 'repeatFrequency'
| 'duration';
export const dateSeriesRelevantKeys: Array<DateSeriesRelevantKeys> = [
'uid',
'dates',
'exceptions',
'repeatFrequency',
'duration',
];
export const formatRelevantKeys: {
[key in DateSeriesRelevantKeys]: (
value: SCDateSeries[key],
dateFormatter: DateFormatPipe,
durationFormatter: DurationPipe,
) => string;
} = {
uid: value => value,
dates: (value, dateFormatter) =>
`[${value.map(it => dateFormatter.transform(it)).join(', ')}]`,
exceptions: (value, dateFormatter) =>
`[${value?.map(it => dateFormatter.transform(it)).join(', ') ?? ''}]`,
repeatFrequency: (value, _, durationFormatter) =>
durationFormatter.transform(value),
duration: (value, _, durationFormatter) => durationFormatter.transform(value),
};
export type DateSeriesRelevantData = Pick<SCDateSeries, DateSeriesRelevantKeys>;
/**
* Provider for app settings
@@ -34,14 +80,11 @@ import {DataProvider} from '../data/data.provider';
export class ScheduleProvider implements OnDestroy {
// tslint:disable:prefer-function-over-method
/**
* Storage key for event UUIDs
*/
private static uuidStorageKey = 'schedule::event_uuids';
private static partialEventsStorageKey = 'schedule::partial_events';
private _uuids$?: BehaviorSubject<SCUuid[]>;
private _partialEvents$?: BehaviorSubject<DateSeriesRelevantData[]>;
private _uuidSubscription?: Subscription;
private _partialEventsSubscription?: Subscription;
constructor(private readonly dataProvider: DataProvider) {
window.addEventListener('storage', this.storageListener);
@@ -70,20 +113,42 @@ export class ScheduleProvider implements OnDestroy {
}
}
public async restore(uuids: SCUuid[]): Promise<SCDateSeries[] | undefined> {
if (uuids.length === 0) {
return undefined;
}
const dateSeries = (await this.getDateSeries(uuids)).dates;
this._partialEvents$?.next(dateSeries.map(toDateSeriesRelevantData));
return dateSeries;
}
/**
* TODO
*/
public get uuids$(): BehaviorSubject<SCUuid[]> {
if (!this._uuids$) {
this._uuids$ = new BehaviorSubject(
ScheduleProvider.get<SCUuid>(ScheduleProvider.uuidStorageKey),
public get uuids$(): Observable<SCUuid[]> {
return this.partialEvents$.pipe(map(events => events.map(it => it.uid)));
}
public get partialEvents$(): BehaviorSubject<DateSeriesRelevantData[]> {
if (!this._partialEvents$) {
const data = ScheduleProvider.get<DateSeriesRelevantData>(
ScheduleProvider.partialEventsStorageKey,
);
this._partialEvents$ = new BehaviorSubject(data ?? []);
this._partialEventsSubscription = this._partialEvents$.subscribe(
result => {
ScheduleProvider.set(
ScheduleProvider.partialEventsStorageKey,
result,
);
},
);
this._uuidSubscription = this._uuids$.subscribe(result => {
ScheduleProvider.set(ScheduleProvider.uuidStorageKey, result);
});
}
return this._uuids$;
return this._partialEvents$;
}
/**
@@ -93,9 +158,9 @@ export class ScheduleProvider implements OnDestroy {
if (
event.newValue &&
event.storageArea === localStorage &&
event.key === ScheduleProvider.uuidStorageKey
event.key === ScheduleProvider.partialEventsStorageKey
) {
this._uuids$?.next(JSON.parse(event.newValue));
this._partialEvents$?.next(JSON.parse(event.newValue));
}
}
@@ -211,7 +276,7 @@ export class ScheduleProvider implements OnDestroy {
* TODO
*/
ngOnDestroy(): void {
this._uuidSubscription?.unsubscribe();
this._partialEventsSubscription?.unsubscribe();
window.removeEventListener('storage', this.storageListener);
}
}

View File

@@ -21,8 +21,8 @@ import {
OnDestroy,
OnInit,
} from '@angular/core';
import {PopoverController} from '@ionic/angular';
import {SCDateSeries, SCUuid} from '@openstapps/core';
import {ModalController, PopoverController} from '@ionic/angular';
import {SCDateSeries} from '@openstapps/core';
import {
difference,
every,
@@ -36,7 +36,14 @@ import {
} from 'lodash-es';
import {capitalize, last} from 'lodash-es';
import {Subscription} from 'rxjs';
import {ScheduleProvider} from '../../schedule/schedule.provider';
import {
DateSeriesRelevantData,
ScheduleProvider,
toDateSeriesRelevantData,
} from '../../calendar/schedule.provider';
import {CalendarService} from '../../calendar/calendar.service';
import {AddEventReviewModalComponent} from '../../calendar/add-event-review-modal.component';
import {ThingTranslatePipe} from '../../../translation/thing-translate.pipe';
enum Selection {
ON = 2,
@@ -190,7 +197,7 @@ export class AddEventPopoverComponent implements OnInit, OnDestroy {
/**
* Uuids
*/
uuids: SCUuid[];
partialDateSeries: DateSeriesRelevantData[];
/**
* Uuid Subscription
@@ -201,6 +208,9 @@ export class AddEventPopoverComponent implements OnInit, OnDestroy {
readonly ref: ChangeDetectorRef,
readonly scheduleProvider: ScheduleProvider,
readonly popoverController: PopoverController,
readonly calendar: CalendarService,
readonly modalController: ModalController,
readonly thingTranslatePipe: ThingTranslatePipe,
) {}
/**
@@ -214,16 +224,18 @@ export class AddEventPopoverComponent implements OnInit, OnDestroy {
* Init
*/
ngOnInit() {
this.uuidSubscription = this.scheduleProvider.uuids$.subscribe(
this.uuidSubscription = this.scheduleProvider.partialEvents$.subscribe(
async result => {
this.uuids = result;
this.partialDateSeries = result;
this.selection = new TreeNode(
values(
groupBy(
sortBy(
this.items.map(item => ({
selected: this.uuids.includes(item.uid),
selected: this.partialDateSeries.some(
it => it.uid === item.uid,
),
item: item,
})),
it => it.item.repeatFrequency,
@@ -237,17 +249,44 @@ export class AddEventPopoverComponent implements OnInit, OnDestroy {
);
}
getSelection(): {
selected: DateSeriesRelevantData[];
unselected: DateSeriesRelevantData[];
} {
const selection = mapValues(
groupBy(flatMap(this.selection.children, 'children'), 'selected'),
value => value.map(it => toDateSeriesRelevantData(it.item)),
);
return {selected: selection.true ?? [], unselected: selection.false ?? []};
}
async export() {
const modal = await this.modalController.create({
component: AddEventReviewModalComponent,
swipeToClose: true,
cssClass: 'add-modal',
componentProps: {
dismissAction: () => {
modal.dismiss();
},
dateSeries: this.items,
},
});
await modal.present();
await modal.onWillDismiss();
}
/**
* On selection change
*/
async onCommit(save: boolean) {
if (save) {
const {false: unselected, true: selected} = mapValues(
groupBy(flatMap(this.selection.children, 'children'), 'selected'),
value => value.map(it => it.item.uid),
);
this.scheduleProvider.uuids$.next(
union(difference(this.uuids, unselected), selected),
const {selected, unselected} = this.getSelection();
console.log(selected, unselected);
this.scheduleProvider.partialEvents$.next(
union(difference(this.partialDateSeries, unselected), selected),
);
}

View File

@@ -75,4 +75,14 @@
'ok' | translate
}}</ion-button>
</div>
<div class="download-button">
<ion-button
fill="clear"
(click)="export()"
[disabled]="!(selection.indeterminate || selection.checked)"
>
<ion-icon slot="icon-only" name="download"></ion-icon>
<!-- {{ 'export' | translate }} -->
</ion-button>
</div>
</ion-card-content>

View File

@@ -1,3 +1,18 @@
/*!
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
::ng-deep ion-item-divider {
cursor: pointer;
}
@@ -5,3 +20,7 @@
.action-buttons {
float: right;
}
.download-button {
float: left;
}

View File

@@ -18,7 +18,7 @@ import {PopoverController} from '@ionic/angular';
import {SCDateSeries, SCThing, SCThingType, SCUuid} from '@openstapps/core';
import {difference, map} from 'lodash-es';
import {Subscription} from 'rxjs';
import {ScheduleProvider} from '../../../schedule/schedule.provider';
import {ScheduleProvider} from '../../../calendar/schedule.provider';
import {AddEventPopoverComponent} from '../add-event-popover.component';
import {CoordinatedSearchProvider} from '../../coordinated-search.provider';
import {

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2018-2021 StApps
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
@@ -24,7 +24,7 @@ import {MarkdownModule} from 'ngx-markdown';
import {MomentModule} from 'ngx-moment';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {MenuModule} from '../menu/menu.module';
import {ScheduleProvider} from '../schedule/schedule.provider';
import {ScheduleProvider} from '../calendar/schedule.provider';
import {StorageModule} from '../storage/storage.module';
import {ActionChipListComponent} from './chips/action-chip-list.component';
import {AddEventPopoverComponent} from './chips/add-event-popover.component';
@@ -83,6 +83,7 @@ import {Geolocation} from '@ionic-native/geolocation/ngx';
import {FavoriteButtonComponent} from './elements/favorite-button.component';
import {SimpleDataListComponent} from './list/simple-data-list.component';
import {TitleCardComponent} from './elements/title-card.component';
import {CalendarService} from '../calendar/calendar.service';
/**
* Module for handling data
@@ -168,6 +169,7 @@ import {TitleCardComponent} from './elements/title-card.component';
Network,
ScheduleProvider,
StAppsWebHttpClient,
CalendarService,
],
exports: [
DataDetailComponent,

View File

@@ -25,7 +25,7 @@ import {
materialManualFade,
materialSharedAxisX,
} from '../../../animation/material-motion';
import {ScheduleProvider} from '../schedule.provider';
import {ScheduleProvider} from '../../calendar/schedule.provider';
import {ScheduleEvent, ScheduleResponsiveBreakpoint} from './schema/schema';
import {SwiperComponent} from 'swiper/angular';

View File

@@ -14,7 +14,7 @@
*/
import {Component, Input, OnInit} from '@angular/core';
import moment from 'moment';
import {ScheduleProvider} from '../../schedule.provider';
import {ScheduleProvider} from '../../../calendar/schedule.provider';
import {ScheduleEvent} from '../schema/schema';
/**
@@ -87,9 +87,9 @@ export class ScheduleCardComponent implements OnInit {
*/
removeEvent(): false {
if (confirm('Remove event?')) {
this.scheduleProvider.uuids$.next(
this.scheduleProvider.uuids$.value.filter(
it => it !== this.scheduleEvent.dateSeries.uid,
this.scheduleProvider.partialEvents$.next(
this.scheduleProvider.partialEvents$.value.filter(
it => it.uid !== this.scheduleEvent.dateSeries.uid,
),
);
}

View File

@@ -15,7 +15,7 @@
import {Component, Input} from '@angular/core';
import moment from 'moment';
import {Range, ScheduleEvent} from '../schema/schema';
import {ScheduleProvider} from '../../schedule.provider';
import {ScheduleProvider} from '../../../calendar/schedule.provider';
import {SCISO8601Duration, SCUuid} from '@openstapps/core';
import {materialFade} from '../../../../animation/material-motion';

View File

@@ -18,7 +18,7 @@ import {flatMap, groupBy, isNil, omit, sortBy} from 'lodash-es';
import moment from 'moment';
import {Subscription} from 'rxjs';
import {materialFade} from '../../../animation/material-motion';
import {ScheduleProvider} from '../schedule.provider';
import {ScheduleProvider} from '../../calendar/schedule.provider';
import {ScheduleEvent} from './schema/schema';
/**

View File

@@ -22,7 +22,7 @@ import {
materialManualFade,
materialSharedAxisX,
} from '../../../animation/material-motion';
import {ScheduleProvider} from '../schedule.provider';
import {ScheduleProvider} from '../../calendar/schedule.provider';
import {CalendarViewComponent} from './calendar-view.component';
import {SwiperComponent} from 'swiper/angular';

View File

@@ -30,11 +30,12 @@ import {ModalEventCreatorComponent} from './page/modal/modal-event-creator.compo
import {SchedulePageComponent} from './page/schedule-page.component';
import {ScheduleSingleEventsComponent} from './page/schedule-single-events.component';
import {ScheduleViewComponent} from './page/schedule-view.component';
import {ScheduleProvider} from './schedule.provider';
import {ScheduleProvider} from '../calendar/schedule.provider';
import {SwiperModule} from 'swiper/angular';
import {ScheduleDayComponent} from './page/grid/schedule-day.component';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {InfiniteSwiperComponent} from './page/grid/infinite-swiper.component';
import {FileOpener} from '@ionic-native/file-opener/ngx';
const settingsRoutes: Routes = [
{path: 'schedule', redirectTo: 'schedule/calendar/now'},
@@ -72,6 +73,6 @@ const settingsRoutes: Routes = [
UtilModule,
ThingTranslateModule,
],
providers: [ScheduleProvider, DataProvider, DateFormatPipe],
providers: [ScheduleProvider, DataProvider, DateFormatPipe, FileOpener],
})
export class ScheduleModule {}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const CALENDAR_SYNC_SETTINGS_KEY = 'calendarSettings';
export const CALENDAR_SYNC_ENABLED_KEY = 'sync';
export const CALENDAR_NOTIFICATIONS_ENABLED_KEY = 'notifications';
export type CALENDAR_SYNC_KEYS =
| typeof CALENDAR_SYNC_ENABLED_KEY
| typeof CALENDAR_NOTIFICATIONS_ENABLED_KEY;

View File

@@ -0,0 +1,209 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, OnInit} from '@angular/core';
import {AddEventReviewModalComponent} from '../../calendar/add-event-review-modal.component';
import {ModalController} from '@ionic/angular';
import {ScheduleProvider} from '../../calendar/schedule.provider';
import {map} from 'lodash-es';
import {Directory, Encoding, Filesystem} from '@capacitor/filesystem';
import {Share} from '@capacitor/share';
import {Device} from '@capacitor/device';
import {Dialog} from '@capacitor/dialog';
import {SCUuid} from '@openstapps/core';
import {TranslateService} from '@ngx-translate/core';
import {StorageProvider} from '../../storage/storage.provider';
import {ScheduleSyncService} from '../../background/schedule/schedule-sync.service';
import {CalendarService} from '../../calendar/calendar.service';
import {getNativeCalendarExport} from '../../calendar/ical/ical';
import {ThingTranslateService} from '../../../translation/thing-translate.service';
import {
CALENDAR_NOTIFICATIONS_ENABLED_KEY,
CALENDAR_SYNC_ENABLED_KEY,
CALENDAR_SYNC_KEYS,
CALENDAR_SYNC_SETTINGS_KEY,
} from './calendar-sync-settings-keys';
@Component({
selector: 'calendar-sync-settings',
templateUrl: 'calendar-sync-settings.html',
styleUrls: ['calendar-sync-settings.scss'],
})
export class CalendarSyncSettingsComponent implements OnInit {
isWeb = true;
syncEnabled = false;
notificationsEnabled = false;
constructor(
readonly modalController: ModalController,
readonly scheduleProvider: ScheduleProvider,
readonly translator: TranslateService,
readonly thingTranslator: ThingTranslateService,
readonly storageProvider: StorageProvider,
readonly scheduleSyncService: ScheduleSyncService,
readonly calendarService: CalendarService,
) {}
ngOnInit() {
Device.getInfo().then(it => {
this.isWeb = it.platform === 'web';
});
this.getSetting(CALENDAR_SYNC_ENABLED_KEY).then(
it => (this.syncEnabled = it),
);
this.getSetting(CALENDAR_NOTIFICATIONS_ENABLED_KEY).then(
it => (this.notificationsEnabled = it),
);
}
async getSetting(key: CALENDAR_SYNC_KEYS) {
return (await this.storageProvider.get(
`${CALENDAR_SYNC_SETTINGS_KEY}.${key}`,
)) as boolean;
}
async syncCalendar(sync: boolean) {
this.syncEnabled = sync;
if (sync) {
const uuids = this.scheduleProvider.partialEvents$.value.map(
it => it.uid,
);
const dateSeries = (await this.scheduleProvider.getDateSeries(uuids))
.dates;
await this.calendarService.syncEvents(
getNativeCalendarExport(dateSeries, this.thingTranslator.translator),
);
} else {
await this.calendarService.purge();
}
}
async setSetting(settings: Partial<Record<CALENDAR_SYNC_KEYS, boolean>>) {
await Promise.all(
map(settings, (setting, key) =>
this.storageProvider.put(
`${CALENDAR_SYNC_SETTINGS_KEY}.${key}`,
setting,
),
),
);
return this.scheduleSyncService.enable();
}
async export() {
const uuids = this.scheduleProvider.partialEvents$.value.map(it => it.uid);
const dateSeries = (await this.scheduleProvider.getDateSeries(uuids)).dates;
const modal = await this.modalController.create({
component: AddEventReviewModalComponent,
swipeToClose: true,
cssClass: 'add-modal',
componentProps: {
dismissAction: () => {
modal.dismiss();
},
dateSeries: dateSeries,
},
});
await modal.present();
await modal.onWillDismiss();
}
async restore(event: Event) {
// @ts-expect-error files do actually exist
const file = event.target?.files[0] as File;
const uuids = JSON.parse(await file.text()) as SCUuid[] | unknown;
if (!Array.isArray(uuids) || uuids.some(it => typeof it !== 'string')) {
return Dialog.alert({
title: this.translator.instant(
'settings.calendar.export.dialogs.restore.rejectFile.title',
),
message: this.translator.instant(
'settings.calendar.export.dialogs.restore.rejectFile.message',
),
});
}
const dateSeries = await this.scheduleProvider.restore(uuids);
return dateSeries
? Dialog.confirm({
title: this.translator.instant(
'settings.calendar.export.dialogs.restore.success.title',
),
message: this.translator.instant(
'settings.calendar.export.dialogs.restore.success.message',
),
})
: Dialog.alert({
title: this.translator.instant(
'settings.calendar.export.dialogs.restore.error.title',
),
message: this.translator.instant(
'settings.calendar.export.dialogs.restore.error.message',
),
});
}
translateWithDefault(key: string, defaultValue: string) {
const out = this.translator.instant(key);
return out === key ? defaultValue : out;
}
async backup() {
const uuids = JSON.stringify(
this.scheduleProvider.partialEvents$.value.map(it => it.uid),
);
const fileName = `${this.translator.instant(
'settings.calendar.export.fileName',
)}.json`;
const info = await Device.getInfo();
if (info.platform === 'web') {
const blob = new Blob([uuids], {type: 'application/json'});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
window.URL.revokeObjectURL(url);
} else {
const result = await Filesystem.writeFile({
path: fileName,
data: uuids,
encoding: Encoding.UTF8,
directory: Directory.Cache,
});
await Share.share({
title: this.translator.instant(
'settings.calendar.export.dialogs.backup.save.title',
),
text: this.translator.instant(
'settings.calendar.export.dialogs.backup.save.message',
),
url: result.uri,
dialogTitle: this.translator.instant(
'settings.calendar.export.dialogs.backup.save.title',
),
});
}
}
}

View File

@@ -0,0 +1,120 @@
<!--
~ Copyright (C) 2021 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<ion-card>
<ion-card-header>
<ion-card-subtitle>{{
'settings.calendar.title' | translate
}}</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<ion-list lines="none">
<ion-item-group>
<ion-item-divider>
<ion-label>{{
'settings.calendar.sync.title' | translate
}}</ion-label>
</ion-item-divider>
<ion-item>
<ion-toggle
[disabled]="isWeb"
[checked]="syncEnabled"
(ionChange)="
setSetting({
sync: $event.detail.checked
});
syncCalendar($event.detail.checked)
"
>
</ion-toggle>
<ion-label>{{
'settings.calendar.sync.syncWithCalendar' | translate
}}</ion-label>
</ion-item>
<ion-item>
<ion-toggle
[disabled]="isWeb"
[checked]="notificationsEnabled"
(ionChange)="
setSetting({
notifications: $event.detail.checked
})
"
>
</ion-toggle>
<ion-label>{{
'settings.calendar.sync.eventNotifications' | translate
}}</ion-label>
</ion-item>
<ion-item>
<ion-button
[disabled]="isWeb || !syncEnabled"
fill="clear"
(click)="syncCalendar(true)"
>
<ion-label>Sync Now</ion-label>
<ion-icon slot="end" name="sync"></ion-icon>
</ion-button>
</ion-item>
<ion-item *ngIf="isWeb">
<ion-label color="medium"
><i>{{
'settings.calendar.sync.unavailableWeb' | translate
}}</i></ion-label
>
</ion-item>
</ion-item-group>
<ion-item-group>
<ion-item-divider>
<ion-label>{{
'settings.calendar.export.title' | translate
}}</ion-label>
</ion-item-divider>
<ion-item>
<ion-button fill="clear" (click)="export()">
<ion-label>{{
'settings.calendar.export.exportEvents' | translate
}}</ion-label>
<ion-icon slot="end" name="download"></ion-icon>
</ion-button>
</ion-item>
<ion-item>
<ion-button fill="clear" (click)="backup()">
<ion-label>{{
'settings.calendar.export.backup' | translate
}}</ion-label>
<ion-icon slot="end" name="save"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="restoreInput.click()">
<ion-label>{{
'settings.calendar.export.restore' | translate
}}</ion-label>
<ion-icon slot="end" name="refresh"></ion-icon>
</ion-button>
<!--suppress CheckEmptyScriptTag -->
<input
class="ion-hide"
type="file"
accept="application/json"
#restoreInput
(change)="restore($event)"
/>
</ion-item>
</ion-item-group>
</ion-list>
</ion-card-content>
</ion-card>

View File

@@ -16,9 +16,11 @@
{{
'categories[0]'
| thingTranslate
: settingsCache[categoryKey]?.settings[
objectKeys(settingsCache[categoryKey]?.settings)[0]
]
: $any(
settingsCache[categoryKey]?.settings[
objectKeys(settingsCache[categoryKey]?.settings)[0]
]
)
| titlecase
}}
</h5>
@@ -31,6 +33,9 @@
></stapps-settings-item>
</div>
</ion-list>
<calendar-sync-settings></calendar-sync-settings>
<ion-button
color="medium"
expand="block"

View File

@@ -25,6 +25,14 @@ import {SettingsItemComponent} from './item/settings-item.component';
import {SettingsPageComponent} from './page/settings-page.component';
import {SettingTranslatePipe} from './setting-translate.pipe';
import {SettingsProvider} from './settings.provider';
import {CalendarSyncSettingsComponent} from './page/calendar-sync-settings.component';
import {ScheduleProvider} from '../calendar/schedule.provider';
import {FileOpener} from '@ionic-native/file-opener/ngx';
import {ThingTranslatePipe} from '../../translation/thing-translate.pipe';
import {ScheduleSyncService} from '../background/schedule/schedule-sync.service';
import {CalendarService} from '../calendar/calendar.service';
import {CalendarModule} from '../calendar/calendar.module';
import {BackgroundModule} from '../background/background.module';
const settingsRoutes: Routes = [
{path: 'settings', component: SettingsPageComponent},
@@ -38,16 +46,27 @@ const settingsRoutes: Routes = [
SettingsPageComponent,
SettingsItemComponent,
SettingTranslatePipe,
CalendarSyncSettingsComponent,
],
exports: [SettingsItemComponent, SettingTranslatePipe],
imports: [
CommonModule,
FormsModule,
CalendarModule,
BackgroundModule,
IonicModule.forRoot(),
TranslateModule.forChild(),
ThingTranslateModule.forChild(),
RouterModule.forChild(settingsRoutes),
],
providers: [ConfigProvider, SettingsProvider],
providers: [
ScheduleSyncService,
ConfigProvider,
SettingsProvider,
CalendarService,
ScheduleProvider,
FileOpener,
ThingTranslatePipe,
],
})
export class SettingsModule {}

View File

@@ -26,11 +26,13 @@ import {
SettingValuesContainer,
STORAGE_KEY_SETTING_VALUES,
} from './settings.provider';
import {ScheduleSyncService} from '../background/schedule/schedule-sync.service';
describe('SettingsProvider', () => {
let configProviderSpy: jasmine.SpyObj<ConfigProvider>;
let settingsProvider: SettingsProvider;
let storageProviderSpy: jasmine.SpyObj<StorageProvider>;
let scheduleSyncServiceSpy: jasmine.SpyObj<ScheduleSyncService>;
beforeEach(async () => {
const storageProviderMethodSpy = jasmine.createSpyObj('StorageProvider', [
@@ -42,6 +44,10 @@ describe('SettingsProvider', () => {
const configProviderMethodSpy = jasmine.createSpyObj('ConfigProvider', [
'getValue',
]);
scheduleSyncServiceSpy = jasmine.createSpyObj('ScheduleSyncService', [
'getDifferences',
'postDifferencesNotification',
]);
TestBed.configureTestingModule({
imports: [],
@@ -55,6 +61,10 @@ describe('SettingsProvider', () => {
provide: ConfigProvider,
useValue: configProviderMethodSpy,
},
{
provide: ScheduleSyncService,
useValue: scheduleSyncServiceSpy,
},
],
});
configProviderSpy = TestBed.get(ConfigProvider);