feat: import
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useImportStore } from '@ChronoManager/stores/importStore'
|
||||||
|
import { CollectionObject } from '@ChronoManager/models/collection'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean; collections: CollectionObject[] }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; close: [refresh: boolean] }>()
|
||||||
|
const importStore = useImportStore()
|
||||||
|
const { files, sessions, order, stage, running, totals } = storeToRefs(importStore)
|
||||||
|
|
||||||
|
const collectionOptions = computed(() => props.collections.map(collection => ({ title: collection.properties.label || 'Unnamed', value: collection.identifier })))
|
||||||
|
const selecting = computed(() => stage.value === 'selecting')
|
||||||
|
const complete = computed(() => stage.value === 'completed' || stage.value === 'error')
|
||||||
|
const canImport = computed(() => files.value.length > 0 && files.value.every(entry => entry.collectionId !== null))
|
||||||
|
const progress = computed(() => totals.value.discovered ? Math.min(100, Math.round(totals.value.processed / totals.value.discovered * 100)) : 0)
|
||||||
|
const counters = computed(() => [
|
||||||
|
{ key: 'discovered', label: 'Discovered', value: totals.value.discovered, color: 'info' },
|
||||||
|
{ key: 'created', label: 'Created', value: totals.value.created, color: 'success' },
|
||||||
|
{ key: 'updated', label: 'Updated', value: totals.value.updated, color: 'info' },
|
||||||
|
{ key: 'exists', label: 'Skipped', value: totals.value.exists, color: 'warning' },
|
||||||
|
{ key: 'error', label: 'Errors', value: totals.value.error, color: 'error' },
|
||||||
|
])
|
||||||
|
|
||||||
|
async function startImport() { try { await importStore.startImport() } catch (error) { console.error('[Chrono] - Import failed:', error) } }
|
||||||
|
function cancel() { emit('update:modelValue', false); emit('close', false) }
|
||||||
|
function done() { emit('update:modelValue', false); emit('close', totals.value.created > 0 || totals.value.updated > 0) }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog :model-value="modelValue" max-width="640" persistent scrollable>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex align-center"><v-icon icon="mdi-calendar-import" class="mr-2" />Import iCalendar Data</v-card-title>
|
||||||
|
<v-divider />
|
||||||
|
<v-card-text v-if="selecting" class="pt-4">
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">Choose a destination collection for each iCalendar file. Events, tasks, and journals are supported.</p>
|
||||||
|
<v-card v-for="entry in files" :key="entry.file.id" variant="tonal" class="mb-3 pa-3">
|
||||||
|
<div class="d-flex align-center mb-2"><v-icon icon="mdi-calendar-blank-outline" size="small" class="mr-2" /><span class="text-subtitle-2 text-truncate">{{ entry.file.name }}</span></div>
|
||||||
|
<v-select
|
||||||
|
:model-value="entry.collectionId" :items="collectionOptions" label="Calendar or task list"
|
||||||
|
density="compact" variant="outlined" hide-details
|
||||||
|
@update:model-value="importStore.setCollectionForFile(entry.file.id, $event)"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-text v-else class="pt-4">
|
||||||
|
<div class="d-flex justify-space-between mb-1"><span>{{ complete ? 'Import complete' : 'Importing…' }}</span><span class="text-medium-emphasis">{{ totals.processed }} / {{ totals.discovered }}</span></div>
|
||||||
|
<v-progress-linear :model-value="progress" :indeterminate="running && totals.discovered === 0" color="primary" height="8" rounded class="mb-4" />
|
||||||
|
<div class="d-flex flex-wrap ga-2"><v-chip v-for="counter in counters" :key="counter.key" :color="counter.color" size="small" variant="tonal">{{ counter.label }}: {{ counter.value }}</v-chip></div>
|
||||||
|
<v-list v-if="order.length > 1" density="compact" class="mt-3 bg-transparent">
|
||||||
|
<v-list-item v-for="id in order" :key="id" :title="sessions[id]?.fileName" :subtitle="sessions[id]?.lastError ?? undefined">
|
||||||
|
<template #append><span class="text-caption">{{ sessions[id]?.counters.processed }} / {{ sessions[id]?.counters.discovered }}</span></template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
<v-divider />
|
||||||
|
<v-card-actions><v-spacer />
|
||||||
|
<template v-if="selecting"><v-btn variant="text" @click="cancel">Cancel</v-btn><v-btn color="primary" variant="flat" :disabled="!canImport" @click="startImport">Import</v-btn></template>
|
||||||
|
<v-btn v-else color="primary" variant="flat" :disabled="running" @click="done">Close</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useDisplay } from 'vuetify';
|
import { useDisplay } from 'vuetify';
|
||||||
@@ -13,6 +13,7 @@ import TaskView from '@/components/TaskView.vue';
|
|||||||
import EventEditor from '@/components/EventEditor.vue';
|
import EventEditor from '@/components/EventEditor.vue';
|
||||||
import TaskEditor from '@/components/TaskEditor.vue';
|
import TaskEditor from '@/components/TaskEditor.vue';
|
||||||
import MiniCalendar from '@/components/MiniCalendar.vue';
|
import MiniCalendar from '@/components/MiniCalendar.vue';
|
||||||
|
import ImportDialog from '@/components/ImportDialog.vue';
|
||||||
|
|
||||||
// Vuetify display
|
// Vuetify display
|
||||||
const display = useDisplay();
|
const display = useDisplay();
|
||||||
@@ -36,6 +37,7 @@ const {
|
|||||||
showEventEditor,
|
showEventEditor,
|
||||||
showTaskEditor,
|
showTaskEditor,
|
||||||
showCollectionEditor,
|
showCollectionEditor,
|
||||||
|
showImportDialog,
|
||||||
selectedEntity,
|
selectedEntity,
|
||||||
entityEditorMode,
|
entityEditorMode,
|
||||||
editingCollection,
|
editingCollection,
|
||||||
@@ -76,8 +78,19 @@ const {
|
|||||||
deleteCollection,
|
deleteCollection,
|
||||||
openCreateCalendar,
|
openCreateCalendar,
|
||||||
openCreateTaskList,
|
openCreateTaskList,
|
||||||
|
openImport,
|
||||||
|
closeImport,
|
||||||
} = chronoStore;
|
} = chronoStore;
|
||||||
|
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
function triggerFileInput() { fileInputRef.value?.click() }
|
||||||
|
async function onFilesSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const files = Array.from(input.files ?? [])
|
||||||
|
input.value = ''
|
||||||
|
if (files.length) await openImport(files)
|
||||||
|
}
|
||||||
|
|
||||||
function syncViewModeFromRoute() {
|
function syncViewModeFromRoute() {
|
||||||
const routePath = route.path;
|
const routePath = route.path;
|
||||||
const mode = routePath.endsWith('/tasks') ? 'tasks' : 'calendar';
|
const mode = routePath.endsWith('/tasks') ? 'tasks' : 'calendar';
|
||||||
@@ -178,6 +191,7 @@ watch(() => route.fullPath, () => {
|
|||||||
<v-icon start>mdi-plus</v-icon>
|
<v-icon start>mdi-plus</v-icon>
|
||||||
New Calendar
|
New Calendar
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
v-else
|
v-else
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
@@ -190,6 +204,12 @@ watch(() => route.fullPath, () => {
|
|||||||
New Task List
|
New Task List
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<v-btn variant="text" prepend-icon="mdi-calendar-import" block class="justify-start" @click="triggerFileInput">
|
||||||
|
Import iCalendar
|
||||||
|
</v-btn>
|
||||||
|
<input ref="fileInputRef" type="file" accept=".ics,text/calendar" multiple class="d-none" @change="onFilesSelected" />
|
||||||
|
|
||||||
<!-- Mini Calendar Widget -->
|
<!-- Mini Calendar Widget -->
|
||||||
<v-card v-if="!isTaskView" class="mt-4" variant="outlined">
|
<v-card v-if="!isTaskView" class="mt-4" variant="outlined">
|
||||||
<v-card-text class="pa-2">
|
<v-card-text class="pa-2">
|
||||||
@@ -293,6 +313,8 @@ watch(() => route.fullPath, () => {
|
|||||||
@save="saveCollection"
|
@save="saveCollection"
|
||||||
@delete="deleteCollection"
|
@delete="deleteCollection"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ImportDialog v-model="showImportDialog" :collections="collections" @close="closeImport" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ServiceObject } from '@ChronoManager/models/service'
|
|||||||
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
|
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
|
||||||
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
|
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
|
||||||
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
||||||
|
import { useImportStore } from '@ChronoManager/stores/importStore'
|
||||||
import type { CalendarView as CalendarViewType } from '@/types'
|
import type { CalendarView as CalendarViewType } from '@/types'
|
||||||
import {
|
import {
|
||||||
AGENDA_VIEW_SPANS,
|
AGENDA_VIEW_SPANS,
|
||||||
@@ -24,6 +25,7 @@ export const useChronoStore = defineStore('chronoStore', () => {
|
|||||||
const servicesStore = useServicesStore()
|
const servicesStore = useServicesStore()
|
||||||
const collectionsStore = useCollectionsStore()
|
const collectionsStore = useCollectionsStore()
|
||||||
const entitiesStore = useEntitiesStore()
|
const entitiesStore = useEntitiesStore()
|
||||||
|
const importStore = useImportStore()
|
||||||
|
|
||||||
const savedCalendarView = userStore.getSetting('chrono.calendarView')
|
const savedCalendarView = userStore.getSetting('chrono.calendarView')
|
||||||
const savedDaysViewSpan = userStore.getSetting('chrono.daysViewSpan')
|
const savedDaysViewSpan = userStore.getSetting('chrono.daysViewSpan')
|
||||||
@@ -55,6 +57,7 @@ export const useChronoStore = defineStore('chronoStore', () => {
|
|||||||
const showEventEditor = ref(false)
|
const showEventEditor = ref(false)
|
||||||
const showTaskEditor = ref(false)
|
const showTaskEditor = ref(false)
|
||||||
const showCollectionEditor = ref(false)
|
const showCollectionEditor = ref(false)
|
||||||
|
const showImportDialog = ref(false)
|
||||||
|
|
||||||
const entityEditorMode = ref<'edit' | 'view'>('view')
|
const entityEditorMode = ref<'edit' | 'view'>('view')
|
||||||
const editingCollection = shallowRef<CollectionObject | null>(null)
|
const editingCollection = shallowRef<CollectionObject | null>(null)
|
||||||
@@ -357,6 +360,33 @@ export const useChronoStore = defineStore('chronoStore', () => {
|
|||||||
showCollectionEditor.value = false
|
showCollectionEditor.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openImport(files: File[]) {
|
||||||
|
importStore.removeAllFiles()
|
||||||
|
importStore.reset()
|
||||||
|
for (const file of files) {
|
||||||
|
const id = importStore.addFile({ name: file.name, contents: await file.text(), size: file.size, type: file.type })
|
||||||
|
if (selectedCollection.value && collections.value.some(collection => collection.identifier === selectedCollection.value?.identifier)) {
|
||||||
|
importStore.setCollectionForFile(id, selectedCollection.value.identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
importStore.stage = 'selecting'
|
||||||
|
showImportDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeImport(refresh = false) {
|
||||||
|
showImportDialog.value = false
|
||||||
|
if (refresh) {
|
||||||
|
const targets = new Set<CollectionObject['identifier']>()
|
||||||
|
for (const id of importStore.order) {
|
||||||
|
const target = importStore.sessions[id]?.targetIdentifier
|
||||||
|
if (target) targets.add(target)
|
||||||
|
}
|
||||||
|
for (const target of targets) await entitiesStore.list([target])
|
||||||
|
}
|
||||||
|
importStore.removeAllFiles()
|
||||||
|
importStore.reset()
|
||||||
|
}
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -391,6 +421,7 @@ export const useChronoStore = defineStore('chronoStore', () => {
|
|||||||
showEventEditor,
|
showEventEditor,
|
||||||
showTaskEditor,
|
showTaskEditor,
|
||||||
showCollectionEditor,
|
showCollectionEditor,
|
||||||
|
showImportDialog,
|
||||||
entityEditorMode,
|
entityEditorMode,
|
||||||
editingCollection,
|
editingCollection,
|
||||||
collectionEditorMode,
|
collectionEditorMode,
|
||||||
@@ -433,6 +464,8 @@ export const useChronoStore = defineStore('chronoStore', () => {
|
|||||||
deleteTask,
|
deleteTask,
|
||||||
saveCollection,
|
saveCollection,
|
||||||
deleteCollection,
|
deleteCollection,
|
||||||
|
openImport,
|
||||||
|
closeImport,
|
||||||
initialize,
|
initialize,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user