diff --git a/css/import.scss b/css/import.scss index be1b1e0bae..43f424423e 100644 --- a/css/import.scss +++ b/css/import.scss @@ -5,37 +5,44 @@ .import-modal { .modal-container { - padding: 24px !important; - min-width: 50%; + padding: calc(var(--default-grid-baseline) * 6) !important; overflow: visible !important; - .import-modal__title, - .import-modal__subtitle { + .import-modal__title { text-align: center; + font-size: calc(var(--default-grid-baseline) * 5); + font-weight: bold; + } + + .import-modal__progress-bar { + margin-top: calc(var(--default-grid-baseline) * 6); + margin-bottom: calc(var(--default-grid-baseline) * 4); } .import-modal__actions { + margin-top: calc(var(--default-grid-baseline) * 3); display: flex; - gap: 5px; + gap: var(--default-grid-baseline); + justify-content: flex-end; } - .import-modal-file-item { - display: flex; + .import-modal__counters { + display: grid; + grid-template-columns: repeat(3, 1fr); + column-gap: calc(var(--default-grid-baseline) * 2); + row-gap: calc(var(--default-grid-baseline) * 2); margin-top: calc(var(--default-grid-baseline) * 2); - margin-bottom: calc(var(--default-grid-baseline) * 2); - &--header { - font-weight: bold; + .notecard { + align-items: center; + margin: 0 !important; } + } - &__filename { - flex: 2 1 0; - } + .import-modal-file-item { + margin-top: calc(var(--default-grid-baseline) * 2); + margin-bottom: calc(var(--default-grid-baseline) * 2); - &__calendar-select { - flex: 1 1 0; - } - &__calendar-disabled-hint { color: var(--color-text-maxcontrast); } diff --git a/src/components/AppNavigation/Settings.vue b/src/components/AppNavigation/Settings.vue index a70a5a5413..0127b19b9c 100644 --- a/src/components/AppNavigation/Settings.vue +++ b/src/components/AppNavigation/Settings.vue @@ -186,7 +186,7 @@ import { } from '../../models/consts.js' import { getCurrentUserPrincipal } from '../../services/caldavService.js' import useCalendarsStore from '../../store/calendars.js' -import useImportFilesStore from '../../store/importFiles.js' +import useImportStore from '../../store/import.ts' import usePrincipalsStore from '../../store/principals.js' import useSettingsStore from '../../store/settings.js' import { @@ -248,7 +248,7 @@ export default { }, computed: { - ...mapStores(useSettingsStore, useCalendarsStore, useImportFilesStore, usePrincipalsStore), + ...mapStores(useSettingsStore, useCalendarsStore, useImportStore, usePrincipalsStore), ...mapState(useSettingsStore, [ 'eventLimit', 'showTasks', @@ -277,26 +277,10 @@ export default { return isAfterVersion(34) }, - files() { - return this.importFilesStore.importFiles - }, - hasBirthdayCalendar() { return !!this.calendarsStore.getBirthdayCalendar }, - showUploadButton() { - return this.importStateStore.importState.stage === IMPORT_STAGE_DEFAULT - }, - - showImportModal() { - return this.importStateStore.importState.stage === IMPORT_STAGE_PROCESSING - }, - - showProgressBar() { - return this.importStateStore.importState.stage === IMPORT_STAGE_IMPORTING - }, - settingsTitle() { return this.$t('calendar', 'Calendar settings') }, diff --git a/src/components/AppNavigation/Settings/ImportScreen.vue b/src/components/AppNavigation/Settings/ImportScreen.vue index 1d22ba3908..67c9f40a6f 100644 --- a/src/components/AppNavigation/Settings/ImportScreen.vue +++ b/src/components/AppNavigation/Settings/ImportScreen.vue @@ -6,57 +6,117 @@ + + diff --git a/src/components/AppNavigation/Settings/SettingsImportSection.vue b/src/components/AppNavigation/Settings/SettingsImportSection.vue index 17c1346e25..f1b52b5f8d 100644 --- a/src/components/AppNavigation/Settings/SettingsImportSection.vue +++ b/src/components/AppNavigation/Settings/SettingsImportSection.vue @@ -31,7 +31,10 @@ @@ -48,17 +51,9 @@ import { NcButton } from '@nextcloud/vue' import { mapState, mapStores } from 'pinia' import Upload from 'vue-material-design-icons/TrayArrowUp.vue' import ImportScreen from './ImportScreen.vue' -import { - IMPORT_STAGE_AWAITING_USER_SELECT, - IMPORT_STAGE_DEFAULT, - IMPORT_STAGE_IMPORTING, - IMPORT_STAGE_PROCESSING, -} from '../../../models/consts.js' import { readFileAsText } from '../../../services/readFileAsTextService.js' import useCalendarObjectsStore from '../../../store/calendarObjects.js' -import useCalendarsStore from '../../../store/calendars.js' -import useImportFilesStore from '../../../store/importFiles.js' -import useImportStateStore from '../../../store/importState.js' +import useImportStore from '../../../store/import.ts' export default { name: 'SettingsImportSection', @@ -76,16 +71,15 @@ export default { }, computed: { - ...mapStores(useImportStateStore, useImportFilesStore, useCalendarsStore, useCalendarObjectsStore), - ...mapState(useImportFilesStore, { - files: 'importFiles', + ...mapStores(useImportStore, useCalendarObjectsStore), + ...mapState(useImportStore, { + entries: 'files', }), - ...mapState(useImportStateStore, { + ...mapState(useImportStore, { stage: 'stage', - total: 'total', - accepted: 'accepted', - denied: 'denied', + totals: 'totals', + activeSession: 'activeSession', }), /** @@ -94,7 +88,11 @@ export default { * @return {number} */ imported() { - return this.accepted + this.denied + return this.totals.processed + }, + + total() { + return this.totals.discovered }, /** @@ -103,7 +101,7 @@ export default { * @return {boolean} */ allowUploadOfFiles() { - return this.stage === IMPORT_STAGE_DEFAULT + return this.stage === 'idle' }, /** @@ -112,7 +110,7 @@ export default { * @return {boolean} */ showImportModal() { - return this.stage === IMPORT_STAGE_AWAITING_USER_SELECT + return this.stage === 'selecting' || this.stage === 'importing' }, /** @@ -121,7 +119,7 @@ export default { * @return {boolean} */ showProgressBar() { - return this.stage === IMPORT_STAGE_IMPORTING + return false }, /** @@ -154,7 +152,7 @@ export default { * @param {Event} event The change-event of the input-field */ async processFiles(event) { - this.importStateStore.stage = IMPORT_STAGE_PROCESSING + this.importStore.stage = 'preparing' let addedFiles = false for (const file of event.target.files) { @@ -201,7 +199,7 @@ export default { continue } - this.importFilesStore.addFile({ + this.importStore.addFile({ contents, lastModified, name, @@ -214,12 +212,12 @@ export default { if (!addedFiles) { showError(this.$t('calendar', 'No valid files found, aborting import')) - this.importFilesStore.removeAllFiles() - this.importStateStore.resetState() + this.importStore.removeAllFiles() + this.importStore.reset() return } - this.importStateStore.stage = IMPORT_STAGE_AWAITING_USER_SELECT + this.importStore.stage = 'selecting' }, /** @@ -227,18 +225,18 @@ export default { * This will show */ async importCalendar() { - await this.calendarsStore.importEventsIntoCalendar() + const totals = await this.importStore.startImport() - if (this.total === this.accepted) { - showSuccess(this.$n('calendar', 'Successfully imported %n event', 'Successfully imported %n events', this.total)) + if (totals.discovered === totals.created + totals.updated + totals.exists && totals.error === 0) { + showSuccess(this.$n('calendar', 'Successfully imported %n event', 'Successfully imported %n events', totals.discovered)) } else { showWarning(this.$t('calendar', 'Import partially failed. Imported {accepted} out of {total}.', { - accepted: this.accepted, - total: this.total, + accepted: totals.processed - totals.error, + total: totals.discovered, })) } - this.importFilesStore.removeAllFiles() - this.importStateStore.resetState() + this.importStore.removeAllFiles() + this.importStore.reset() // Once we are done importing, reload the calendar view this.calendarObjectsStore.modificationCount++ @@ -250,8 +248,8 @@ export default { * Resets the import sate */ cancelImport() { - this.importFilesStore.removeAllFiles() - this.importStateStore.resetState() + this.importStore.removeAllFiles() + this.importStore.reset() this.resetInput() }, diff --git a/src/services/importService.ts b/src/services/importService.ts new file mode 100644 index 0000000000..d7ead554b9 --- /dev/null +++ b/src/services/importService.ts @@ -0,0 +1,103 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { + ImportRequest, + ImportStreamDataResponse, + ImportStreamRequest, + ImportStreamResponse, +} from '@/types/import' + +/** + * Calendar import service + */ +import { getRequestToken } from '@nextcloud/auth' +import { generateOcsUrl } from '@nextcloud/router' + +const API_URL = generateOcsUrl('/calendar/import') +const OPERATION = 'calendar import' + +export function generateTransactionId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}` +} + +async function transceiveStream( + request: ImportRequest, + onData: (data: ImportStreamDataResponse) => void, +): Promise { + const streamRequest: ImportStreamRequest = { + transaction: generateTransactionId(), + ...request, + } + + const response = await fetch(API_URL, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/x-ndjson', + 'Content-Type': 'application/json', + 'OCS-APIRequest': 'true', + requesttoken: getRequestToken() ?? '', + }, + body: JSON.stringify(streamRequest), + }) + + if (!response.ok) { + throw new Error(`[${OPERATION}] Request failed with status ${response.status}`) + } + + const stream = response.body + if (!stream || typeof stream.getReader !== 'function') { + throw new Error(`[${OPERATION}] Response body is not readable`) + } + + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (!line.trim()) { + continue + } + + processStream(JSON.parse(line) as ImportStreamResponse, onData) + } + } + + buffer += decoder.decode() + if (buffer.trim()) { + processStream(JSON.parse(buffer) as ImportStreamResponse, onData) + } + } finally { + reader.releaseLock() + } +} + +function processStream(message: ImportStreamResponse, onData: (data: ImportStreamDataResponse) => void): void { + if (message.type === 'control') { + return + } + + onData(message) +} + +export const importService = { + async import(request: ImportRequest, onData: (data: ImportStreamDataResponse) => void): Promise { + return transceiveStream(request, onData) + }, +} + +export default importService diff --git a/src/store/calendars.js b/src/store/calendars.js index 8974f8a79e..60b6a63021 100644 --- a/src/store/calendars.js +++ b/src/store/calendars.js @@ -9,8 +9,6 @@ import { mapCDavObjectToCalendarObject } from '../models/calendarObject.js' import { CALDAV_BIRTHDAY_CALENDAR, CALDAV_PERSONAL_CALENDAR, - IMPORT_STAGE_IMPORTING, - IMPORT_STAGE_PROCESSING, } from '../models/consts.js' /** * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors @@ -24,14 +22,11 @@ import { findPublicCalendarsByTokens, } from '../services/caldavService.js' import getTimezoneManager from '../services/timezoneDataProviderService.js' -import { uidToHexColor } from '../utils/color.js' import { dateFactory, getUnixTimestampFromDate } from '../utils/date.js' import logger from '../utils/logger.js' import { isAfterVersion } from '../utils/nextcloudVersion.ts' import useCalendarObjectsStore from './calendarObjects.js' import useFetchedTimeRangesStore from './fetchedTimeRanges.js' -import useImportFilesStore from './importFiles.js' -import useImportStateStore from './importState.js' import usePrincipalsStore from './principals.js' import useSettingsStore from './settings.js' @@ -828,93 +823,6 @@ export default defineStore('calendars', { return calendarObject }, - /** - * Import events into calendar - * - */ - async importEventsIntoCalendar() { - const importStateStore = useImportStateStore() - const importFilesStore = useImportFilesStore() - const principalsStore = usePrincipalsStore() - const fetchedTimeRangesStore = useFetchedTimeRangesStore() - const calendarObjectsStore = useCalendarObjectsStore() - - importStateStore.stage = IMPORT_STAGE_IMPORTING - - // Create a copy - const files = importFilesStore.importFiles.slice() - - let totalCount = 0 - for (const file of files) { - totalCount += file.parser.getItemCount() - - const calendarId = importFilesStore.importCalendarRelation[file.id] - if (calendarId === 'new') { - const displayName = file.parser.getName() || t('calendar', 'Imported {filename}', { - filename: file.name, - }) - const color = file.parser.getColor() || uidToHexColor(displayName) - const components = [] - if (file.parser.containsVEvents()) { - components.push('VEVENT') - } - if (file.parser.containsVJournals()) { - components.push('VJOURNAL') - } - if (file.parser.containsVTodos()) { - components.push('VTODO') - } - - const response = await createCalendar(displayName, color, components, 0) - const calendar = mapDavCollectionToCalendar(response, principalsStore.getCurrentUserPrincipal) - this.addCalendarMutation({ calendar }) - importFilesStore.setCalendarForFileId({ - fileId: file.id, - calendarId: calendar.id, - }) - } - } - - importStateStore.total = totalCount - - const limit = pLimit(3) - const requests = [] - - for (const file of files) { - const calendarId = importFilesStore.importCalendarRelation[file.id] - const calendar = this.getCalendarById(calendarId) - - for (const item of file.parser.getItemIterator()) { - requests.push(limit(async () => { - const ics = item.toICS() - - let davObject - try { - davObject = await calendar.dav.createVObject(ics) - } catch (error) { - importStateStore.denied++ - console.error(error) - return - } - - const calendarObject = mapCDavObjectToCalendarObject(davObject, calendarId) - calendarObjectsStore.appendCalendarObjectMutation({ calendarObject }) - this.addCalendarObjectToCalendarMutation({ - calendar, - calendarObjectId: calendarObject.id, - }) - fetchedTimeRangesStore.addCalendarObjectIdToAllTimeRangesOfCalendar({ - calendarId: calendar.id, - calendarObjectId: calendarObject.id, - }) - importStateStore.accepted++ - })) - } - } - - await Promise.all(requests) - importStateStore.stage = IMPORT_STAGE_PROCESSING - }, /** * * @param {object} data The data destructuring object diff --git a/src/store/import.ts b/src/store/import.ts new file mode 100644 index 0000000000..e6fb239869 --- /dev/null +++ b/src/store/import.ts @@ -0,0 +1,339 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { + ImportCounters, + ImportFileAdd, + ImportFileEntry, + ImportFileOptions, + ImportFileSource, + ImportFormat, + ImportRequest, + ImportSession, + ImportSessionStage, + ImportStreamDataResponse, +} from '@/types/import' + +import { translate as t } from '@nextcloud/l10n' +import { defineStore } from 'pinia' +import { computed, markRaw, ref } from 'vue' +import { mapDavCollectionToCalendar } from '@/models/calendar.js' +import { createCalendar } from '@/services/caldavService.js' +import { importService } from '@/services/importService' +import useCalendarsStore from '@/store/calendars.js' +import usePrincipalsStore from '@/store/principals.js' +import { uidToHexColor } from '@/utils/color.js' + +/** + * Create a fresh counter object for import progress aggregation. + */ +function createEmptyCounters(): ImportCounters { + return { + discovered: 0, + processed: 0, + created: 0, + updated: 0, + exists: 0, + error: 0, + } +} + +/** + * Map a file MIME type to the backend import format. + * + * @param type MIME type of the imported file + */ +function determineFileFormat(type: string): ImportFormat { + switch (type) { + case 'application/calendar+json': + return 'jcal' + case 'application/calendar+xml': + return 'xcal' + default: + return 'ical' + } +} + +/** + * Extract the calendar URI expected by the import controller from a CalDAV URL. + * + * @param url CalDAV collection URL + */ +function getCalendarUriFromUrl(url: string): string { + const normalizedUrl = url.endsWith('/') ? url.slice(0, -1) : url + return normalizedUrl.slice(normalizedUrl.lastIndexOf('/') + 1) +} + +export default defineStore('import', () => { + const lastFileInsertId = ref(-1) + const files = ref([]) + const stage = ref('idle') + const running = ref(false) + const activeFileId = ref(null) + const lastError = ref(null) + const sessions = ref>({}) + const order = ref([]) + + const totals = computed(() => { + return order.value.reduce((aggregate, fileId) => { + const session = sessions.value[fileId] + if (!session) { + return aggregate + } + + aggregate.discovered += session.counters.discovered + aggregate.processed += session.counters.processed + aggregate.created += session.counters.created + aggregate.updated += session.counters.updated + aggregate.exists += session.counters.exists + aggregate.error += session.counters.error + return aggregate + }, createEmptyCounters()) + }) + + const activeSession = computed(() => { + return activeFileId.value !== null ? sessions.value[activeFileId.value] ?? null : null + }) + + /** + * Adds a file to the import queue. + * + * @param data File metadata and parser + */ + function addFile({ contents, lastModified, name, parser, size, type }: ImportFileAdd): void { + const file: ImportFileSource = { + id: ++lastFileInsertId.value, + contents, + lastModified, + name, + parser: markRaw(parser), + size, + type, + } + + files.value = [...files.value, { + file, + calendarId: null, + options: { + format: determineFileFormat(file.type), + supersede: false, + }, + }] + } + + /** + * Clear the queued import files and their per-file selections. + */ + function removeAllFiles(): void { + files.value = [] + } + + /** + * Reset the active import session state. + */ + function reset(): void { + stage.value = 'idle' + running.value = false + activeFileId.value = null + lastError.value = null + sessions.value = {} + order.value = [] + } + + /** + * Find a queued entry by its file id. + * + * @param fileId Imported file identifier + */ + function getEntry(fileId: number): ImportFileEntry | undefined { + return files.value.find((entry) => entry.file.id === fileId) + } + + /** + * Associate a queued file with a selected calendar id. + * + * @param data File and calendar identifiers + */ + function setCalendarForFile({ fileId, calendarId }: { fileId: number, calendarId: string }): void { + const entry = getEntry(fileId) + if (entry) { + entry.calendarId = calendarId + } + } + + /** + * Override the import options for a queued file. + * + * @param data File identifier and the options to merge + */ + function setOptionsForFile({ fileId, options }: { fileId: number, options: Partial }): void { + const entry = getEntry(fileId) + if (entry) { + entry.options = { ...entry.options, ...options } + } + } + + /** + * Resolve or create the destination calendar for a queued entry. + * + * @param entry Entry being imported + */ + async function ensureCalendarForEntry(entry: ImportFileEntry): Promise<{ id: string, uri: string, displayName: string }> { + const calendarsStore = useCalendarsStore() + const principalsStore = usePrincipalsStore() + const { file, calendarId } = entry + + if (calendarId !== 'new') { + const calendar = calendarId !== null ? calendarsStore.getCalendarById(calendarId) : null + if (!calendar) { + throw new Error(t('calendar', 'Selected calendar not found')) + } + + return { + id: calendar.id, + uri: getCalendarUriFromUrl(calendar.url), + displayName: calendar.displayName, + } + } + + const displayName = file.parser.getName?.() || t('calendar', 'Imported {filename}', { + filename: file.name, + }) + const color = file.parser.getColor?.() || uidToHexColor(displayName) + const components: string[] = [] + if (file.parser.containsVEvents()) { + components.push('VEVENT') + } + if (file.parser.containsVJournals()) { + components.push('VJOURNAL') + } + if (file.parser.containsVTodos()) { + components.push('VTODO') + } + + const response = await createCalendar(displayName, color, components, 0) + const calendar = mapDavCollectionToCalendar(response, principalsStore.getCurrentUserPrincipal) + calendarsStore.addCalendarMutation({ calendar }) + setCalendarForFile({ + fileId: file.id, + calendarId: calendar.id, + }) + + return { + id: calendar.id, + uri: getCalendarUriFromUrl(calendar.url), + displayName: calendar.displayName, + } + } + + /** + * Stream-import all selected files sequentially while preserving live counters. + */ + async function startImport(): Promise { + const entries = files.value.slice() + if (entries.length === 0) { + return createEmptyCounters() + } + + sessions.value = Object.fromEntries(entries.map(({ file }) => [file.id, { + fileId: file.id, + fileName: file.name, + targetDisplayName: '', + targetUri: null, + status: 'pending', + counters: createEmptyCounters(), + recentResults: [], + lastError: null, + }])) + order.value = entries.map(({ file }) => file.id) + stage.value = 'importing' + running.value = true + lastError.value = null + + try { + for (const entry of entries) { + const { file, options } = entry + activeFileId.value = file.id + const session = sessions.value[file.id] + if (!session) { + continue + } + + session.status = 'importing' + const target = await ensureCalendarForEntry(entry) + session.targetDisplayName = target.displayName + session.targetUri = target.uri + + const request: ImportRequest = { + target: target.uri, + options: { + format: options.format, + validation: 1, + errors: 0, + supersede: options.supersede, + }, + data: file.contents, + } + + await importService.import(request, (event) => handleEvent(file.id, event)) + session.status = session.counters.error > 0 ? 'error' : 'completed' + } + + stage.value = 'completed' + return totals.value + } catch (error) { + stage.value = 'error' + lastError.value = error instanceof Error ? error.message : t('calendar', 'Import failed') + if (activeFileId.value !== null && sessions.value[activeFileId.value]) { + sessions.value[activeFileId.value].status = 'error' + sessions.value[activeFileId.value].lastError = lastError.value + } + throw error + } finally { + running.value = false + activeFileId.value = null + } + } + + /** + * Reduce a streamed import event into the per-file session state. + * + * @param fileId Imported file identifier + * @param event Streamed import event + */ + function handleEvent(fileId: number, event: ImportStreamDataResponse): void { + const session = sessions.value[fileId] + if (!session) { + return + } + + if (event.type === 'count') { + session.counters.discovered = event.vevent + event.vtodo + event.vjournal + return + } + + session.recentResults = [event, ...session.recentResults].slice(0, 25) + session.counters.processed += 1 + session.counters[event.disposition] += 1 + } + + return { + activeSession, + addFile, + files, + lastError, + lastFileInsertId, + order, + removeAllFiles, + reset, + running, + sessions, + setCalendarForFile, + setOptionsForFile, + stage, + startImport, + totals, + } +}) diff --git a/src/store/importFiles.js b/src/store/importFiles.js deleted file mode 100644 index b897c6f9da..0000000000 --- a/src/store/importFiles.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { defineStore } from 'pinia' -import { markRaw } from 'vue' - -export default defineStore('importFiles', { - state: () => { - return { - lastFileInsertId: -1, - importFiles: [], - importFilesById: {}, - importCalendarRelation: {}, - } - }, - actions: { - /** - * Adds a file to the state - * - * @param {object} data The destructuring object - * @param {string} data.contents Contents of file - * @param {number} data.lastModified Timestamp of last modification - * @param {string} data.name Name of file - * @param {AbstractParser} data.parser The parser - * @param {number} data.size Size of file - * @param {string} data.type mime-type of file - */ - addFile({ contents, lastModified, name, parser, size, type }) { - const file = { - id: ++this.lastFileInsertId, - contents, - lastModified, - name, - parser: markRaw(parser), - size, - type, - } - - this.importFiles = [...this.importFiles, file] - this.importFilesById[file.id] = file - }, - - /** - * Sets a calendar for the file - * - * @param {object} data The destructuring object - * @param {number} data.fileId Id of file to select calendar for - * @param {string} data.calendarId Id of calendar to import file into - */ - setCalendarForFileId({ fileId, calendarId }) { - this.importCalendarRelation[fileId] = calendarId - }, - - /** - * Removes all files from state - */ - removeAllFiles() { - this.importFiles = [] - this.importFilesById = {} - this.importCalendarRelation = {} - }, - }, -}) diff --git a/src/store/importState.js b/src/store/importState.js deleted file mode 100644 index 734c2dcaad..0000000000 --- a/src/store/importState.js +++ /dev/null @@ -1,28 +0,0 @@ -import { defineStore } from 'pinia' -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { IMPORT_STAGE_DEFAULT } from '../models/consts.js' - -export default defineStore('importState', { - state: () => { - return { - total: 0, - accepted: 0, - denied: 0, - stage: IMPORT_STAGE_DEFAULT, - } - }, - actions: { - /** - * Reset to the default state - */ - resetState() { - this.total = 0 - this.accepted = 0 - this.denied = 0 - this.stage = IMPORT_STAGE_DEFAULT - }, - }, -}) diff --git a/src/types/import.ts b/src/types/import.ts new file mode 100644 index 0000000000..fb65096192 --- /dev/null +++ b/src/types/import.ts @@ -0,0 +1,110 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export type ImportFormat = 'ical' | 'jcal' | 'xcal' +export type ImportDisposition = 'created' | 'updated' | 'exists' | 'error' +export type ImportSessionStage = 'idle' | 'preparing' | 'selecting' | 'importing' | 'completed' | 'error' + +export type ImportFileSource = { + id: number + name: string + contents: string + lastModified: number + size: number + type: string + parser: { + getName?: () => string | null + getColor?: () => string | null + containsVEvents: () => boolean + containsVTodos: () => boolean + containsVJournals: () => boolean + } +} + +export type ImportFileAdd = Omit & { + parser: ImportFileSource['parser'] +} + +export interface ImportFileOptions { + format: ImportFormat + supersede: boolean +} + +export interface ImportFileEntry { + file: ImportFileSource + calendarId: string | null + options: ImportFileOptions +} + +export interface ImportCounters { + discovered: number + processed: number + created: number + updated: number + exists: number + error: number +} + +export interface ImportSession { + fileId: number + fileName: string + targetDisplayName: string + targetUri: string | null + status: 'pending' | 'importing' | 'completed' | 'error' + counters: ImportCounters + recentResults: ImportStreamObjectResponse[] + lastError: string | null +} + +export interface ImportStreamRequest { + transaction: string + target: string + options: { + format: ImportFormat + validation: number + errors: number + supersede: boolean + } + data: string + user?: string +} + +export interface ImportStreamStartResponse { + type: 'control' + transaction: string + disposition: 'start' +} + +export interface ImportStreamEndResponse { + type: 'control' + transaction: string + disposition: 'end' +} + +export interface ImportStreamCountResponse { + type: 'count' + transaction: string + vevent: number + vtodo: number + vjournal: number +} + +export interface ImportStreamObjectResponse { + type: 'object' + transaction: string + identifier: string | null + disposition: ImportDisposition + errors: string[] +} + +export type ImportStreamDataResponse = ImportStreamCountResponse | ImportStreamObjectResponse + +export type ImportStreamResponse + = | ImportStreamStartResponse + | ImportStreamEndResponse + | ImportStreamCountResponse + | ImportStreamObjectResponse + +export type ImportRequest = Omit diff --git a/tests/javascript/unit/store/import.test.js b/tests/javascript/unit/store/import.test.js new file mode 100644 index 0000000000..866bdf17e2 --- /dev/null +++ b/tests/javascript/unit/store/import.test.js @@ -0,0 +1,282 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { createPinia, setActivePinia } from 'pinia' +import useImportStore from '../../../../src/store/import.ts' +import { importService } from '../../../../src/services/importService' + +const { calendarsStoreMock, principalsStoreMock } = vi.hoisted(() => ({ + calendarsStoreMock: { + getCalendarById: vi.fn(), + addCalendarMutation: vi.fn(), + }, + principalsStoreMock: { + getCurrentUserPrincipal: { url: 'http://localhost/principal/admin' }, + }, +})) + +vi.mock('../../../../src/services/importService', () => ({ + importService: { import: vi.fn() }, +})) +vi.mock('../../../../src/services/caldavService.js', () => ({ + createCalendar: vi.fn(), +})) +vi.mock('../../../../src/models/calendar.js', () => ({ + mapDavCollectionToCalendar: vi.fn(), +})) +vi.mock('../../../../src/store/calendars.js', () => ({ + default: () => calendarsStoreMock, +})) +vi.mock('../../../../src/store/principals.js', () => ({ + default: () => principalsStoreMock, +})) + +/** + * Build a queueable import file payload (the shape `addFile` expects). + * + * @param {object} overrides Properties overriding the defaults + * @return {object} A file payload for `addFile` + */ +function makeFilePayload(overrides = {}) { + return { + contents: 'BEGIN:VCALENDAR...', + lastModified: 1590151056, + name: 'file.ics', + parser: {}, + size: 1337, + type: 'text/calendar', + ...overrides, + } +} + +describe('store/import test suite', () => { + beforeEach(() => { + importService.import.mockReset() + calendarsStoreMock.getCalendarById.mockReset() + calendarsStoreMock.addCalendarMutation.mockReset() + setActivePinia(createPinia()) + }) + + it('should provide a default state', () => { + const store = useImportStore() + + expect(store.files).toEqual([]) + expect(store.sessions).toEqual({}) + expect(store.order).toEqual([]) + expect(store.lastFileInsertId).toBe(-1) + expect(store.stage).toBe('idle') + expect(store.running).toBe(false) + expect(store.lastError).toBe(null) + expect(store.activeSession).toBe(null) + expect(store.totals).toEqual({ + discovered: 0, + processed: 0, + created: 0, + updated: 0, + exists: 0, + error: 0, + }) + }) + + it('should add files with an incrementing id and default per-file options', () => { + const store = useImportStore() + + store.addFile(makeFilePayload({ name: 'ical.ics', type: 'text/calendar' })) + store.addFile(makeFilePayload({ name: 'jcal.json', type: 'application/calendar+json' })) + store.addFile(makeFilePayload({ name: 'xcal.xml', type: 'application/calendar+xml' })) + + expect(store.lastFileInsertId).toBe(2) + expect(store.files).toEqual([ + { + file: { ...makeFilePayload({ name: 'ical.ics', type: 'text/calendar' }), id: 0 }, + calendarId: null, + options: { format: 'ical', supersede: false }, + }, + { + file: { ...makeFilePayload({ name: 'jcal.json', type: 'application/calendar+json' }), id: 1 }, + calendarId: null, + options: { format: 'jcal', supersede: false }, + }, + { + file: { ...makeFilePayload({ name: 'xcal.xml', type: 'application/calendar+xml' }), id: 2 }, + calendarId: null, + options: { format: 'xcal', supersede: false }, + }, + ]) + }) + + it('should merge per-file options without dropping untouched keys', () => { + const store = useImportStore() + store.addFile(makeFilePayload()) + + store.setOptionsForFile({ fileId: 0, options: { supersede: true } }) + expect(store.files[0].options).toEqual({ format: 'ical', supersede: true }) + + store.setOptionsForFile({ fileId: 0, options: { format: 'xcal' } }) + expect(store.files[0].options).toEqual({ format: 'xcal', supersede: true }) + }) + + it('should ignore option changes for an unknown file id', () => { + const store = useImportStore() + store.addFile(makeFilePayload()) + + expect(() => store.setOptionsForFile({ fileId: 99, options: { supersede: true } })).not.toThrow() + expect(store.files[0].options).toEqual({ format: 'ical', supersede: false }) + }) + + it('should set the calendar for a queued file and ignore unknown file ids', () => { + const store = useImportStore() + store.addFile(makeFilePayload()) + + store.setCalendarForFile({ fileId: 0, calendarId: 'CALENDAR-ID-1' }) + expect(store.files[0].calendarId).toBe('CALENDAR-ID-1') + + expect(() => store.setCalendarForFile({ fileId: 99, calendarId: 'CALENDAR-ID-2' })).not.toThrow() + expect(store.files[0].calendarId).toBe('CALENDAR-ID-1') + }) + + it('should remove all queued files', () => { + const store = useImportStore() + store.addFile(makeFilePayload({ name: 'a.ics' })) + store.addFile(makeFilePayload({ name: 'b.ics' })) + + store.removeAllFiles() + + expect(store.files).toEqual([]) + // The insert id keeps growing so re-added files never collide with prior ones. + expect(store.lastFileInsertId).toBe(1) + }) + + it('should reset the run state while keeping the queued files', () => { + const store = useImportStore() + store.addFile(makeFilePayload()) + store.stage = 'importing' + store.running = true + store.lastError = 'boom' + store.order = [0] + store.sessions = { 0: { status: 'error' } } + + store.reset() + + expect(store.stage).toBe('idle') + expect(store.running).toBe(false) + expect(store.lastError).toBe(null) + expect(store.order).toEqual([]) + expect(store.sessions).toEqual({}) + // Files are part of the queue, not the run, so they survive a reset. + expect(store.files).toHaveLength(1) + }) + + it('should aggregate counters across sessions in totals', () => { + const store = useImportStore() + store.order = [0, 1] + store.sessions = { + 0: { counters: { discovered: 2, processed: 2, created: 1, updated: 1, exists: 0, error: 0 } }, + 1: { counters: { discovered: 3, processed: 1, created: 0, updated: 0, exists: 1, error: 0 } }, + } + + expect(store.totals).toEqual({ + discovered: 5, + processed: 3, + created: 1, + updated: 1, + exists: 1, + error: 0, + }) + }) + + it('should return empty counters and not stream when the queue is empty', async () => { + const store = useImportStore() + + const totals = await store.startImport() + + expect(importService.import).not.toHaveBeenCalled() + expect(totals).toEqual({ + discovered: 0, + processed: 0, + created: 0, + updated: 0, + exists: 0, + error: 0, + }) + }) + + it('should stream into the selected calendar and aggregate the results', async () => { + calendarsStoreMock.getCalendarById.mockReturnValue({ + id: 'cal-1', + url: 'http://localhost/remote.php/dav/calendars/admin/personal/', + displayName: 'Personal', + }) + importService.import.mockImplementation(async (request, onData) => { + onData({ type: 'count', transaction: 't', vevent: 2, vtodo: 0, vjournal: 0 }) + onData({ type: 'object', transaction: 't', identifier: 'a', disposition: 'created', errors: [] }) + onData({ type: 'object', transaction: 't', identifier: 'b', disposition: 'created', errors: [] }) + }) + + const store = useImportStore() + store.addFile(makeFilePayload({ type: 'application/calendar+xml' })) + store.setCalendarForFile({ fileId: 0, calendarId: 'cal-1' }) + store.setOptionsForFile({ fileId: 0, options: { supersede: true } }) + + const totals = await store.startImport() + + expect(importService.import).toHaveBeenCalledTimes(1) + const [request] = importService.import.mock.calls[0] + expect(request).toEqual({ + target: 'personal', + options: { format: 'xcal', validation: 1, errors: 0, supersede: true }, + data: 'BEGIN:VCALENDAR...', + }) + + expect(totals).toEqual({ + discovered: 2, + processed: 2, + created: 2, + updated: 0, + exists: 0, + error: 0, + }) + expect(store.stage).toBe('completed') + expect(store.running).toBe(false) + expect(store.sessions[0].status).toBe('completed') + expect(store.sessions[0].targetDisplayName).toBe('Personal') + expect(store.sessions[0].targetUri).toBe('personal') + }) + + it('should mark a session as errored when an object fails to import', async () => { + calendarsStoreMock.getCalendarById.mockReturnValue({ + id: 'cal-1', + url: 'http://localhost/remote.php/dav/calendars/admin/personal/', + displayName: 'Personal', + }) + importService.import.mockImplementation(async (request, onData) => { + onData({ type: 'count', transaction: 't', vevent: 2, vtodo: 0, vjournal: 0 }) + onData({ type: 'object', transaction: 't', identifier: 'a', disposition: 'created', errors: [] }) + onData({ type: 'object', transaction: 't', identifier: 'b', disposition: 'error', errors: ['nope'] }) + }) + + const store = useImportStore() + store.addFile(makeFilePayload()) + store.setCalendarForFile({ fileId: 0, calendarId: 'cal-1' }) + + await store.startImport() + + expect(store.sessions[0].status).toBe('error') + expect(store.totals.error).toBe(1) + expect(store.totals.created).toBe(1) + }) + + it('should fail the import when the selected calendar cannot be found', async () => { + calendarsStoreMock.getCalendarById.mockReturnValue(undefined) + + const store = useImportStore() + store.addFile(makeFilePayload()) + store.setCalendarForFile({ fileId: 0, calendarId: 'missing' }) + + await expect(store.startImport()).rejects.toThrow('Selected calendar not found') + expect(store.stage).toBe('error') + expect(store.lastError).toBe('Selected calendar not found') + expect(store.running).toBe(false) + }) +}) diff --git a/tests/javascript/unit/store/importFiles.test.js b/tests/javascript/unit/store/importFiles.test.js deleted file mode 100644 index cb909bf734..0000000000 --- a/tests/javascript/unit/store/importFiles.test.js +++ /dev/null @@ -1,138 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import useImportFilesStore from '../../../../src/store/importFiles.js' -import { setActivePinia, createPinia } from 'pinia' - -describe('store/importFiles test suite', () => { - beforeEach(() => { - setActivePinia(createPinia()) - }) - - it('should provide a default state', () => { - const importFilesStore = useImportFilesStore() - - expect(importFilesStore.$state).toEqual({ - lastFileInsertId: -1, - importFiles: [], - importFilesById: {}, - importCalendarRelation: {}, - }) - }) - - it('should provide a mutation to add a file', () => { - const importFilesStore = useImportFilesStore() - - const state = { - lastFileInsertId: 41, - importFiles: [], - importFilesById: {}, - } - - importFilesStore.$state = state - - const file1 = { - contents: 'BEGIN:VCALENDAR...', - lastModified: 1590151056, - name: 'file-1.ics', - parser: {}, - size: 1337, - type: 'text/calendar', - } - const file2 = { - contents: '{}', - lastModified: 1590151056, - name: 'file-2.ics', - parser: {}, - size: 42, - type: 'application/json+calendar', - } - - importFilesStore.addFile(file1) - importFilesStore.addFile(file2) - - expect(importFilesStore.importFiles).toEqual([ - { - ...file1, - id: 42, - }, { - ...file2, - id: 43, - } - ]) - - expect(importFilesStore.importFilesById[42]).toEqual({ - ...file1, - id: 42, - }) - expect(importFilesStore.importFilesById[43]).toEqual({ - ...file2, - id: 43, - }) - }) - - it('should provide a mutation to set a calendarId for a file', () => { - const importFilesStore = useImportFilesStore() - - importFilesStore.importCalendarRelation = {} - importFilesStore.setCalendarForFileId({ fileId: 0, calendarId: 'CALENDAR-ID-1' }) - importFilesStore.setCalendarForFileId({ fileId: 42, calendarId: 'CALENDAR-ID-1' }) - - expect(importFilesStore.importCalendarRelation).toEqual({ - 0: 'CALENDAR-ID-1', - 42: 'CALENDAR-ID-1', - }) - }) - - it('should provide a mutation to remove all files', () => { - const importFilesStore = useImportFilesStore() - - const file1 = { - id: 0, - contents: 'BEGIN:VCALENDAR...', - lastModified: 1590151056, - name: 'file-1.ics', - parser: {}, - size: 1337, - type: 'text/calendar', - } - const file2 = { - id: 1, - contents: '{}', - lastModified: 1590151056, - name: 'file-2.ics', - parser: {}, - size: 42, - type: 'application/json+calendar', - } - - const state = { - lastFileInsertId: 1, - importFiles: [ - file1, - file2, - ], - importFilesById: { - 0: file1, - 1: file2, - }, - importCalendarRelation: { - 0: 'CALENDAR-ID-1', - 1: 'CALENDAR-ID-1', - }, - } - - importFilesStore.$state = state - - importFilesStore.removeAllFiles() - - expect(importFilesStore.$state).toEqual({ - lastFileInsertId: 1, - importFiles: [], - importFilesById: {}, - importCalendarRelation: {}, - }) - }) - -}) diff --git a/tests/javascript/unit/store/importState.test.js b/tests/javascript/unit/store/importState.test.js deleted file mode 100644 index ef324e0d13..0000000000 --- a/tests/javascript/unit/store/importState.test.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import useImportStateStore from '../../../../src/store/importState.js' -import { setActivePinia, createPinia } from 'pinia' - -import { - IMPORT_STAGE_AWAITING_USER_SELECT, - IMPORT_STAGE_DEFAULT, -} from "../../../../src/models/consts.js"; - -describe('store/importState test suite', () => { - beforeEach(() => { - setActivePinia(createPinia()) - }) - - it('should provide a default state', () => { - const importStateStore = useImportStateStore() - - expect(importStateStore.$state).toEqual({ - total: 0, - accepted: 0, - denied: 0, - stage: IMPORT_STAGE_DEFAULT, - }) - }) - - it('should provide a mutation to reset the state', () => { - const importStateStore = useImportStateStore() - - const state = { - stage: IMPORT_STAGE_AWAITING_USER_SELECT, - total: 1337, - accepted: 42, - denied: 500, - } - - importStateStore.$state = state - - importStateStore.resetState() - - expect(importStateStore.$state).toEqual({ - total: 0, - accepted: 0, - denied: 0, - stage: IMPORT_STAGE_DEFAULT, - }) - }) - -})