Skip to content

Commit

Permalink
Adds ics file import/handling to mobile apps
Browse files Browse the repository at this point in the history
This commit adds the capacity of handling ICS files on mobile apps,
if the user shares the file with Mail or Calendar app, it'll try to
import the files.
  • Loading branch information
mup committed Aug 16, 2024
1 parent 1e7365c commit 3641db5
Show file tree
Hide file tree
Showing 18 changed files with 365 additions and 58 deletions.
11 changes: 11 additions & 0 deletions app-android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@
<data android:mimeType="text/vcard"/>
<data android:mimeType="text/x-vcard"/>
</intent-filter>
<!--Handler for "open" event for a ics file-->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>

<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>

<data android:scheme="file"/>
<data android:scheme="content"/>
<data android:mimeType="text/calendar"/>
</intent-filter>
<!--Handler for "mailto:" links-->
<intent-filter>
<action android:name="android.intent.action.SEND"/>
Expand Down
11 changes: 11 additions & 0 deletions app-android/calendar/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@

<action android:name="android.intent.action.VIEW"/>
</intent-filter>
<!--Handler for "open" event for a ics file-->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>

<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>

<data android:scheme="file"/>
<data android:scheme="content"/>
<data android:mimeType="text/calendar"/>
</intent-filter>
</activity>
<service
android:foregroundServiceType="shortService"
Expand Down
147 changes: 147 additions & 0 deletions src/calendar-app/calendar/export/CalendarImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import { getTimeZone } from "../../../common/calendar/date/CalendarUtils.js"
import { ParserError } from "../../../common/misc/parsing/ParserCombinator.js"
import { CalendarEvent } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { AlarmInfoTemplate } from "../../../common/api/worker/facades/lazy/CalendarFacade.js"
import { Dialog, DialogType } from "../../../common/gui/base/Dialog.js"
import { lang, TranslationText } from "../../../common/misc/LanguageViewModel.js"
import { List, ListAttrs, ListLoadingState, MultiselectMode, RenderConfig } from "../../../common/gui/base/List.js"
import { KindaCalendarRow } from "../gui/CalendarRow.js"
import { size } from "../../../common/gui/size.js"
import { DialogHeaderBar } from "../../../common/gui/base/DialogHeaderBar.js"
import { ButtonType } from "../../../common/gui/base/Button.js"
import m from "mithril"
import { DropDownSelector, DropDownSelectorAttrs } from "../../../common/gui/base/DropDownSelector.js"
import { getSharedGroupName, hasCapabilityOnGroup } from "../../../common/sharing/GroupUtils.js"
import { BootIcons } from "../../../common/gui/base/icons/BootIcons.js"
import { CalendarInfo } from "../model/CalendarModel.js"
import { UserController } from "../../../common/api/main/UserController.js"
import { ShareCapability } from "../../../common/api/common/TutanotaConstants.js"
import { renderCalendarColor } from "../gui/CalendarGuiUtils.js"
import { GroupColors } from "../view/CalendarView.js"

export type ParsedEvent = {
event: Require<"uid", CalendarEvent>
Expand Down Expand Up @@ -34,3 +50,134 @@ export function parseCalendarStringData(value: string, zone: string): ParsedCale
const tree = parseICalendar(value)
return parseCalendarEvents(tree, zone)
}

/**
* Show a dialog with a preview of a given list of events
* @param events The event list to be previewed
* @param okAction The action to be executed when the user press the ok or continue button
* @param title
*/
export function showEventsImportDialog(events: CalendarEvent[], okAction: (dialog: Dialog) => unknown, title: TranslationText) {
const renderConfig: RenderConfig<CalendarEvent, KindaCalendarRow> = {
itemHeight: size.list_row_height,
multiselectionAllowed: MultiselectMode.Disabled,
swipe: null,
createElement: (dom) => {
return new KindaCalendarRow(dom)
},
}

const dialog = new Dialog(DialogType.EditSmall, {
view: () => [
m(DialogHeaderBar, {
left: [
{
type: ButtonType.Secondary,
label: "cancel_action",
click: () => {
dialog.close()
},
},
],
middle: () => lang.getMaybeLazy(title),
right: [
{
type: ButtonType.Primary,
label: "import_action",
click: () => {
okAction(dialog)
},
},
],
}),
/** variable-size child container that may be scrollable. */
m(".dialog-max-height.plr-s.pb.text-break.nav-bg", [
m(
".flex.col.rel.mt-s",
{
style: {
height: "80vh",
},
},
m(List, {
renderConfig,
state: {
items: events,
loadingStatus: ListLoadingState.Done,
loadingAll: false,
inMultiselect: true,
activeIndex: null,
selectedItems: new Set(),
},
onLoadMore() {
},
onRangeSelectionTowards(item: CalendarEvent) {
},
onRetryLoading() {
},
onSingleSelection(item: CalendarEvent) {
},
onSingleTogglingMultiselection(item: CalendarEvent) {
},
onStopLoading() {
},
} satisfies ListAttrs<CalendarEvent, KindaCalendarRow>),
),
]),
],
}).show()
}

export function calendarSelectionDialog(
calendars: CalendarInfo[],
userController: UserController,
groupColors: GroupColors,
okAction: (dialog: Dialog, selectedCalendar: CalendarInfo) => unknown,
) {
const availableCalendars = calendars.filter((calendarInfo) => hasCapabilityOnGroup(userController.user, calendarInfo.group, ShareCapability.Write))
let selectedCalendar = availableCalendars[0]

const dialog = new Dialog(DialogType.EditSmall, {
view: () => [
m(DialogHeaderBar, {
left: [
{
type: ButtonType.Secondary,
label: "cancel_action",
click: () => {
dialog.close()
},
},
],
middle: () => lang.getMaybeLazy("calendar_label"),
right: [
{
type: ButtonType.Primary,
label: "pricing.select_action",
click: () => {
okAction(dialog, selectedCalendar)
},
},
],
}),

m(".dialog-max-height.plr-l.pt.pb.text-break.scroll", [
m(".text-break.selectable", lang.get("calendarImportSelection_label")),
m(DropDownSelector, {
label: "calendar_label",
items: availableCalendars.map((calendarInfo) => {
return {
name: getSharedGroupName(calendarInfo.groupInfo, userController, calendarInfo.shared),
value: calendarInfo,
}
}),
selectedValue: selectedCalendar,
selectionChangedHandler: (v) => (selectedCalendar = v),
icon: BootIcons.Expand,
disabled: availableCalendars.length < 2,
helpLabel: () => renderCalendarColor(selectedCalendar, groupColors),
} satisfies DropDownSelectorAttrs<CalendarInfo>)
]),
],
}).show()
}
24 changes: 18 additions & 6 deletions src/calendar-app/calendar/export/CalendarImporterDialog.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { CalendarEvent, CalendarGroupRoot } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { CalendarEventTypeRef, createFile } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { CALENDAR_MIME_TYPE, showFileChooser } from "../../../common/file/FileController"
import { CALENDAR_MIME_TYPE, showFileChooser, showNativeFilePicker } from "../../../common/file/FileController"
import { generateEventElementId } from "../../../common/api/common/utils/CommonCalendarUtils"
import { showProgressDialog } from "../../../common/gui/dialogs/ProgressDialog"
import { ParserError } from "../../../common/misc/parsing/ParserCombinator"
import { Dialog } from "../../../common/gui/base/Dialog"
import { lang } from "../../../common/misc/LanguageViewModel"
import { serializeCalendar } from "./CalendarExporter.js"
import { parseCalendarFile, ParsedEvent } from "./CalendarImporter.js"
import { parseCalendarFile, ParsedEvent, showEventsImportDialog } from "./CalendarImporter.js"
import { elementIdPart, isSameId, listIdPart } from "../../../common/api/common/utils/EntityUtils"
import type { UserAlarmInfo } from "../../../common/api/entities/sys/TypeRefs.js"
import { createDateWrapper, UserAlarmInfoTypeRef } from "../../../common/api/entities/sys/TypeRefs.js"
Expand All @@ -18,6 +18,7 @@ import { assignEventId, CalendarEventValidity, checkEventValidity, getTimeZone }
import { ImportError } from "../../../common/api/common/error/ImportError"
import { TranslationKeyType } from "../../../common/misc/TranslationKey"
import { AlarmInfoTemplate } from "../../../common/api/worker/facades/lazy/CalendarFacade.js"
import { isApp } from "../../../common/api/common/Env.js"

export const enum EventImportRejectionReason {
Pre1970,
Expand Down Expand Up @@ -47,8 +48,8 @@ async function partialImportConfirmation(skippedEvents: CalendarEvent[], confirm
)
}

export async function showCalendarImportDialog(calendarGroupRoot: CalendarGroupRoot): Promise<void> {
const parsedEvents: ParsedEvent[] = await selectAndParseIcalFile()
export async function showCalendarImportDialog(calendarGroupRoot: CalendarGroupRoot, events: ParsedEvent[] = []): Promise<void> {
const parsedEvents: ParsedEvent[] = events.length > 0 ? events : await showProgressDialog("loading_msg", selectAndParseIcalFile())
if (parsedEvents.length === 0) return
const zone = getTimeZone()
const existingEvents = await showProgressDialog("loading_msg", loadAllEvents(calendarGroupRoot))
Expand All @@ -60,12 +61,23 @@ export async function showCalendarImportDialog(calendarGroupRoot: CalendarGroupR
if (!(await partialImportConfirmation(rejectedEvents.get(EventImportRejectionReason.Inversed) ?? [], "importEndNotAfterStartInEvent_msg", total))) return
if (!(await partialImportConfirmation(rejectedEvents.get(EventImportRejectionReason.Pre1970) ?? [], "importPre1970StartInEvent_msg", total))) return

return await importEvents(eventsForCreation)
if (eventsForCreation.length > 0) {
showEventsImportDialog(
eventsForCreation.map((ev) => ev.event),
async (dialog) => {
dialog.close()
await importEvents(eventsForCreation)
},
"importEvents_label",
)
}
}

async function selectAndParseIcalFile(): Promise<ParsedEvent[]> {
try {
const dataFiles = await showFileChooser(true, ["ical", "ics", "ifb", "icalendar"])
const dataFiles = isApp()
? await showNativeFilePicker(["ical", "ics", "ifb", "icalendar"])
: await showFileChooser(true, ["ical", "ics", "ifb", "icalendar"])
const contents = dataFiles.map((file) => parseCalendarFile(file).contents)
return contents.flat()
} catch (e) {
Expand Down
11 changes: 11 additions & 0 deletions src/calendar-app/calendar/gui/CalendarGuiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -866,3 +866,14 @@ async function confirmDeleteClose(model: CalendarEventPreviewViewModel, onClose?
export function getDisplayEventTitle(title: string): string {
return title ?? title !== "" ? title : lang.get("noTitle_label")
}

export function renderCalendarColor(selectedCalendar: CalendarInfo | null, groupColors: Map<Id, string>) {
const color = selectedCalendar ? groupColors.get(selectedCalendar.groupInfo.group) ?? defaultCalendarColor : null
return m(".mt-xs", {
style: {
width: "100px",
height: "10px",
background: color ? "#" + color : "transparent",
},
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { ViewHolder } from "../../../common/gui/base/List.js"
import { styles } from "../../../common/gui/styles.js"
import { DefaultAnimationTime } from "../../../common/gui/animation/Animations.js"

import { formatEventDuration, getDisplayEventTitle, getEventColor, getGroupColors } from "../gui/CalendarGuiUtils.js"
import { GroupColors } from "./CalendarView.js"
import { formatEventDuration, getDisplayEventTitle, getEventColor, getGroupColors } from "./CalendarGuiUtils.js"
import { GroupColors } from "../view/CalendarView.js"
import { lang } from "../../../common/misc/LanguageViewModel.js"

export class CalendarRow implements VirtualRow<CalendarEvent> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { CalendarEventModel, CalendarOperation, ReadonlyReason } from "../evente

import { getSharedGroupName } from "../../../../common/sharing/GroupUtils.js"

import { createAlarmIntervalItems, createCustomRepeatRuleUnitValues, humanDescriptionForAlarmInterval, renderCalendarColor } from "../CalendarGuiUtils.js"
import { RemindersEditor, RemindersEditorAttrs } from "../RemindersEditor.js"

export type CalendarEventEditViewAttrs = {
Expand Down Expand Up @@ -197,22 +198,11 @@ export class CalendarEventEditView implements Component<CalendarEventEditViewAtt
},
icon: BootIcons.Expand,
disabled: !model.canChangeCalendar() || availableCalendars.length < 2,
helpLabel: () => this.renderCalendarColor(model.editModels.whoModel.selectedCalendar, vnode.attrs.groupColors),
helpLabel: () => renderCalendarColor(model.editModels.whoModel.selectedCalendar, vnode.attrs.groupColors),
} satisfies DropDownSelectorAttrs<CalendarInfo>),
)
}

private renderCalendarColor(selectedCalendar: CalendarInfo | null, groupColors: Map<Id, string>) {
const color = selectedCalendar ? groupColors.get(selectedCalendar.groupInfo.group) ?? defaultCalendarColor : null
return m(".mt-xs", {
style: {
width: "100px",
height: "10px",
background: color ? "#" + color : "transparent",
},
})
}

private renderRemindersEditor(vnode: Vnode<CalendarEventEditViewAttrs>): Children {
if (!vnode.attrs.model.editModels.alarmModel.canEditReminders) return null
const { alarmModel } = vnode.attrs.model.editModels
Expand Down
2 changes: 1 addition & 1 deletion src/calendar-app/calendar/search/view/SearchListView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { lang } from "../../../../common/misc/LanguageViewModel.js"
import { theme } from "../../../../common/gui/theme.js"
import { VirtualRow } from "../../../../common/gui/base/ListUtils.js"
import { styles } from "../../../../common/gui/styles.js"
import { KindaCalendarRow } from "../../view/CalendarRow.js"
import { KindaCalendarRow } from "../../gui/CalendarRow.js"

assertMainOrNode()

Expand Down
7 changes: 3 additions & 4 deletions src/calendar-app/calendar/view/CalendarView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView<Calen
label: "edit_action",
icon: Icons.Edit,
size: ButtonSize.Compact,
click: () => this.onPressedEditCalendar(calendarInfo, colorValue, existingGroupSettings, userSettingsGroupRoot, sharedCalendar),
click: () => this.onPressedEditCalendar(groupInfo, colorValue, existingGroupSettings, userSettingsGroupRoot, sharedCalendar),
},
{
label: "sharing_label",
Expand All @@ -740,7 +740,7 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView<Calen
}
},
},
!isApp() && group.type === GroupType.Calendar && hasCapabilityOnGroup(user, group, ShareCapability.Write)
group.type === GroupType.Calendar && hasCapabilityOnGroup(user, group, ShareCapability.Write)
? {
label: "import_action",
icon: Icons.Import,
Expand Down Expand Up @@ -800,13 +800,12 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView<Calen
}

private onPressedEditCalendar(
calendarInfo: CalendarInfo,
groupInfo: GroupInfo,
colorValue: string,
existingGroupSettings: GroupSettings | null,
userSettingsGroupRoot: UserSettingsGroupRoot,
shared: boolean,
) {
const { groupInfo } = calendarInfo
showEditCalendarDialog(
{
name: getSharedGroupName(groupInfo, locator.logins.getUserController(), shared),
Expand Down
Loading

0 comments on commit 3641db5

Please sign in to comment.