feat: import gottesdienste from zelebrationsplan
Some checks failed
Deploy Miniplan / build (push) Failing after 1m0s

This commit is contained in:
Jonas Gerg 2025-04-16 00:40:20 +02:00
parent 67cbb650f0
commit 6a335247ca
8 changed files with 386 additions and 3 deletions

View File

@ -9,6 +9,8 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"rxjs": "^7.8.1" "dropzone-vue3": "^1.0.2",
"rxjs": "^7.8.1",
"v-file-drop": "^0.2.1"
} }
} }

View File

@ -44,6 +44,9 @@ dependencies {
implementation("io.github.cdimascio:dotenv-kotlin:6.4.1") implementation("io.github.cdimascio:dotenv-kotlin:6.4.1")
implementation("at.favre.lib:bcrypt:0.10.2") implementation("at.favre.lib:bcrypt:0.10.2")
implementation("org.apache.poi:poi:5.2.3") // Check for the latest version
implementation("org.apache.poi:poi-ooxml:5.2.3") // Check for the latest version
implementation("org.apache.xmlbeans:xmlbeans:5.0.2") // Check for the latest version
testImplementation("io.ktor:ktor-server-test-host") testImplementation("io.ktor:ktor-server-test-host")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")

View File

@ -10,9 +10,13 @@ fun Application.configureHTTP() {
allowMethod(HttpMethod.Put) allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete) allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch) allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Options) allowMethod(HttpMethod.Options)
allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.AccessControlAllowOrigin) allowHeader(HttpHeaders.AccessControlAllowOrigin)
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.CacheControl)
allowHeader("x-requested-with")
allowNonSimpleContentTypes = true allowNonSimpleContentTypes = true
// allowHeader("MyCustomHeader") // allowHeader("MyCustomHeader")
anyHost() // @TODO: Don't do this in production if possible. Try to limit it. anyHost() // @TODO: Don't do this in production if possible. Try to limit it.

View File

@ -0,0 +1,83 @@
package de.walamana.service
import de.walamana.models.Gottesdienst
import org.apache.poi.ss.usermodel.Row
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.File
import java.io.InputStream
import java.lang.Exception
import java.time.*
import java.time.format.DateTimeFormatter
import java.util.Date
object ZelebrationsplanParser {
private val weekdays = listOf("Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag")
fun parse(ins: InputStream, planId: Int): HashMap<String, ArrayList<Gottesdienst>> {
val workbook = XSSFWorkbook(ins)
val sheet = workbook.getSheetAt(0)
var currentDate: String? = null
val gottesdienste = hashMapOf<String, ArrayList<Gottesdienst>>()
for (row in sheet) {
val r = rowToArray(row)
val weekday = weekdays.firstOrNull { r[0]?.startsWith(it) == true }
if(weekday != null) {
currentDate = r[0]!!
}else if(r[0]?.isBlank() == true) {
println("Empty line")
}else if(r.all { it != null }){
try {
val time = formatTime(currentDate!!, r[1]!!)
val pfarrei = r[0]!!
val attendance = time - Duration.ofMinutes(30)
gottesdienste[pfarrei] = gottesdienste.getOrElse(pfarrei) { arrayListOf() }.apply {
add(Gottesdienst(
id = 0,
name = r[2]!!,
date = time.toDate(),
attendance = attendance.toDate(),
planId = planId
))
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return gottesdienste
}
private fun formatTime(date: String, time: String): LocalDateTime {
try{
return LocalDateTime.parse("$date, $time", DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy, H.mm 'Uhr'"))
} catch (_: Exception) {}
try{
return LocalDateTime.parse("$date, $time", DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy, H:mm 'Uhr'"))
} catch (_: Exception) {}
try{
return LocalDateTime.parse("$date, $time", DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy, 'a. 'H.mm 'Uhr'"))
} catch (_: Exception) {}
return LocalDateTime.parse("$date, $time", DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy, 'a. 'H:mm 'Uhr'"))
}
private fun rowToArray(row: Row): Array<String?> {
return arrayOf(row.getCell(0)?.stringCellValue, row.getCell(1)?.stringCellValue, row.getCell(2)?.stringCellValue, row.getCell(3)?.stringCellValue)
}
private fun LocalDateTime.toDate() = Date.from(this.atZone(ZoneId.systemDefault()).toInstant())
}
fun main() {
File("Zelebrationsplan Mai 2025.xlsx").inputStream().use {
ZelebrationsplanParser.parse(it, 0)
}
}

View File

@ -1,9 +1,10 @@
package de.walamana.views package de.walamana.views
import de.walamana.models.Gottesdienst import de.walamana.models.Gottesdienst
import de.walamana.models.Gottesdienste
import de.walamana.models.GottesdiensteDao import de.walamana.models.GottesdiensteDao
import de.walamana.service.ZelebrationsplanParser
import io.ktor.http.* import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
@ -43,5 +44,17 @@ fun Route.configureGottesdiensteRoutes() {
GottesdiensteDao.deleteGottesdienst(id) GottesdiensteDao.deleteGottesdienst(id)
call.respond(HttpStatusCode.OK) call.respond(HttpStatusCode.OK)
} }
post("/parseZelebrationsplan") {
val id = call.parameters.getOrFail("id").toInt()
val multipartData = call.receiveMultipart()
multipartData.forEachPart { part ->
if(part is PartData.FileItem) {
val data = ZelebrationsplanParser.parse(part.streamProvider(), planId = id)
call.respond(data)
}
part.dispose()
}
}
} }
} }

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const emit = defineEmits(["addPlan", "addGodi", "addMini", "save"]) const emit = defineEmits(["addPlan", "addGodi", "addMini", "importZelebrationsplan", "save"])
const props = defineProps<{ const props = defineProps<{
save: boolean, save: boolean,
plan: boolean, plan: boolean,
@ -12,6 +12,7 @@ const props = defineProps<{
<div class="action-bar" :class="{save: true}"> <div class="action-bar" :class="{save: true}">
<div class="other-action"> <div class="other-action">
<button class="add-plan" :class="{show: props.plan}" @click="$emit('addPlan')"> <i class="icon">add_box</i> Neuer Plan</button> <button class="add-plan" :class="{show: props.plan}" @click="$emit('addPlan')"> <i class="icon">add_box</i> Neuer Plan</button>
<button class="import-zelebrationsplan" :class="{show: props.godi}" @click="$emit('importZelebrationsplan')"> <i class="icon">upload</i> Zelebrationsplan importieren</button>
<button class="add-godi" :class="{show: props.godi}" @click="$emit('addGodi')"> <i class="icon">add</i> Gottesdienst</button> <button class="add-godi" :class="{show: props.godi}" @click="$emit('addGodi')"> <i class="icon">add</i> Gottesdienst</button>
<button class="add-mini" :class="{show: props.godi}" @click="$emit('addMini')"> <i class="icon">add</i> Ministrant</button> <button class="add-mini" :class="{show: props.godi}" @click="$emit('addMini')"> <i class="icon">add</i> Ministrant</button>
</div> </div>

View File

@ -0,0 +1,263 @@
<script setup lang="ts">
import Dialog from "@/components/dialog/Dialog.vue";
import type {DialogControls} from "@/components/dialog/dialog";
import Input from "@/components/Input.vue";
import {onMounted, ref, toRaw} from "vue";
import {onKeyPress} from "@/composables/enter";
import {API} from "@/services/api";
import {Dialogs} from "@/services/DialogService";
import UpdatePasswordDialog from "@/components/dialog/UpdatePasswordDialog.vue";
import {VFileDrop} from "v-file-drop";
import vueDropzone from "dropzone-vue3"
import type {Gottesdienst} from "@/models/models";
interface ImportZelebrationsplanProps extends DialogControls {
onImport(gottesdienste: Gottesdienst[])
planId: number
}
const props = defineProps<ImportZelebrationsplanProps>()
const gottesdienste = ref<(Gottesdienst & {checked: boolean, pfarrei: string})[]>([])
const pfarreien = ref<string[]>([])
const filter = ref("Pfarrei wählen")
const importing = ref(false)
const progress = ref(0)
const dropzoneOptions = {
url: API.getZelebrationsplanParsingUrl(props.planId),
parallelUploads: 1,
acceptedFiles: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
disablePreviews: true,
init: function() {
this.on("complete", file => {
console.log("File", file)
let response = JSON.parse(file.xhr?.responseText)
gottesdienste.value = Object.keys(response).reduce((acc, next) => {
return acc.concat(response[next].map((it: any) => ({
...it,
checked: false,
pfarrei: next
})));
}, []);
pfarreien.value = Object.keys(response)
})
}
}
async function importZelebrationsplan() {
const godis = gottesdienste.value.filter(it => it.checked)
importing.value = true
for(let godi of godis) {
const id = await API.addGottesdienstNew({
...godi,
date: new Date(godi.date),
attendance: new Date(godi.attendance)
})
progress.value += 1
godi.id = id
}
props.onImport(godis)
props.onDismiss()
}
function canImport() {
return gottesdienste.value.filter(it => it.checked).length > 0
}
function amountChecked() {
return gottesdienste.value.filter(it => it.checked).length
}
function selectAll() {
return gottesdienste.value.forEach(it => {
if(it.pfarrei == filter.value) {
it.checked = true
}
})
}
function deselectAll() {
return gottesdienste.value.forEach(it => it.checked = false)
}
function two(s) {
return (s < 10 ? "0" : "") + s
}
function formatDay(time) {
let date = new Date(time)
return two(date.getDate()) + "." + two(date.getMonth() + 1) + "."
}
function formatTime(time) {
let date = new Date(time)
return two(date.getHours()) + ":" + two(date.getMinutes())
}
function formatWeekday(time) {
let date = new Date(time)
switch (date.getDay()) {
case 1:
return "Mo";
case 2:
return "Di";
case 3:
return "Mi";
case 4:
return "Do";
case 5:
return "Fr";
case 6:
return "Sa";
case 0:
return "So"
}
}
function getGottesdienste() {
return gottesdienste.value.filter(it => it["pfarrei"] == filter.value || it.checked)
}
</script>
<template>
<Dialog class="dialog">
<h3>Zelebrationsplan importieren</h3>
<vue-dropzone v-if="gottesdienste.length == 0" id="dropzone" :options="dropzoneOptions"/>
<div class="select" v-if="gottesdienste.length > 0 && !importing">
<span>Gottesdienste ausgewählt: <strong>{{amountChecked()}} / {{getGottesdienste().length}}</strong></span>
<div style="margin: 10px 0; display: flex; align-items: center">
<select v-model="filter">
<option disabled selected>Pfarrei wählen</option>
<option v-for="pfarrei in pfarreien">{{pfarrei}}</option>
</select>
<template v-if="filter">
<button class="flat" @click="selectAll"><i class="icon">select_all</i>Alle auswählen</button>
<button class="flat" @click="deselectAll"><i class="icon">deselect</i>Alle abwählen</button>
</template>
</div>
<div class="list">
<table>
<thead>
<tr>
<td></td>
<td>Titel</td>
<td></td>
<td>Datum</td>
<td>Uhrzeit</td>
<td>Anwesenheit</td>
</tr>
</thead>
<tbody>
<tr v-for="godi in getGottesdienste()" @click.stop="godi.checked = !godi.checked">
<td><input type="checkbox" v-model="godi.checked"/></td>
<td style="width: 100%">{{godi.name}}</td>
<td>{{formatWeekday(godi.date)}}</td>
<td>{{formatDay(godi.date)}}</td>
<td>{{formatTime(godi.date)}}</td>
<td>{{formatTime(godi.attendance)}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="importing" style="display: flex; align-items: center; justify-content: center; padding: 100px; flex-direction: column; gap: 10px">
<p>Gottesdienste werden importiert...</p>
<p style="font-size: 3rem"><strong>{{progress}} / {{amountChecked()}}</strong></p>
</div>
<div class="buttons" style="display: flex; justify-content: end; margin-top: 20px;">
<button @click="onDismiss">Abbrechen</button>
<button v-if="canImport()" @click="importZelebrationsplan">Importieren</button>
</div>
</Dialog>
</template>
<style scoped lang="less">
.dialog {
display: flex;
flex-direction: column;
width: min(90vw, 700px);
h3{
margin-bottom: 30px;
}
.input {
margin-bottom: 16px;
}
}
.upload {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 60px 100px;
border: 1px solid #e1e1e1;
border-radius: 4px;
text-align: center;
cursor: pointer;
user-select: none;
transition: background 200ms;
.icon {
font-size: 50px;
color: #989898;
}
span {
margin-top: 20px;
color: #bdbdbd;
font-size: 14px;
line-height: 20px;
}
&:hover {
background: #f9f9f9;
}
}
.list {
max-height: 400px;
overflow-y: auto;
border: 1px solid #f1f1f1;
border-radius: 4px;
table {
width: 100%;
height: 100%;
border-spacing: 0;
thead {
font-weight: bold;
}
tr td{
padding: 12px 10px;
border-bottom: 1px solid #e1e1e1;
transition: background 200ms;
}
tr:hover td {
cursor: pointer;
background: #f6f6f6;
}
tbody tr:last-child td{
border-bottom: none;
}
}
}
</style>

View File

@ -16,6 +16,7 @@ import CreateMinistrantDialog from "@/components/dialog/CreateMinistrantDialog.v
import {useRoute, useRouter} from "vue-router"; import {useRoute, useRouter} from "vue-router";
import debounce from "underscore/modules/debounce.js" import debounce from "underscore/modules/debounce.js"
import SavingIndicator from "@/components/SavingIndicator.vue"; import SavingIndicator from "@/components/SavingIndicator.vue";
import ImportZelebrationsplanDialog from "@/components/dialog/ImportZelebrationsplanDialog.vue";
const MAX_WIDTH_MOBILE = 600; const MAX_WIDTH_MOBILE = 600;
@ -279,6 +280,18 @@ async function createMinistrant(ministrantId?: number) {
}) })
} }
async function importZelebrationsplan() {
Dialogs.createDialog(ImportZelebrationsplanDialog, {
onPositive() {},
onNegative() {},
onDismiss() {}
}, {
planId: planId.value,
async onImport(godis: Gottesdienst[]) {
plan.gottesdienste.push(...godis)
}
})
}
</script> </script>
@ -329,6 +342,7 @@ async function createMinistrant(ministrantId?: number) {
@save="saveChanges()" @save="saveChanges()"
@add-godi="createGottesdienst()" @add-godi="createGottesdienst()"
@add-mini="createMinistrant()" @add-mini="createMinistrant()"
@import-zelebrationsplan="importZelebrationsplan()"
v-if="editPlanAdmin" v-if="editPlanAdmin"
/> />