Compare commits

...

2 commits

Author SHA1 Message Date
Lars Westermann
07a619f826
Add backup 2019-06-12 20:54:21 +02:00
Lars Westermann
94c35b9067
Add reso check and room list 2019-06-12 19:04:44 +02:00
17 changed files with 269 additions and 99 deletions

View file

@ -9,6 +9,10 @@ reference = "2019-06-12"
offset = 7200000
wall_start = 1
[reso]
day = 3
time = 900
[general]
wiki_url = "https://wiki.kif.rocks/w/index.php?title=KIF470:Arbeitskreise&action=raw"

View file

@ -1,6 +1,7 @@
package de.kif.common
import de.kif.common.model.ConstraintType
import de.kif.common.model.Room
import de.kif.common.model.Schedule
import kotlinx.serialization.Serializable
@ -16,10 +17,15 @@ data class ConstraintMap(
fun checkConstraints(
check: List<Schedule>,
against: List<Schedule>
against: List<Schedule>,
rooms: List<Room>,
resoDay: Int,
resoTime: Int
): ConstraintMap {
val map = mutableMapOf<Long, List<ConstraintError>>()
val roomMap = rooms.associateBy { it.id }
for (schedule in check) {
if (schedule.id == null) continue
val errors = mutableListOf<ConstraintError>()
@ -46,7 +52,7 @@ fun checkConstraints(
schedule.time,
schedule.time + schedule.workGroup.length
)
}.any {it}
}.any { it }
if (blocked) {
errors += ConstraintError("The room ${schedule.room.name} is blocked!")
}
@ -54,10 +60,20 @@ fun checkConstraints(
val start = schedule.getAbsoluteStartTime()
val end = schedule.getAbsoluteEndTime()
if (schedule.workGroup.resolution) {
val resoDeadline = resoDay * 24 * 60 + resoTime
if (end > resoDeadline) {
errors += ConstraintError("The work group is ${end - resoDeadline} minutes after resolution deadline")
}
}
for (leader in schedule.workGroup.leader) {
for (s in against) {
if (
schedule != s &&
schedule.day == s.day &&
leader in s.workGroup.leader &&
start < s.getAbsoluteEndTime() &&
s.getAbsoluteStartTime() < end
@ -70,6 +86,7 @@ fun checkConstraints(
for (s in against) {
if (
schedule != s &&
schedule.day == s.day &&
schedule.room.id == s.room.id &&
start < s.getAbsoluteEndTime() &&
s.getAbsoluteStartTime() < end
@ -146,6 +163,7 @@ fun checkConstraints(
for (s in against) {
if (
s.workGroup.id == constraint.workGroup &&
schedule.day == s.day &&
start <= s.getAbsoluteEndTime() &&
s.getAbsoluteStartTime() <= end
) {
@ -159,14 +177,25 @@ fun checkConstraints(
for (constraint in constraints) {
for (s in against) {
if (
s.workGroup.id == constraint.workGroup &&
s.getAbsoluteEndTime() > start
s.workGroup.id == constraint.workGroup && (
s.day > schedule.day ||
s.day == schedule.day && s.getAbsoluteEndTime() > start
)
) {
errors += ConstraintError("Work group requires after ${s.workGroup.name}!")
}
}
}
}
ConstraintType.Room -> {
val roomBools = constraints.map { it.room == schedule.room.id }
if (roomBools.none { it }) {
val roomList = constraints.mapNotNull { it.room }.distinct().sorted().map {
"${(roomMap[it]?.name ?: "")}($it)"
}
errors += ConstraintError("Work group requires rooms $roomList, but is in room ${schedule.room.name}(${schedule.room.id})")
}
}
}
}

View file

@ -7,43 +7,49 @@ data class WorkGroupConstraint(
val type: ConstraintType,
val day: Int? = null,
val time: Int? = null,
val workGroup: Long? = null
val workGroup: Long? = null,
val room: Long? = null
)
enum class ConstraintType {
/**
* Requires day, permits time and workGroup.
* Requires day, permits time, workGroup and room.
*/
OnlyOnDay,
/**
* Requires day, permits time and workGroup.
* Requires day, permits time, workGroup and room.
*/
NotOnDay,
/**
* Requires time, optionally allows day, permits workGroup.
* Requires time, optionally allows day, permits workGroup and room.
*/
OnlyAfterTime,
/**
* Requires time, optionally allows day, permits workGroup.
* Requires time, optionally allows day, permits workGroup and room.
*/
OnlyBeforeTime,
/**
* Requires time, optionally allows day, permits workGroup
* Requires time, optionally allows day, permits workGroup and room.
*/
ExactTime,
/**
* Requires workGroup, permits day and time.
* Requires workGroup, permits day, time and room.
*/
NotAtSameTime,
/**
* Requires workGroup, permits day and time.
* Requires workGroup, permits day, time and room.
*/
OnlyAfterWorkGroup
OnlyAfterWorkGroup,
/**
* Requires room, permits day, time and workGroup
*/
Room
}

View file

@ -1,6 +1,7 @@
package de.kif.frontend.views
import de.kif.frontend.launch
import de.kif.frontend.repository.RoomRepository
import de.kif.frontend.repository.WorkGroupRepository
import de.westermann.kobserve.event.EventListener
import de.westermann.kwebview.View
@ -10,6 +11,7 @@ import de.westermann.kwebview.createHtmlView
import de.westermann.kwebview.iterator
import org.w3c.dom.*
import kotlin.browser.document
import kotlin.dom.clear
fun initWorkGroupConstraints() {
var index = 10000
@ -149,10 +151,15 @@ fun initWorkGroupConstraints() {
launch {
val all = WorkGroupRepository.all()
val id = (select.options[select.selectedIndex] as? HTMLOptionElement)?.value
for (wg in all) {
val option = createHtmlView<HTMLOptionElement>()
option.value = wg.id.toString()
option.textContent = wg.name
if (option.value == id) {
option.selected = true
}
select.appendChild(option)
}
}
@ -177,10 +184,49 @@ fun initWorkGroupConstraints() {
launch {
val all = WorkGroupRepository.all()
val id = (select.options[select.selectedIndex] as? HTMLOptionElement)?.value
for (wg in all) {
val option = createHtmlView<HTMLOptionElement>()
option.value = wg.id.toString()
option.textContent = wg.name
if (option.value == id) {
option.selected = true
}
select.appendChild(option)
}
}
html.appendChild(select)
}.html)
}
}
addList.textView("In Raum x") {
onClick {
constraints.appendChild(View.wrap(createHtmlView<HTMLDivElement>()) {
classList += "input-group"
html.appendChild(TextView("Raum").apply {
classList += "form-btn"
onClick { this@wrap.html.remove() }
}.html)
val select = createHtmlView<HTMLSelectElement>()
select.classList.add("form-control")
select.name = "constraint-room-${index++}"
val id = (select.options[select.selectedIndex] as? HTMLOptionElement)?.value
launch {
val all = RoomRepository.all()
select.clear()
for (room in all) {
val option = createHtmlView<HTMLOptionElement>()
option.value = room.id.toString()
option.textContent = room.name
if (option.value == id) {
option.selected = true
}
select.appendChild(option)
}
}
@ -195,7 +241,6 @@ fun initWorkGroupConstraints() {
val span = child.firstElementChild as HTMLElement
span.addEventListener("click", org.w3c.dom.events.EventListener {
println("click")
child.remove()
})
}

View file

@ -1,5 +1,7 @@
package de.kif.frontend.views.table
import de.kif.frontend.launch
import de.kif.frontend.repository.TrackRepository
import de.westermann.kwebview.components.InputView
import de.westermann.kwebview.iterator
import org.w3c.dom.HTMLFormElement
@ -14,21 +16,25 @@ fun initTableLayout() {
val table = document.getElementsByClassName("table-layout-table")[0] as HTMLTableElement
val list = table.getElementsByTagName("tr").iterator().asSequence().filter {
it.dataset["search"] != null
}.map {
when (it.dataset["edit"]) {
"workgroup" -> WorkGroupTableLine(it)
"room" -> RoomTableLine(it)
else -> TableLine(it)
}
}.toList()
launch {
val tracks = TrackRepository.all()
val input = form.getElementsByTagName("input")[0] as HTMLInputElement
val search = InputView.wrap(input)
search.valueProperty.onChange {
for (row in list) {
row.search(search.value)
val list = table.getElementsByTagName("tr").iterator().asSequence().filter {
it.dataset["search"] != null
}.map {
when (it.dataset["edit"]) {
"workgroup" -> WorkGroupTableLine(it, tracks)
"room" -> RoomTableLine(it)
else -> TableLine(it)
}
}.toList()
val input = form.getElementsByTagName("input")[0] as HTMLInputElement
val search = InputView.wrap(input)
search.valueProperty.onChange {
for (row in list) {
row.search(search.value)
}
}
}
}

View file

@ -13,7 +13,7 @@ import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLSpanElement
import org.w3c.dom.get
class WorkGroupTableLine(view: HTMLElement) : TableLine(view) {
class WorkGroupTableLine(view: HTMLElement, tracks: List<Track>) : TableLine(view) {
private var lineId = dataset["id"]?.toLongOrNull() ?: -1
@ -24,9 +24,7 @@ class WorkGroupTableLine(view: HTMLElement) : TableLine(view) {
private val spanWorkGroupLength: TextView
private val spanWorkGroupInterested: TextView
private val spanWorkGroupTrack: TextView
private val spanWorkGroupProjector: TextView
private val spanWorkGroupResolution: TextView
private val spanWorkGroupLanguage: TextView
override var searchElement: SearchElement = super.searchElement
@ -41,12 +39,8 @@ class WorkGroupTableLine(view: HTMLElement) : TableLine(view) {
TextView.wrap(spans.first { it.dataset["editType"] == "workgroup-interested" } as HTMLSpanElement)
spanWorkGroupTrack =
TextView.wrap(spans.first { it.dataset["editType"] == "workgroup-track" } as HTMLSpanElement)
spanWorkGroupProjector =
TextView.wrap(spans.first { it.dataset["editType"] == "workgroup-projector" } as HTMLSpanElement)
spanWorkGroupResolution =
TextView.wrap(spans.first { it.dataset["editType"] == "workgroup-resolution" } as HTMLSpanElement)
spanWorkGroupLanguage =
TextView.wrap(spans.first { it.dataset["editType"] == "workgroup-language" } as HTMLSpanElement)
setupEditable(spanWorkGroupName) {
launch {
@ -77,13 +71,6 @@ class WorkGroupTableLine(view: HTMLElement) : TableLine(view) {
}
}
setupBoolean(spanWorkGroupProjector) {
launch {
val wg = workGroup.get()
WorkGroupRepository.update(wg.copy(projector = !wg.projector))
}
}
setupBoolean(spanWorkGroupResolution) {
launch {
val wg = workGroup.get()
@ -91,19 +78,10 @@ class WorkGroupTableLine(view: HTMLElement) : TableLine(view) {
}
}
setupList(spanWorkGroupLanguage, Language.values().sortedBy { it.localeName }, { it.localeName }) {
if (it == null) return@setupList
launch {
val wg = workGroup.get()
if (wg.language == it) return@launch
WorkGroupRepository.update(wg.copy(language = it))
}
}
launch {
val tracks = listOf<Track?>(null) + TrackRepository.all()
val list = listOf<Track?>(null) + tracks
setupList(spanWorkGroupTrack, tracks, { it.name }) {
setupList(spanWorkGroupTrack, list, { it.name }) {
launch x@{
val wg = workGroup.get()
if (wg.track == it) return@x
@ -124,9 +102,7 @@ class WorkGroupTableLine(view: HTMLElement) : TableLine(view) {
spanWorkGroupLength.text = wg.length.toString()
spanWorkGroupInterested.text = wg.interested.toString()
spanWorkGroupTrack.text = wg.track?.name ?: ""
spanWorkGroupProjector.text = wg.projector.toString()
spanWorkGroupResolution.text = wg.resolution.toString()
spanWorkGroupLanguage.text = wg.language.localeName
}
}
}

View file

@ -85,6 +85,7 @@
.board-running {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
&:empty + .board-running-empty {
display: block;

View file

@ -3,6 +3,7 @@ package de.kif.backend
import com.fasterxml.jackson.databind.SerializationFeature
import de.kif.backend.route.*
import de.kif.backend.route.api.*
import de.kif.backend.util.Backup
import de.kif.backend.util.pushService
import io.ktor.application.Application
import io.ktor.application.call
@ -101,4 +102,6 @@ fun Application.main() {
}
logger.info { "Responding at http://${Configuration.Server.host}:${Configuration.Server.port}$prefix/" }
Backup.startBackupService()
}

View file

@ -43,6 +43,7 @@ object Configuration {
val uploads by required<String>()
val database by required<String>()
val announcement by required<String>()
val backup by required<String>()
}
object Path {
@ -60,6 +61,9 @@ object Configuration {
val announcement by c(PathSpec.announcement)
val announcementPath: java.nio.file.Path by lazy { Paths.get(announcement).toAbsolutePath() }
val backup by c(PathSpec.backup)
val backupPath: java.nio.file.Path by lazy { Paths.get(backup).toAbsolutePath() }
}
private object ScheduleSpec : ConfigSpec("schedule") {
@ -93,6 +97,7 @@ object Configuration {
private object GeneralSpec : ConfigSpec("general") {
val allowedUploadExtensions by required<String>("allowed_upload_extensions")
val wikiUrl by required<String>("wiki_url")
val backupInterval by required<Long>("backup_interval")
}
object General {
@ -101,6 +106,7 @@ object Configuration {
allowedUploadExtensions.split(",").map { it.trim().toLowerCase() }.toSet()
}
val wikiUrl by c(GeneralSpec.wikiUrl)
val backupInterval by c(GeneralSpec.backupInterval)
}
private object TwitterSpec : ConfigSpec("twitter") {
@ -111,6 +117,16 @@ object Configuration {
val timeline by c(TwitterSpec.timeline)
}
private object ResoSpec : ConfigSpec("reso") {
val day by required<Int>()
val time by required<Int>()
}
object Reso {
val day by c(ResoSpec.day)
val time by c(ResoSpec.time)
}
init {
var config = Config {
addSpec(ServerSpec)
@ -119,6 +135,7 @@ object Configuration {
addSpec(SecuritySpec)
addSpec(GeneralSpec)
addSpec(TwitterSpec)
addSpec(ResoSpec)
}.from.toml.resource("portal.toml")
for (file in Files.list(Paths.get("."))) {

View file

@ -83,11 +83,19 @@ object Resources {
Files.createDirectories(Configuration.Path.sessionsPath)
Files.createDirectories(Configuration.Path.uploadsPath)
Files.createDirectories(Configuration.Path.webPath)
Files.createDirectories(Configuration.Path.backupPath)
if (!Files.exists(Configuration.Path.announcementPath)) {
Files.createFile(Configuration.Path.announcementPath)
}
logger.info { "Database path: ${Configuration.Path.databasePath}" }
logger.info { "Sessions path: ${Configuration.Path.sessionsPath}" }
logger.info { "Uploads path: ${Configuration.Path.uploadsPath}" }
logger.info { "Web path: ${Configuration.Path.webPath}" }
logger.info { "Backup path: ${Configuration.Path.backupPath}" }
logger.info { "Announcement file: ${Configuration.Path.announcementPath}" }
logger.info { "Extract web content..." }
extractWeb()

View file

@ -60,7 +60,7 @@ fun Route.board() {
val list = ScheduleRepository.getByDay(day)
val rooms = RoomRepository.all()
val schedules = list.groupBy { it.room }.mapValues { (_, it) ->
it.associateBy {
it.groupBy {
it.time
}
}

View file

@ -70,7 +70,7 @@ fun DIV.renderCalendar(
from: Int,
to: Int,
rooms: List<Room>,
schedules: Map<Room, Map<Int, Schedule>>
schedules: Map<Room, Map<Int, List<Schedule>>>
) {
val gridLabelWidth = 60
val minutesOfDay = to - from
@ -144,9 +144,9 @@ fun DIV.renderCalendar(
title = room.name + " - " + timeString
val schedule = (start..end).mapNotNull { schedules[room]?.get(it) }.firstOrNull()
val cellSchedules = (start..end).flatMap { schedules[room]?.get(it) ?: emptyList() }
if (schedule != null) {
for(schedule in cellSchedules) {
calendarEntry(schedule, diff, currentTime)
}
}
@ -231,7 +231,7 @@ fun Route.calendar() {
val list = ScheduleRepository.getByDay(day)
val schedules = list.groupBy { it.room }.mapValues { (_, it) ->
it.associateBy {
it.groupBy {
it.time
}
}

View file

@ -17,7 +17,7 @@ import kotlin.math.min
data class WallData(
val number: Int,
val schedules: Map<Room, Map<Int, Schedule>>,
val schedules: Map<Room, Map<Int, List<Schedule>>>,
val max: Int?,
val min: Int?
)
@ -26,14 +26,13 @@ suspend fun genWallData(day: Int): WallData {
val list = ScheduleRepository.getByDay(day)
val rooms = RoomRepository.all()
if (list.isEmpty()) return WallData(day, rooms.associateWith { emptyMap<Int, Schedule>() }, null, null)
if (list.isEmpty()) return WallData(day, rooms.associateWith { emptyMap<Int, List<Schedule>>() }, null, null)
val schedules =
rooms.associateWith { emptyMap<Int, Schedule>() } + list.groupBy { it.room }.mapValues { (_, it) ->
it.associateBy {
it.time
}
val schedules = list.groupBy { it.room }.mapValues { (_, it) ->
it.groupBy {
it.time
}
}
var max = 0
var min = 24 * 60

View file

@ -2,6 +2,7 @@ package de.kif.backend.route
import de.kif.backend.authenticateOrRedirect
import de.kif.backend.prefix
import de.kif.backend.repository.RoomRepository
import de.kif.backend.repository.TrackRepository
import de.kif.backend.repository.WorkGroupRepository
import de.kif.backend.view.TableTemplate
@ -59,15 +60,9 @@ fun Route.workGroup() {
th {
+"Track"
}
th {
+"Beamer"
}
th {
+"Resolution"
}
th {
+"Sprache"
}
th(classes = "action") {
+"Aktion"
}
@ -111,13 +106,6 @@ fun Route.workGroup() {
+(u.track?.name ?: "")
}
}
td {
span {
attributes["data-edit-type"] = "workgroup-projector"
+u.projector.toString()
}
}
td {
span {
attributes["data-edit-type"] = "workgroup-resolution"
@ -125,13 +113,6 @@ fun Route.workGroup() {
+u.resolution.toString()
}
}
td {
span {
attributes["data-edit-type"] = "workgroup-language"
+u.language.localeName
}
}
td(classes = "action") {
a("$prefix/workgroup/${u.id}") {
i("material-icons") { +"edit" }
@ -157,6 +138,12 @@ fun Route.workGroup() {
WorkGroupRepository.get(it)!!
}
val rooms = editWorkGroup.constraints.mapNotNull {
it.room
}.distinct().associateWith {
RoomRepository.get(it)!!
}
respondMain {
content {
h1 { +"Arbeitskreis bearbeiten" }
@ -541,6 +528,22 @@ fun Route.workGroup() {
}
}
}
ConstraintType.Room -> {
span("form-btn") {
+"Raum"
}
select(
classes = "form-control"
) {
name = "constraint-room-$index"
option {
selected = true
value = constraint.room.toString()
+(rooms[constraint.room!!]?.name ?: "")
}
}
}
}
}
@ -985,6 +988,9 @@ private fun parseConstraintParam(params: Map<String, String?>) = params.map { (k
key.startsWith("constraint-only-after-work-group") -> {
value?.toLongOrNull()?.let { WorkGroupConstraint(ConstraintType.OnlyAfterWorkGroup, workGroup = it) }
}
key.startsWith("constraint-room") -> {
value?.toLongOrNull()?.let { WorkGroupConstraint(ConstraintType.Room, room = it) }
}
else -> null
}
}.groupBy({ it.first }) {

View file

@ -1,6 +1,8 @@
package de.kif.backend.route.api
import de.kif.backend.Configuration
import de.kif.backend.authenticate
import de.kif.backend.repository.RoomRepository
import de.kif.backend.repository.ScheduleRepository
import de.kif.common.checkConstraints
import de.kif.common.model.Permission
@ -14,14 +16,22 @@ fun Route.constraintsApi() {
try {
authenticate(Permission.SCHEDULE) {
val schedules = ScheduleRepository.all()
val rooms = RoomRepository.all()
val errors = checkConstraints(schedules, schedules)
val errors = checkConstraints(
schedules,
schedules,
rooms,
Configuration.Reso.day,
Configuration.Reso.time
)
call.success(errors)
} onFailure {
call.error(HttpStatusCode.Unauthorized)
}
} catch (_: Exception) {
} catch (e: Exception) {
e.printStackTrace()
call.error(HttpStatusCode.InternalServerError)
}
}
@ -31,10 +41,17 @@ fun Route.constraintsApi() {
authenticate(Permission.SCHEDULE) {
val id = call.parameters["id"]?.toLongOrNull()
val schedules = ScheduleRepository.all()
val rooms = RoomRepository.all()
val check = schedules.filter { it.workGroup.id == id }
val errors = checkConstraints(check, schedules)
val errors = checkConstraints(
check,
schedules,
rooms,
Configuration.Reso.day,
Configuration.Reso.time
)
call.success(errors)
} onFailure {

View file

@ -1,11 +1,18 @@
package de.kif.backend.util
import de.kif.backend.Configuration
import de.kif.backend.database.Connection
import de.kif.backend.repository.*
import de.kif.common.RepositoryType
import de.kif.common.Serialization
import de.kif.common.model.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import mu.KotlinLogging
import java.lang.Exception
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.thread
@Serializable
data class Backup(
@ -17,6 +24,8 @@ data class Backup(
val workGroups: List<WorkGroup> = emptyList()
) {
companion object {
private val logger = KotlinLogging.logger {}
suspend fun backup(vararg repositories: RepositoryType): String {
var backup = Backup()
@ -78,7 +87,9 @@ data class Backup(
var workGroup = it
val track = workGroup.track
if (track != null) {
workGroup = workGroup.copy(track = track.copy(id = trackMap.firstOrNull { (i,_) -> i.equalsIgnoreId(track) }?.second ?: run {
workGroup = workGroup.copy(track = track.copy(id = trackMap.firstOrNull { (i, _) ->
i.equalsIgnoreId(track)
}?.second ?: run {
println("Cannot import work group, due to missing track")
return@mapNotNull null
}))
@ -94,14 +105,16 @@ data class Backup(
newSchedules.forEach {
ScheduleRepository.create(
it.copy(
room = it.room.copy(id = roomMap.firstOrNull { (i,_) -> i.equalsIgnoreId(it.room) }?.second ?: run {
println("Cannot import schedule, due to missing room")
return@forEach
}),
workGroup = it.workGroup.copy(id = workGroupMap.firstOrNull { (i,_) -> i.equalsIgnoreId(it.workGroup) }?.second ?: run {
println("Cannot import schedule, due to missing work group")
return@forEach
})
room = it.room.copy(
id = roomMap.firstOrNull { (i, _) -> i.equalsIgnoreId(it.room) }?.second ?: run {
println("Cannot import schedule, due to missing room")
return@forEach
}),
workGroup = it.workGroup.copy(
id = workGroupMap.firstOrNull { (i, _) -> i.equalsIgnoreId(it.workGroup) }?.second ?: run {
println("Cannot import schedule, due to missing work group")
return@forEach
})
)
)
@ -114,5 +127,39 @@ data class Backup(
import(data)
}
fun startBackupService() {
val backupPath = Configuration.Path.backupPath.toFile()
val backupInterval = Configuration.General.backupInterval
val formatter = SimpleDateFormat("yyyyMMdd'T'HHmmss")
thread(
start = true,
isDaemon = true,
name = "backup-service"
) {
while (true) {
try {
Thread.sleep(backupInterval)
if (!backupPath.exists()) {
backupPath.mkdirs()
}
val date = formatter.format(Date())
val backupFile = backupPath.resolve("backup_$date.json")
val backup = runBlocking {
backup(*RepositoryType.values())
}
backupFile.writeText(backup)
} catch (e: Exception) {
logger.error(e) { "Cannot create backup" }
}
}
}
}
}
}

View file

@ -10,12 +10,17 @@ sessions = "data/sessions"
uploads = "data/uploads"
database = "data/portal.db"
announcement = "data/announcement.txt"
backup = "data/backup"
[schedule]
reference = "1970-01-01"
offset = 0
wall_start = 0
[reso]
day = 0
time = 0
[security]
session_name = "SESSION"
sign_key = "d1 20 23 8c 01 f8 f0 0d 9d 7c ff 68 21 97 75 31 38 3f fb 91 20 3a 8d 86 d4 e9 d8 50 f8 71 f1 dc"
@ -23,6 +28,7 @@ sign_key = "d1 20 23 8c 01 f8 f0 0d 9d 7c ff 68 21 97 75 31 38 3f fb 91 20 3a 8d
[general]
allowed_upload_extensions = "png, jpg, jpeg"
wiki_url = ""
backup_interval = 3600000
[twitter]
timeline = ""