Compare commits
No commits in common. "190017d20edb79cc331350d5d54008bde420a7d7" and "30a50b775fe10624f41fb1462236b0332ab32108" have entirely different histories.
190017d20e
...
30a50b775f
118 changed files with 6568 additions and 324 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ui/node_modules
|
||||
ui/build
|
||||
22
.drone-manifest.yml
Normal file
22
.drone-manifest.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
image: registry.nemunai.re/reveil:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
|
||||
{{#if build.tags}}
|
||||
tags:
|
||||
{{#each build.tags}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
manifests:
|
||||
- image: registry.nemunai.re/reveil:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
|
||||
platform:
|
||||
architecture: amd64
|
||||
os: linux
|
||||
- image: registry.nemunai.re/reveil:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
|
||||
platform:
|
||||
architecture: arm64
|
||||
os: linux
|
||||
variant: v8
|
||||
- image: registry.nemunai.re/reveil:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
|
||||
platform:
|
||||
architecture: arm
|
||||
os: linux
|
||||
variant: v7
|
||||
92
.drone.yml
Normal file
92
.drone.yml
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-arm
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm
|
||||
|
||||
workspace:
|
||||
base: /go
|
||||
path: src/git.nemunai.re/nemunaire/reveil
|
||||
|
||||
steps:
|
||||
- name: build front
|
||||
image: node:18-alpine
|
||||
commands:
|
||||
- mkdir deploy
|
||||
- cd ui
|
||||
- npm install --network-timeout=100000
|
||||
- sed -i 's!@popperjs/core/dist/esm/popper!@popperjs/core!' node_modules/sveltestrap/src/*.js node_modules/sveltestrap/src/*.svelte
|
||||
- npm run build
|
||||
- tar chjf ../deploy/static.tar.bz2 build
|
||||
|
||||
- name: build
|
||||
image: golang:1-alpine
|
||||
commands:
|
||||
- apk --no-cache add alsa-lib-dev build-base git pkgconf
|
||||
- go get -v -d
|
||||
- go vet -v
|
||||
- go build -v -ldflags '-w -X main.Version="${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/reveil-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
- ln deploy/reveil-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} reveil
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- tag
|
||||
|
||||
- name: build tag
|
||||
image: golang:1-alpine
|
||||
commands:
|
||||
- apk --no-cache add alsa-lib-dev build-base git pkgconf
|
||||
- go get -v -d
|
||||
- go vet -v
|
||||
- go build -v -ldflags '-w -X main.Version="${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/reveil-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
- ln deploy/reveil-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} reveil
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
- name: docker
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: registry.nemunai.re
|
||||
repo: registry.nemunai.re/reveil
|
||||
auto_tag: true
|
||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
dockerfile: Dockerfile-norebuild
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- cron
|
||||
- push
|
||||
- tag
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: docker-manifest
|
||||
|
||||
steps:
|
||||
- name: publish on Docker Hub
|
||||
image: plugins/manifest
|
||||
settings:
|
||||
auto_tag: true
|
||||
ignore_missing: true
|
||||
spec: .drone-manifest.yml
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- cron
|
||||
- push
|
||||
- tag
|
||||
|
||||
depends_on:
|
||||
- build-arm
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -1 +1,10 @@
|
|||
reveil
|
||||
/actions
|
||||
/alarms.db
|
||||
/gongs
|
||||
/reveil
|
||||
/routines
|
||||
/settings.json
|
||||
/tracks/
|
||||
/vendor
|
||||
/ui/build
|
||||
/ui/node_modules
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
FROM node:18-alpine as nodebuild
|
||||
|
||||
WORKDIR /ui
|
||||
|
||||
COPY ui/ .
|
||||
|
||||
RUN npm install --network-timeout=100000 && \
|
||||
sed -i 's!@popperjs/core/dist/esm/popper!@popperjs/core!' node_modules/sveltestrap/src/*.js node_modules/sveltestrap/src/*.svelte && \
|
||||
npm run build
|
||||
|
||||
|
||||
FROM golang:1-alpine AS build
|
||||
|
||||
RUN apk --no-cache add git go-bindata
|
||||
|
||||
COPY . /go/src/git.nemunai.re/nemunaire/reveil
|
||||
COPY --from=nodebuild /ui/build /go/src/git.nemunai.re/nemunaire/reveil/ui/build
|
||||
WORKDIR /go/src/git.nemunai.re/nemunaire/reveil
|
||||
RUN go get -v && go generate -v && go build -v -ldflags="-s -w"
|
||||
|
||||
|
||||
FROM alpine:3.16
|
||||
|
||||
VOLUME /data
|
||||
WORKDIR /data
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["/srv/reveil"]
|
||||
|
||||
COPY --from=build /go/src/git.nemunai.re/nemunaire/reveil/reveil /srv/reveil
|
||||
9
Dockerfile-norebuild
Normal file
9
Dockerfile-norebuild
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
FROM alpine:3.16
|
||||
|
||||
VOLUME /data
|
||||
WORKDIR /data
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["/srv/reveil"]
|
||||
|
||||
COPY reveil /srv/reveil
|
||||
93
api/actions.go
Normal file
93
api/actions.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
"git.nemunai.re/nemunaire/reveil/model"
|
||||
)
|
||||
|
||||
func declareActionsRoutes(cfg *config.Config, router *gin.RouterGroup) {
|
||||
router.GET("/actions", func(c *gin.Context) {
|
||||
actions, err := reveil.LoadActions(cfg)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, actions)
|
||||
})
|
||||
router.POST("/actions", func(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusNotImplemented, gin.H{"errmsg": "TODO"})
|
||||
})
|
||||
|
||||
actionsRoutes := router.Group("/actions/:tid")
|
||||
actionsRoutes.Use(func(c *gin.Context) {
|
||||
actions, err := reveil.LoadActions(cfg)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, t := range actions {
|
||||
if t.Id.ToString() == c.Param("tid") {
|
||||
c.Set("action", t)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Action not found"})
|
||||
})
|
||||
|
||||
actionsRoutes.GET("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, c.MustGet("action"))
|
||||
})
|
||||
actionsRoutes.PUT("", func(c *gin.Context) {
|
||||
oldaction := c.MustGet("action").(*reveil.Action)
|
||||
|
||||
var action reveil.Action
|
||||
if err := c.ShouldBindJSON(&action); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if action.Name != oldaction.Name {
|
||||
err := oldaction.Rename(action.Name)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to rename the action: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if action.Enabled != oldaction.Enabled {
|
||||
var err error
|
||||
if action.Enabled {
|
||||
err = oldaction.Enable()
|
||||
} else {
|
||||
err = oldaction.Disable()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to enable/disable the action: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, oldaction)
|
||||
})
|
||||
actionsRoutes.DELETE("", func(c *gin.Context) {
|
||||
action := c.MustGet("action").(*reveil.Action)
|
||||
|
||||
err := action.Remove()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to remove the action: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, nil)
|
||||
})
|
||||
}
|
||||
47
api/alarm.go
Normal file
47
api/alarm.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
"git.nemunai.re/nemunaire/reveil/player"
|
||||
)
|
||||
|
||||
func declareAlarmRoutes(cfg *config.Config, router *gin.RouterGroup) {
|
||||
router.GET("/alarm", func(c *gin.Context) {
|
||||
if player.CommonPlayer == nil {
|
||||
c.JSON(http.StatusOK, false)
|
||||
} else {
|
||||
c.JSON(http.StatusOK, true)
|
||||
}
|
||||
})
|
||||
|
||||
router.POST("/alarm/run", func(c *gin.Context) {
|
||||
if player.CommonPlayer == nil {
|
||||
err := player.WakeUp(cfg)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Player already running"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, true)
|
||||
})
|
||||
|
||||
router.DELETE("/alarm", func(c *gin.Context) {
|
||||
if player.CommonPlayer != nil {
|
||||
err := player.CommonPlayer.Stop()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
293
api/alarms.go
Normal file
293
api/alarms.go
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
"git.nemunai.re/nemunaire/reveil/model"
|
||||
)
|
||||
|
||||
func declareAlarmsRoutes(cfg *config.Config, db *reveil.LevelDBStorage, resetTimer func(), router *gin.RouterGroup) {
|
||||
router.GET("/alarms/next", func(c *gin.Context) {
|
||||
alarm, err := reveil.GetNextAlarm(db)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
})
|
||||
|
||||
router.GET("/alarms/single", func(c *gin.Context) {
|
||||
alarms, err := reveil.GetAlarmsSingle(db)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, alarms)
|
||||
})
|
||||
router.POST("/alarms/single", func(c *gin.Context) {
|
||||
var alarm reveil.AlarmSingle
|
||||
if err := c.ShouldBindJSON(&alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(alarm.Time) {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "This date is already passed."})
|
||||
return
|
||||
}
|
||||
|
||||
alarm.Id = nil
|
||||
if err := reveil.PutAlarmSingle(db, &alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
})
|
||||
|
||||
router.GET("/alarms/repeated", func(c *gin.Context) {
|
||||
alarms, err := reveil.GetAlarmsRepeated(db)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, alarms)
|
||||
})
|
||||
router.POST("/alarms/repeated", func(c *gin.Context) {
|
||||
var alarm reveil.AlarmRepeated
|
||||
if err := c.ShouldBindJSON(&alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
alarm.Id = nil
|
||||
if err := reveil.PutAlarmRepeated(db, &alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
})
|
||||
|
||||
router.GET("/alarms/exceptions", func(c *gin.Context) {
|
||||
exceptions, err := reveil.GetAlarmExceptions(db)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, exceptions)
|
||||
})
|
||||
router.POST("/alarms/exceptions", func(c *gin.Context) {
|
||||
var alarm reveil.AlarmException
|
||||
if err := c.ShouldBindJSON(&alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if alarm.Start == nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Start not defined"})
|
||||
return
|
||||
}
|
||||
if alarm.End == nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "End not defined"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(time.Time(*alarm.End)) {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "End date is already passed."})
|
||||
return
|
||||
}
|
||||
if time.Time(*alarm.Start).After(time.Time(*alarm.End)) {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Start is defined after End. Please verify your inputs."})
|
||||
return
|
||||
}
|
||||
|
||||
alarm.Id = nil
|
||||
if err := reveil.PutAlarmException(db, &alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
})
|
||||
|
||||
singleAlarmsRoutes := router.Group("/alarms/single/:aid")
|
||||
singleAlarmsRoutes.Use(func(c *gin.Context) {
|
||||
id, err := reveil.NewIdentifierFromString(c.Param("aid"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Invalid alarm idenfifier: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
alarm, err := reveil.GetAlarmSingle(db, id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("alarm", alarm)
|
||||
|
||||
c.Next()
|
||||
})
|
||||
|
||||
singleAlarmsRoutes.GET("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, c.MustGet("alarm"))
|
||||
})
|
||||
singleAlarmsRoutes.PUT("", func(c *gin.Context) {
|
||||
oldalarm := c.MustGet("alarm").(*reveil.AlarmSingle)
|
||||
|
||||
var alarm reveil.AlarmSingle
|
||||
if err := c.ShouldBindJSON(&alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
alarm.Id = oldalarm.Id
|
||||
if err := reveil.PutAlarmSingle(db, &alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
})
|
||||
singleAlarmsRoutes.DELETE("", func(c *gin.Context) {
|
||||
alarm := c.MustGet("alarm").(*reveil.AlarmSingle)
|
||||
|
||||
if err := reveil.DeleteAlarmSingle(db, alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
|
||||
c.JSON(http.StatusOK, nil)
|
||||
})
|
||||
|
||||
repeatedAlarmsRoutes := router.Group("/alarms/repeated/:aid")
|
||||
repeatedAlarmsRoutes.Use(func(c *gin.Context) {
|
||||
id, err := reveil.NewIdentifierFromString(c.Param("aid"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Invalid alarm idenfifier: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
alarm, err := reveil.GetAlarmRepeated(db, id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("alarm", alarm)
|
||||
|
||||
c.Next()
|
||||
})
|
||||
|
||||
repeatedAlarmsRoutes.GET("", func(c *gin.Context) {
|
||||
alarm := c.MustGet("alarm").(*reveil.AlarmRepeated)
|
||||
alarm.FillExcepts(db)
|
||||
alarm.NextTime = alarm.GetNextOccurence(db)
|
||||
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
})
|
||||
repeatedAlarmsRoutes.PUT("", func(c *gin.Context) {
|
||||
oldalarm := c.MustGet("alarm").(*reveil.AlarmRepeated)
|
||||
|
||||
var alarm reveil.AlarmRepeated
|
||||
if err := c.ShouldBindJSON(&alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
alarm.Id = oldalarm.Id
|
||||
if err := reveil.PutAlarmRepeated(db, &alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
})
|
||||
repeatedAlarmsRoutes.DELETE("", func(c *gin.Context) {
|
||||
alarm := c.MustGet("alarm").(*reveil.AlarmRepeated)
|
||||
|
||||
if err := reveil.DeleteAlarmRepeated(db, alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
|
||||
c.JSON(http.StatusOK, nil)
|
||||
})
|
||||
|
||||
exceptionAlarmsRoutes := router.Group("/alarms/exceptions/:aid")
|
||||
exceptionAlarmsRoutes.Use(func(c *gin.Context) {
|
||||
id, err := reveil.NewIdentifierFromString(c.Param("aid"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Invalid alarm idenfifier: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
alarm, err := reveil.GetAlarmException(db, id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("alarm", alarm)
|
||||
|
||||
c.Next()
|
||||
})
|
||||
|
||||
exceptionAlarmsRoutes.GET("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, c.MustGet("alarm"))
|
||||
})
|
||||
exceptionAlarmsRoutes.PUT("", func(c *gin.Context) {
|
||||
oldalarm := c.MustGet("alarm").(*reveil.AlarmException)
|
||||
|
||||
var alarm reveil.AlarmException
|
||||
if err := c.ShouldBindJSON(&alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
alarm.Id = oldalarm.Id
|
||||
if err := reveil.PutAlarmException(db, &alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
})
|
||||
exceptionAlarmsRoutes.DELETE("", func(c *gin.Context) {
|
||||
alarm := c.MustGet("alarm").(*reveil.AlarmException)
|
||||
|
||||
if err := reveil.DeleteAlarmException(db, alarm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
|
||||
c.JSON(http.StatusOK, nil)
|
||||
})
|
||||
}
|
||||
148
api/gongs.go
Normal file
148
api/gongs.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
"git.nemunai.re/nemunaire/reveil/model"
|
||||
)
|
||||
|
||||
func declareGongsRoutes(cfg *config.Config, router *gin.RouterGroup) {
|
||||
router.GET("/gongs", func(c *gin.Context) {
|
||||
gongs, err := reveil.LoadGongs(cfg)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gongs)
|
||||
})
|
||||
router.POST("/gongs", func(c *gin.Context) {
|
||||
fgong, err := c.FormFile("gongfile")
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No gong found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if path.Ext(fgong.Filename) != ".mp3" && path.Ext(fgong.Filename) != ".flac" && path.Ext(fgong.Filename) != ".wav" {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad file type. You should only upload .mp3, .wav or .flac files."})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(fgong.Filename, "/") {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad file name."})
|
||||
return
|
||||
}
|
||||
|
||||
dst := path.Join(cfg.GongsDir, fgong.Filename)
|
||||
err = c.SaveUploadedFile(fgong, dst)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Something goes wrong when saving the gong: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
d, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Something goes wrong when saving the gong: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
gong, err := reveil.LoadGong(cfg, dst, d)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to load gong: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gong)
|
||||
})
|
||||
|
||||
gongsRoutes := router.Group("/gongs/:tid")
|
||||
gongsRoutes.Use(func(c *gin.Context) {
|
||||
gongs, err := reveil.LoadGongs(cfg)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, g := range gongs {
|
||||
if g.Id.ToString() == c.Param("tid") {
|
||||
c.Set("gong", g)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Gong not found"})
|
||||
})
|
||||
|
||||
gongsRoutes.GET("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, c.MustGet("gong"))
|
||||
})
|
||||
gongsRoutes.GET("/stream", func(c *gin.Context) {
|
||||
gong := c.MustGet("gong").(*reveil.Gong)
|
||||
|
||||
size, err := gong.Size()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to open the gong: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
fd, err := gong.Open()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to open the gong: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
c.DataFromReader(http.StatusOK, size, gong.ContentType(), fd, map[string]string{})
|
||||
})
|
||||
gongsRoutes.PUT("", func(c *gin.Context) {
|
||||
oldgong := c.MustGet("gong").(*reveil.Gong)
|
||||
|
||||
var gong reveil.Gong
|
||||
if err := c.ShouldBindJSON(&gong); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if gong.Name != oldgong.Name {
|
||||
err := oldgong.Rename(gong.Name)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to rename the gong: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if gong.Enabled != oldgong.Enabled {
|
||||
var err error
|
||||
if gong.Enabled {
|
||||
err = oldgong.SetDefault(cfg)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to set the new default gong: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, oldgong)
|
||||
})
|
||||
gongsRoutes.DELETE("", func(c *gin.Context) {
|
||||
gong := c.MustGet("gong").(*reveil.Gong)
|
||||
|
||||
err := gong.Remove()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to remove the gong: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, nil)
|
||||
})
|
||||
}
|
||||
13
api/history.go
Normal file
13
api/history.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
)
|
||||
|
||||
func declareHistoryRoutes(cfg *config.Config, router *gin.RouterGroup) {
|
||||
router.GET("/stats", func(c *gin.Context) {
|
||||
|
||||
})
|
||||
}
|
||||
133
api/quotes.go
Normal file
133
api/quotes.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
)
|
||||
|
||||
var quotes = []Quote{
|
||||
Quote{1, "La qualité d’un homme se calcule à sa démesure ; tentez, essayer, échouez même, ce sera votre réussite.", "Jacques brel"},
|
||||
Quote{2, "Certains veulent que ça arrive, d’autres aimeraient que ça arrive, et les autres font que ça arrive.", "Michael Jordan"},
|
||||
Quote{3, "Les winners sont des loosers qui se lèvent et essaient une fois de plus.", "Dennis DeYoung"},
|
||||
Quote{4, "L’homme le plus heureux est celui qui fait le bonheur d’un plus grand nombre d’autres.", "Denis Diderot"},
|
||||
Quote{5, "La joie est en tout ; il faut savoir l’extraire.", "Confucius"},
|
||||
Quote{6, "L’optimiste ne refuse jamais de voir le côté négatif des choses ; il refuse simplement de s’attarder dessus.", "Alexandre Lockhart"},
|
||||
Quote{7, "L’obstination est le chemin de la réussite.", "Charlie chaplin"},
|
||||
Quote{8, "Si on veut obtenir quelque chose que l’on n’a jamais eu, il faut tenter quelque chose que l’on n’a jamais fait.", "Péricles"},
|
||||
Quote{9, "La plus grande erreur que puisse faire un homme est d’avoir peur d’en faire une.", "Elbert Hubbard"},
|
||||
Quote{10, "La définition de la folie, c’est de refaire toujours la même chose, et d’attendre un résultat différent.", "Albert Einstein"},
|
||||
Quote{11, "Peu importe qui tu es ou qui tu as été, tu peux être qui tu veux.", "Clement Stone"},
|
||||
Quote{12, "Celui qui attend que tout danger soir écarté pour mettre les voiles ne prendra jamais la mer.", "Thomas Fuller"},
|
||||
Quote{13, "Beaucoup de ceux qui ont échoué n’ont pas réalisé qu’ils étaient aussi près du succès quand ils ont abandonné.", "Thomas Edison"},
|
||||
Quote{14, "La vie, c’est comme une bicyclette, il faut avancer pour ne pas perdre l’équilibre.", "Albert Einstein"},
|
||||
Quote{15, "Vous ne pouvez choisir ni comment mourir, ni quand. Mais vous pouvez décider de comment vous allez vivre. Maintenant.", "Joan Baez"},
|
||||
Quote{16, "Je passe mon temps à faire ce que je ne sais pas faire, pour apprendre à le faire.", "Pablo Picasso"},
|
||||
Quote{17, "Le dictionnaire, c’est le seul endroit où Succès arrive avant Travail.", "Vince Lombardi"},
|
||||
Quote{18, "Si on me donnait six heures pour abattre un arbre, je passerais la première à affûter la hache.", "Abraham Lincoln"},
|
||||
Quote{19, "A la fin, ce qui compte, ce ne sont pas les années qu’il y a eu dans la vie. C’est la vie qu’il y a eu dans les années.", "Abraham Lincoln"},
|
||||
Quote{20, "La logique peut vous mener d’un point A à un point B. L’imagination peut vous mener partout.", "Albert Einstein"},
|
||||
Quote{21, "N’allez pas où va le chemin. Allez là où il n’y en a pas encore, et ouvrez la route.", "Ralph Waldo Emerson"},
|
||||
Quote{22, "Un homme doit être assez grand pour admettre ses erreurs, assez intelligent pour apprendre de celles-ci et assez fort pour les corriger. John C.", "Maxwell"},
|
||||
Quote{23, "Si ce que vous faites ne vous rapproche pas de vos buts, alors c’est que ça vous éloigne de ceux-ci.", "Brian Tracy"},
|
||||
Quote{24, "Qui veut faire quelque chose trouve un moyen, qui ne veut rien faire trouve une excuse.", "Proverbe arabe"},
|
||||
Quote{25, "Si vous voulez que la vie vous sourie, apportez-lui votre bonne humeur.", "Baruch Spinoza"},
|
||||
Quote{26, "Nulle pierre ne peut être polie sans friction, nul homme ne peut parfaire son expérience sans épreuve.", "Confucius"},
|
||||
Quote{27, "Se donner du mal pour les petites choses, c’est parvenir aux grandes, avec le temps.", "Samuel Beckett"},
|
||||
Quote{28, "Le succès c’est d’avoir ce que vous désirez. Le bonheur c’est aimer ce que vous avez. H.", "Jackson Brown"},
|
||||
Quote{29, "La chose la plus difficile est de n’attribuer aucune importance aux choses qui n’ont aucune importance.", "Charles de Gaulle"},
|
||||
Quote{30, "Nous commençons à vieillir quand nous remplaçons nos rêves par des regrets.", "Sénèque"},
|
||||
Quote{31, "Je préfère vivre en optimiste et me tromper, que vivre en pessimiste pour la seule satisfaction d’avoir eu raison.", "Milan Kundera"},
|
||||
Quote{32, "Les optimistes proclament que nous vivons dans un monde rempli de possibilités… Les pessimistes ont peur que ce soit vrai !", "James Branch Cabell"},
|
||||
Quote{33, "L’une des meilleures façons d’aider quelqu’un est de lui donner une responsabilité et de lui faire savoir que vous lui faites confiance.", "Booker Washington"},
|
||||
Quote{34, "La seule limite à notre épanouissement de demain sera nos doutes d’aujourd’hui.", "Franklin Roosevelt"},
|
||||
Quote{35, "C’est dans les moments les plus sombres qu’on voit le mieux les étoiles.", "Charles Beard"},
|
||||
Quote{36, "Ne jugez pas chaque journée par votre récolte, mais par les graines que vous avez plantées.", "Robert Stevenson"},
|
||||
Quote{37, "Contentez-vous d’agir et laissez les autres parler.", "Baltasar Gracian"},
|
||||
Quote{38, "Mettez en tout un grain d’audace.", "Baltazar Gracian"},
|
||||
Quote{39, "Un sourire coûte moins cher que l’électricité, mais donne autant de lumière.", "Abbé Pierre"},
|
||||
Quote{40, "N’attendez pas d’être heureux pour sourire, souriez pour être heureux.", "Edward Kramer"},
|
||||
Quote{41, "Tout le monde est un génie. Mais si on juge un poisson sur sa capacité à grimper à un arbre, il passera sa vie à croire qu’il est stupide.", "Albert Einstein"},
|
||||
Quote{42, "Le succès est la capacité d’aller d’échec en échec sans perdre son enthousiasme.", "Winston Churchill"},
|
||||
Quote{43, "Rien ne sert de défendre le monde d’hier quand on peut construire le monde de demain.", "Peter Drucker"},
|
||||
Quote{44, "Le plus grand plaisir de la vie est de réaliser ce que les autres vous pensent incapable de réaliser.", "Walter Bagehot"},
|
||||
Quote{45, "Nous avons deux choix dans la vie : le premier est d’accepter les choses comme elles sont et la deuxième est de prendre la décision de les changer.", "Denis Waitley"},
|
||||
Quote{46, "N’acceptez jamais la défaite, vous êtes peut-être à un pas de la réussite.", "Jack E Addington"},
|
||||
Quote{47, "L’échec est seulement l’opportunité de recommencer d’une façon plus intelligente.", "Henry Ford"},
|
||||
Quote{48, "Il n’y a qu’une façon d’échouer, c’est d’abandonner avant d’avoir réussi.", "Georges Clemenceau"},
|
||||
Quote{49, "Vous n’avez rien à craindre car l’échec est impossible. Vous ne pouvez qu’apprendre, évoluer et devenir meilleur que vous ne l’avez jamais été.", "Hal Elrod"},
|
||||
Quote{50, "Appréciez d’échouer, et apprenez de l’échec, car on n’apprend rien de ses succès.", "James Dyson"},
|
||||
Quote{51, "Si vous vivez un moment difficile, ne blâmez pas la vie. Vous êtes juste en train de devenir plus fort.", "Gandhi"},
|
||||
Quote{52, "Au milieu de toute difficulté se trouve cachée une opportunité.", "Albert Einstein"},
|
||||
Quote{53, "L’échec est l’épice qui donne sa saveur au succès.", "Truman Capote"},
|
||||
Quote{54, "Je n’ai pas échoué. J’ai simplement trouvé 10 000 façons de ne pas y arriver.", "Thomas Edison"},
|
||||
Quote{55, "Les plus belles années d’une vie sont celles que l’on n’a pas encore vécues.", "Victor Hugo"},
|
||||
Quote{56, "Si vous voulez que la vie vous sourie, apportez-lui d’abord votre bonne humeur.", "Baruch Spinoza"},
|
||||
Quote{57, "Ce n’est pas parce que les choses sont difficiles que nous n’osons pas, c’est parce que nous n’osons pas qu’elles sont difficiles.", "Sénèque"},
|
||||
Quote{58, "Si on veut obtenir quelque chose que l’on n’a jamais eu, il faut tenter quelque chose que l’on n’a jamais fait.", "Périclès"},
|
||||
Quote{59, "Accepte ce qui est, laisse aller ce qui était, aie confiance en ce qui sera.", "Bouddha"},
|
||||
Quote{60, "Choisis un travail que tu aimes, tu n’auras pas à travailler un seul jour de ta vie.", "Confucius"},
|
||||
Quote{61, "Il est de loin plus lucratif et plus amusant de capitaliser sur vos points forts que d’essayer de corriger tous vos points faibles.", "Tim Ferriss"},
|
||||
Quote{62, "Un homme ayant du succès est celui pouvant se construire une ferme fondation avec les briques que les autres lui jettent.", "David Brinkley"},
|
||||
Quote{63, "Ils ne savaient pas que c’était impossible alors ils l’ont fait.", "Mark Twain"},
|
||||
Quote{64, "Les gagnants trouvent des moyens, les perdants des excuses.", "Franklin Roosevelt"},
|
||||
Quote{65, "Croyez en vos rêves et ils se réaliseront peut-être. Croyez en vous et ils se réaliseront sûrement.", "Martin Luther King"},
|
||||
Quote{66, "Un voyage de mille lieues commence toujours par un premier pas.", "Lao Tseu"},
|
||||
Quote{67, "Il faut toujours viser la lune car même en cas d’échec on atterrit dans les étoiles.", "Oscar Wilde"},
|
||||
Quote{68, "Ce qui est plus triste qu’une œuvre inachevée, c’est une œuvre jamais commencée.", "Christinna Rosseti"},
|
||||
Quote{69, "L’obscurité ne peut pas chasser l’obscurité, seule la lumière le peut. La haine ne peut pas chasser la haine, seul l’amour le peut.", "Martin Luther King"},
|
||||
Quote{70, "Je n’ai pas peur de demain, car j’ai vu hier et j’aime aujourd’hui.", "William Allen White"},
|
||||
Quote{71, "Un pessimiste voit la difficulté dans chaque opportunité, un optimiste voit l’opportunité dans chaque difficulté.", "Winston Churchill"},
|
||||
Quote{72, "Si vous regardez avec attention, la plupart des succès obtenus du jour au lendemain prennent beaucoup de temps.", "Steve Jobs"},
|
||||
Quote{73, "Les échecs sont les marches que nous montons pour atteindre le succès.", "Roy Bennett"},
|
||||
Quote{74, "Si vous pouvez le rêver, vous pouvez le faire.", "Walt Disney"},
|
||||
Quote{75, "Croyez en vos rêves et ils se réaliseront peut-être. Croyez en vous, et ils se réaliseront sûrement.", "Martin Luther King"},
|
||||
Quote{76, "La première étape est de dire que tu peux.", "Will Smith"},
|
||||
Quote{77, "Quand on veut une chose, tout l’Univers conspire à nous permettre de réaliser notre rêve.", "Paulo Coelho"},
|
||||
Quote{78, "Tout est possible à qui rêve, ose, travaille et n’abandonne jamais.", "Xavier Dolan"},
|
||||
Quote{79, "La sagesse, c’est d’avoir des rêves suffisamment grands pour ne pas les perdre de vue lorsqu’on les poursuit.", "Oscar Wilde"},
|
||||
Quote{80, "Quand vous osez rêver grand, c’est là où votre système nerveux va créer du plaisir et être orienté solutions.", "David Laroche"},
|
||||
Quote{81, "Choisir sa vie, c’est se demander si l’on doit réaliser ses rêves ou subir le quotidien.", "Sonia Lahsaini"},
|
||||
Quote{82, "Fais de ta vie un rêve, et d’un rêve, une réalité.", "Antoine de Saint-Exupéry"},
|
||||
}
|
||||
|
||||
func declareQuotesRoutes(cfg *config.Config, router *gin.RouterGroup) {
|
||||
router.GET("/quoteoftheday", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, quotes[time.Now().Add(-5*time.Hour).YearDay()%len(quotes)])
|
||||
})
|
||||
|
||||
router.GET("/quotes", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, quotes)
|
||||
})
|
||||
router.POST("/quotes", func(c *gin.Context) {
|
||||
|
||||
})
|
||||
|
||||
quotesRoutes := router.Group("/quotes/:qid")
|
||||
quotesRoutes.Use(quoteHandler)
|
||||
|
||||
quotesRoutes.GET("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, c.MustGet("quote"))
|
||||
})
|
||||
quotesRoutes.PUT("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, c.MustGet("quote"))
|
||||
})
|
||||
quotesRoutes.DELETE("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, c.MustGet("quote"))
|
||||
})
|
||||
}
|
||||
|
||||
func quoteHandler(c *gin.Context) {
|
||||
c.Set("quote", nil)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
type Quote struct {
|
||||
Id int `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
}
|
||||
22
api/routes.go
Normal file
22
api/routes.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
"git.nemunai.re/nemunaire/reveil/model"
|
||||
)
|
||||
|
||||
func DeclareRoutes(router *gin.Engine, cfg *config.Config, db *reveil.LevelDBStorage, resetTimer func()) {
|
||||
apiRoutes := router.Group("/api")
|
||||
|
||||
declareActionsRoutes(cfg, apiRoutes)
|
||||
declareAlarmRoutes(cfg, apiRoutes)
|
||||
declareAlarmsRoutes(cfg, db, resetTimer, apiRoutes)
|
||||
declareGongsRoutes(cfg, apiRoutes)
|
||||
declareHistoryRoutes(cfg, apiRoutes)
|
||||
declareQuotesRoutes(cfg, apiRoutes)
|
||||
declareRoutinesRoutes(cfg, apiRoutes)
|
||||
declareTracksRoutes(cfg, apiRoutes)
|
||||
declareSettingsRoutes(cfg, apiRoutes)
|
||||
}
|
||||
81
api/routines.go
Normal file
81
api/routines.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
"git.nemunai.re/nemunaire/reveil/model"
|
||||
)
|
||||
|
||||
func declareRoutinesRoutes(cfg *config.Config, router *gin.RouterGroup) {
|
||||
router.GET("/routines", func(c *gin.Context) {
|
||||
routines, err := reveil.LoadRoutines(cfg)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, routines)
|
||||
})
|
||||
router.POST("/routines", func(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusNotImplemented, gin.H{"errmsg": "TODO"})
|
||||
})
|
||||
|
||||
routinesRoutes := router.Group("/routines/:tid")
|
||||
routinesRoutes.Use(func(c *gin.Context) {
|
||||
routines, err := reveil.LoadRoutines(cfg)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, t := range routines {
|
||||
if t.Id.ToString() == c.Param("tid") {
|
||||
c.Set("routine", t)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Routine not found"})
|
||||
})
|
||||
|
||||
routinesRoutes.GET("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, c.MustGet("routine"))
|
||||
})
|
||||
routinesRoutes.PUT("", func(c *gin.Context) {
|
||||
oldroutine := c.MustGet("routine").(*reveil.Routine)
|
||||
|
||||
var routine reveil.Routine
|
||||
if err := c.ShouldBindJSON(&routine); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if routine.Name != oldroutine.Name {
|
||||
err := oldroutine.Rename(routine.Name)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to rename the routine: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: change actions
|
||||
|
||||
c.JSON(http.StatusOK, oldroutine)
|
||||
})
|
||||
routinesRoutes.DELETE("", func(c *gin.Context) {
|
||||
routine := c.MustGet("routine").(*reveil.Routine)
|
||||
|
||||
err := routine.Remove()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to remove the routine: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, nil)
|
||||
})
|
||||
}
|
||||
38
api/settings.go
Normal file
38
api/settings.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
"git.nemunai.re/nemunaire/reveil/model"
|
||||
)
|
||||
|
||||
func declareSettingsRoutes(cfg *config.Config, router *gin.RouterGroup) {
|
||||
router.GET("/settings", func(c *gin.Context) {
|
||||
settings, err := reveil.ReadSettings(cfg.SettingsFile)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, settings)
|
||||
})
|
||||
router.PUT("/settings", func(c *gin.Context) {
|
||||
var config reveil.Settings
|
||||
err := c.ShouldBindJSON(&config)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
err = reveil.SaveSettings(cfg.SettingsFile, config)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
})
|
||||
}
|
||||
150
api/tracks.go
Normal file
150
api/tracks.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
"git.nemunai.re/nemunaire/reveil/model"
|
||||
)
|
||||
|
||||
func declareTracksRoutes(cfg *config.Config, router *gin.RouterGroup) {
|
||||
router.GET("/tracks", func(c *gin.Context) {
|
||||
tracks, err := reveil.LoadTracks(cfg)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tracks)
|
||||
})
|
||||
router.POST("/tracks", func(c *gin.Context) {
|
||||
ftrack, err := c.FormFile("trackfile")
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No track found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if path.Ext(ftrack.Filename) != ".mp3" && path.Ext(ftrack.Filename) != ".flac" {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad file type. You should only upload .mp3 or .flac files."})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(ftrack.Filename, "/") {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad file name."})
|
||||
return
|
||||
}
|
||||
|
||||
dst := path.Join(cfg.TracksDir, ftrack.Filename)
|
||||
err = c.SaveUploadedFile(ftrack, dst)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Something goes wrong when saving the track: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
d, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Something goes wrong when saving the track: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
track, err := reveil.LoadTrack(dst, d)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to load track: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, track)
|
||||
})
|
||||
|
||||
tracksRoutes := router.Group("/tracks/:tid")
|
||||
tracksRoutes.Use(func(c *gin.Context) {
|
||||
tracks, err := reveil.LoadTracks(cfg)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, t := range tracks {
|
||||
if t.Id.ToString() == c.Param("tid") {
|
||||
c.Set("track", t)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Track not found"})
|
||||
})
|
||||
|
||||
tracksRoutes.GET("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, c.MustGet("track"))
|
||||
})
|
||||
tracksRoutes.GET("/stream", func(c *gin.Context) {
|
||||
track := c.MustGet("track").(*reveil.Track)
|
||||
|
||||
size, err := track.Size()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to open the track: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
fd, err := track.Open()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to open the track: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
c.DataFromReader(http.StatusOK, size, track.ContentType(), fd, map[string]string{})
|
||||
})
|
||||
tracksRoutes.PUT("", func(c *gin.Context) {
|
||||
oldtrack := c.MustGet("track").(*reveil.Track)
|
||||
|
||||
var track reveil.Track
|
||||
if err := c.ShouldBindJSON(&track); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if track.Name != oldtrack.Name {
|
||||
err := oldtrack.Rename(track.Name)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to rename the track: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if track.Enabled != oldtrack.Enabled {
|
||||
var err error
|
||||
if track.Enabled {
|
||||
err = oldtrack.Enable(cfg)
|
||||
} else {
|
||||
err = oldtrack.Disable()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to enable/disable the track: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, oldtrack)
|
||||
})
|
||||
tracksRoutes.DELETE("", func(c *gin.Context) {
|
||||
track := c.MustGet("track").(*reveil.Track)
|
||||
|
||||
err := track.Remove()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to remove the track: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, nil)
|
||||
})
|
||||
}
|
||||
109
app.go
Normal file
109
app.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/api"
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
"git.nemunai.re/nemunaire/reveil/model"
|
||||
"git.nemunai.re/nemunaire/reveil/player"
|
||||
"git.nemunai.re/nemunaire/reveil/ui"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg *config.Config
|
||||
db *reveil.LevelDBStorage
|
||||
router *gin.Engine
|
||||
srv *http.Server
|
||||
nextAlarm *time.Timer
|
||||
}
|
||||
|
||||
func NewApp(cfg *config.Config) *App {
|
||||
if cfg.DevProxy == "" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
gin.ForceConsoleColor()
|
||||
router := gin.Default()
|
||||
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Open Database
|
||||
db, err := reveil.NewLevelDBStorage(cfg.LevelDBPath)
|
||||
if err != nil {
|
||||
log.Fatal("Unable to open the database:", err)
|
||||
}
|
||||
|
||||
// Prepare struct
|
||||
app := &App{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
router: router,
|
||||
}
|
||||
|
||||
// Register routes
|
||||
ui.DeclareRoutes(router, cfg)
|
||||
api.DeclareRoutes(router, cfg, db, app.ResetTimer)
|
||||
|
||||
router.GET("/api/version", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"version": Version})
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func (app *App) Start() {
|
||||
app.srv = &http.Server{
|
||||
Addr: app.cfg.Bind,
|
||||
Handler: app.router,
|
||||
}
|
||||
|
||||
app.ResetTimer()
|
||||
|
||||
log.Printf("Ready, listening on %s\n", app.cfg.Bind)
|
||||
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) ResetTimer() {
|
||||
if app.nextAlarm != nil {
|
||||
app.nextAlarm.Stop()
|
||||
app.nextAlarm = nil
|
||||
}
|
||||
|
||||
if na, err := reveil.GetNextAlarm(app.db); err == nil && na != nil {
|
||||
app.nextAlarm = time.AfterFunc(time.Until(*na), func() {
|
||||
app.nextAlarm = nil
|
||||
reveil.RemoveOldAlarmsSingle(app.db)
|
||||
err := player.WakeUp(app.cfg)
|
||||
if err != nil {
|
||||
log.Println(err.Error())
|
||||
return
|
||||
}
|
||||
})
|
||||
log.Println("Next timer programmed for", *na)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) Stop() {
|
||||
if app.nextAlarm != nil {
|
||||
app.nextAlarm.Stop()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := app.srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal("Server Shutdown:", err)
|
||||
}
|
||||
|
||||
if err := app.db.Close(); err != nil {
|
||||
log.Fatal("Database Close:", err)
|
||||
}
|
||||
}
|
||||
59
config/cli.go
Normal file
59
config/cli.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
)
|
||||
|
||||
// declareFlags registers flags for the structure Options.
|
||||
func (c *Config) declareFlags() {
|
||||
flag.Var(&c.ExternalURL, "external-url", "Public URL of the service")
|
||||
flag.StringVar(&c.BaseURL, "baseurl", c.BaseURL, "URL prepended to each URL")
|
||||
flag.StringVar(&c.Bind, "bind", c.Bind, "Bind port/socket")
|
||||
flag.StringVar(&c.DevProxy, "dev", c.DevProxy, "Use ui directory instead of embedded assets")
|
||||
flag.StringVar(&c.LevelDBPath, "leveldb-path", c.LevelDBPath, "Path to the LevelDB database")
|
||||
flag.StringVar(&c.SettingsFile, "settings-file", c.SettingsFile, "Path to the file containing the settings")
|
||||
flag.StringVar(&c.TracksDir, "tracks-dir", c.TracksDir, "Path to the directory containing the tracks")
|
||||
flag.StringVar(&c.GongsDir, "gongs-dir", c.GongsDir, "Path to the directory containing the gongs")
|
||||
flag.StringVar(&c.ActionsDir, "actions-dir", c.ActionsDir, "Path to the directory containing the actions")
|
||||
flag.StringVar(&c.RoutinesDir, "routines-dir", c.RoutinesDir, "Path to the directory containing the routines")
|
||||
flag.IntVar(&c.SampleRate, "samplerate", c.SampleRate, "Samplerate for unifying output stream")
|
||||
|
||||
// Others flags are declared in some other files when they need specials configurations
|
||||
}
|
||||
|
||||
func Consolidated() (cfg *Config, err error) {
|
||||
// Define defaults options
|
||||
cfg = &Config{
|
||||
Bind: "127.0.0.1:8080",
|
||||
LevelDBPath: "alarms.db",
|
||||
SettingsFile: "./settings.json",
|
||||
TracksDir: "./tracks/",
|
||||
GongsDir: "./gongs/",
|
||||
ActionsDir: "./actions/",
|
||||
RoutinesDir: "./routines/",
|
||||
SampleRate: 44100,
|
||||
}
|
||||
|
||||
cfg.declareFlags()
|
||||
|
||||
// Then, overwrite that by what is present in the environment
|
||||
err = cfg.FromEnv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Finaly, command line takes precedence
|
||||
err = cfg.parseCLI()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// parseCLI parse the flags and treats extra args as configuration filename.
|
||||
func (c *Config) parseCLI() error {
|
||||
flag.Parse()
|
||||
|
||||
return nil
|
||||
}
|
||||
38
config/config.go
Normal file
38
config/config.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DevProxy string
|
||||
Bind string
|
||||
ExternalURL URL
|
||||
BaseURL string
|
||||
|
||||
LevelDBPath string
|
||||
SettingsFile string
|
||||
TracksDir string
|
||||
GongsDir string
|
||||
ActionsDir string
|
||||
RoutinesDir string
|
||||
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// parseLine treats a config line and place the read value in the variable
|
||||
// declared to the corresponding flag.
|
||||
func (c *Config) parseLine(line string) (err error) {
|
||||
fields := strings.SplitN(line, "=", 2)
|
||||
orig_key := strings.TrimSpace(fields[0])
|
||||
value := strings.TrimSpace(fields[1])
|
||||
|
||||
key := strings.TrimPrefix(orig_key, "REVEIL_")
|
||||
key = strings.Replace(key, "_", "-", -1)
|
||||
key = strings.ToLower(key)
|
||||
|
||||
err = flag.Set(key, value)
|
||||
|
||||
return
|
||||
}
|
||||
44
config/custom.go
Normal file
44
config/custom.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type JWTSecretKey []byte
|
||||
|
||||
func (i *JWTSecretKey) String() string {
|
||||
return base64.StdEncoding.EncodeToString(*i)
|
||||
}
|
||||
|
||||
func (i *JWTSecretKey) Set(value string) error {
|
||||
z, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*i = z
|
||||
return nil
|
||||
}
|
||||
|
||||
type URL struct {
|
||||
URL *url.URL
|
||||
}
|
||||
|
||||
func (i *URL) String() string {
|
||||
if i.URL != nil {
|
||||
return i.URL.String()
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (i *URL) Set(value string) error {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.URL = u
|
||||
return nil
|
||||
}
|
||||
21
config/env.go
Normal file
21
config/env.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FromEnv analyzes all the environment variables to find each one
|
||||
// starting by GUSTUS_
|
||||
func (c *Config) FromEnv() error {
|
||||
for _, line := range os.Environ() {
|
||||
if strings.HasPrefix(line, "GUSTUS_") {
|
||||
err := c.parseLine(line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error in environment (%q): %w", line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
41
go.mod
41
go.mod
|
|
@ -1,5 +1,40 @@
|
|||
module git.nemunai.re/reveil
|
||||
module git.nemunai.re/nemunaire/reveil
|
||||
|
||||
go 1.16
|
||||
go 1.18
|
||||
|
||||
require github.com/faiface/beep v1.0.3-0.20210301102329-98afada94bff
|
||||
require (
|
||||
github.com/faiface/beep v0.0.0-00010101000000-000000000000
|
||||
github.com/gin-gonic/gin v1.8.1
|
||||
github.com/syndtr/goleveldb v1.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ebitengine/purego v0.0.0-20220907032450-cf3e27c364c7 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.10.0 // indirect
|
||||
github.com/goccy/go-json v0.9.7 // indirect
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/hajimehoshi/go-mp3 v0.3.0 // indirect
|
||||
github.com/hajimehoshi/oto/v2 v2.4.0-alpha.4 // indirect
|
||||
github.com/icza/bitio v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/mewkiz/flac v1.0.7 // indirect
|
||||
github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.7 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect
|
||||
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect
|
||||
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/faiface/beep => github.com/MarkKremer/beep v1.0.3-0.20221013180303-756ceb286755
|
||||
|
|
|
|||
130
go.sum
130
go.sum
|
|
@ -1,42 +1,152 @@
|
|||
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/MarkKremer/beep v1.0.3-0.20221013180303-756ceb286755 h1:rkuKNEd+Izze/hA44R1kzPs9BXa544xXeufys8x0fkc=
|
||||
github.com/MarkKremer/beep v1.0.3-0.20221013180303-756ceb286755/go.mod h1:PWWzyIlbyHQjQ/gJzGiMyDAvjo/t5L8TC8qkyX8UfWs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
|
||||
github.com/faiface/beep v1.0.3-0.20210301102329-98afada94bff h1:4zdNP0II42+Z9XaMdFZms8q0NdGGB6cQi1b9zPntdcY=
|
||||
github.com/faiface/beep v1.0.3-0.20210301102329-98afada94bff/go.mod h1:fQeOQNj1CiI7p0IeJ159hm16XDejdTP3B92ngeJMxac=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/purego v0.0.0-20220907032450-cf3e27c364c7 h1:tmSauY5l3s/Cp5n+cEiG1epUR2AejmdHeMJMycMFxb0=
|
||||
github.com/ebitengine/purego v0.0.0-20220907032450-cf3e27c364c7/go.mod h1:Eh8I3yvknDYZeCuXH9kRNaPuHEwvXDCk378o9xszmHg=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
|
||||
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
|
||||
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
|
||||
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
|
||||
github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
|
||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||
github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0=
|
||||
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||
github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM=
|
||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/hajimehoshi/go-mp3 v0.3.0 h1:fTM5DXjp/DL2G74HHAs/aBGiS9Tg7wnp+jkU38bHy4g=
|
||||
github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
|
||||
github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
|
||||
github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk=
|
||||
github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos=
|
||||
github.com/hajimehoshi/oto/v2 v2.4.0-alpha.4 h1:m29xzbn3Pv5MgvgjMPs7m28uhUgVt3B3AIGjQLgkqUI=
|
||||
github.com/hajimehoshi/oto/v2 v2.4.0-alpha.4/go.mod h1:OdGUICBjy7upAjvqqacbB63XIuYR3fqXZ7kYtlVYJgQ=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/icza/bitio v1.0.0 h1:squ/m1SHyFeCA6+6Gyol1AxV9nmPPlJFT8c2vKdj3U8=
|
||||
github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
|
||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
||||
github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk=
|
||||
github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mewkiz/flac v1.0.6 h1:OnMwCWZPAnjDndjEzLynOZ71Y2U+/QYHoVI4JEKgKkk=
|
||||
github.com/mewkiz/flac v1.0.6/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU=
|
||||
github.com/mewkiz/flac v1.0.7 h1:uIXEjnuXqdRaZttmSFM5v5Ukp4U6orrZsnYGGR3yow8=
|
||||
github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU=
|
||||
github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 h1:EyTNMdePWaoWsRSGQnXiSoQu0r6RS1eA557AwJhlzHU=
|
||||
github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU=
|
||||
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
|
||||
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
|
||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc=
|
||||
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ=
|
||||
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10=
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e h1:NHvCuwuS43lGnYhten69ZWqi2QOj/CiDNcKbVqwVoew=
|
||||
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
348
main.go
348
main.go
|
|
@ -1,339 +1,65 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/faiface/beep"
|
||||
"github.com/faiface/beep/effects"
|
||||
"github.com/faiface/beep/flac"
|
||||
"github.com/faiface/beep/mp3"
|
||||
"github.com/faiface/beep/speaker"
|
||||
"github.com/faiface/beep/wav"
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
)
|
||||
|
||||
var (
|
||||
MaxRunTime = 1 * time.Hour
|
||||
ntick int64 = 0
|
||||
Version = "custom-build"
|
||||
)
|
||||
|
||||
func xPrintIdle() (idle uint64) {
|
||||
cmd := exec.Command("xprintidle")
|
||||
cmd.Env = append(os.Environ(), "DISPLAY=:0")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Println(err)
|
||||
} else {
|
||||
s := string(out.Bytes())
|
||||
if idle, err = strconv.ParseUint(strings.TrimSpace(s), 10, 64); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func speakToday() {
|
||||
cmdSetVolume := exec.Command("amixer", "-D", "pulse", "set", "Master", fmt.Sprintf("%d%%", 50+50/(int64(MaxRunTime.Seconds()/3)/ntick+1)))
|
||||
if err := cmdSetVolume.Run(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
cmd0 := exec.Command("/home/nemunaire/scripts/wakeup/today.sh")
|
||||
if err := cmd0.Run(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
cmdSetBackVolume := exec.Command("amixer", "-D", "pulse", "set", "Master", "100%")
|
||||
if err := cmdSetBackVolume.Run(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func speakWeather() {
|
||||
var icon = "partly-cloudy-day"
|
||||
|
||||
cmd := exec.Command("/home/nemunaire/scripts/wakeup/ind_weather.sh")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Println(err)
|
||||
} else {
|
||||
icon = string(out.Bytes())
|
||||
}
|
||||
|
||||
var preset = "pika-summer-forest.xml"
|
||||
switch icon {
|
||||
case "clear-day", "clear-night":
|
||||
preset = "grassland.xml"
|
||||
case "rain", "hail":
|
||||
preset = "the-perfect-storm.xml"
|
||||
case "snow", "sleet":
|
||||
preset = "a-walk-in-the-cold.xml"
|
||||
case "wind", "tornado":
|
||||
preset = "desert-wind.xml"
|
||||
case "fog":
|
||||
preset = "silent-hill-fog-world.xml"
|
||||
case "cloudy":
|
||||
preset = "autumn-forest.xml"
|
||||
case "thunderstorm":
|
||||
preset = "heavy-thunderstorm-for-me.xml"
|
||||
default:
|
||||
preset = "autumn-forest.xml"
|
||||
}
|
||||
|
||||
cmdAmbiant := exec.Command("python3", "ambient.py", path.Join("presets", preset))
|
||||
cmdAmbiant.Dir = "/home/nemunaire/workspace/pyambientmixer"
|
||||
if err := cmdAmbiant.Start(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
cmdSetVolume := exec.Command("amixer", "-D", "pulse", "set", "Master", fmt.Sprintf("%d%%", 50+50/(int64(MaxRunTime.Seconds()/3)/ntick+1)))
|
||||
if err := cmdSetVolume.Run(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
cmd0 := exec.Command("/home/nemunaire/scripts/wakeup/today.sh")
|
||||
if err := cmd0.Run(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
cmd1 := exec.Command("/home/nemunaire/scripts/wakeup/weather.sh")
|
||||
if err := cmd1.Run(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
cmd2 := exec.Command("/home/nemunaire/scripts/wakeup/airparif.sh")
|
||||
if err := cmd2.Run(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
cmd3 := exec.Command("/home/nemunaire/scripts/wakeup/ratp-traffic.sh", "rers", "B")
|
||||
if err := cmd3.Run(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
if cmdAmbiant.Process != nil {
|
||||
(*cmdAmbiant.Process).Kill()
|
||||
}
|
||||
cmdAmbiant.Process.Wait()
|
||||
|
||||
cmdSetBackVolume := exec.Command("amixer", "-D", "pulse", "set", "Master", "100%")
|
||||
if err := cmdSetBackVolume.Run(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadFile(filepath string) (name string, s beep.StreamSeekCloser, format beep.Format, err error) {
|
||||
var fd *os.File
|
||||
|
||||
name = path.Base(filepath)
|
||||
|
||||
fd, err = os.Open(filepath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(path.Ext(filepath)) {
|
||||
case ".flac":
|
||||
s, format, err = flac.Decode(fd)
|
||||
case ".mp3":
|
||||
s, format, err = mp3.Decode(fd)
|
||||
default:
|
||||
s, format, err = wav.Decode(fd)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fd.Close()
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
func main() {
|
||||
var weatherTime = flag.Duration("weather", -1, "Speak weather?")
|
||||
var noshuffle = flag.Bool("noshuffle", false, "Don't shuffle music order")
|
||||
var ignoreidle = flag.Bool("ignoreidle", false, "Don't stop the reveil on idle detection change")
|
||||
var sr = flag.Int("samplerate", 44100, "Samplerate for unifying output stream")
|
||||
var claironTime = flag.Duration("clairon", -1, "Time before running the wake up clairon song")
|
||||
flag.DurationVar(&MaxRunTime, "maxruntime", MaxRunTime, "Maximum duration before auto exit")
|
||||
flag.Parse()
|
||||
|
||||
if len(flag.Args()) < 1 {
|
||||
log.Println("missing required argument: input file name")
|
||||
return
|
||||
cfg, err := config.Consolidated()
|
||||
if err != nil {
|
||||
log.Fatal("Unable to read configuration:", err)
|
||||
}
|
||||
|
||||
seed := time.Now().Unix()
|
||||
seed -= seed % 172800
|
||||
|
||||
reverseOrder := int(time.Now().Unix()/86400)%2 == 0
|
||||
|
||||
log.Println("Starting reveil with seed:", seed, "; order:", reverseOrder)
|
||||
|
||||
rand.Seed(seed)
|
||||
|
||||
sampleRate := beep.SampleRate(*sr)
|
||||
|
||||
paths := []string{}
|
||||
playlist := []beep.Streamer{}
|
||||
formats := []beep.Format{}
|
||||
|
||||
// Load playlist
|
||||
log.Println("Loading playlist...")
|
||||
for _, arg := range flag.Args() {
|
||||
p, s, f, err := loadFile(arg)
|
||||
// Clean paths
|
||||
if _, err := os.Stat(cfg.SettingsFile); os.IsNotExist(err) {
|
||||
fd, err := os.Create(cfg.SettingsFile)
|
||||
if err != nil {
|
||||
log.Printf("Unable to load %s: %s", arg, err)
|
||||
continue
|
||||
log.Fatal("Unable to create settings file:", err)
|
||||
}
|
||||
paths = append(paths, p)
|
||||
playlist = append(playlist, s)
|
||||
formats = append(formats, f)
|
||||
|
||||
fd.Write([]byte{'{', '}'})
|
||||
|
||||
fd.Close()
|
||||
}
|
||||
|
||||
if noshuffle == nil || *noshuffle == false {
|
||||
log.Println("Shuffling playlist...")
|
||||
// Shuffle the playlist
|
||||
rand.Shuffle(len(playlist), func(i, j int) {
|
||||
paths[i], paths[j] = paths[j], paths[i]
|
||||
playlist[i], playlist[j] = playlist[j], playlist[i]
|
||||
formats[i], formats[j] = formats[j], formats[i]
|
||||
})
|
||||
if _, err := os.Stat(cfg.TracksDir); os.IsNotExist(err) {
|
||||
if err := os.Mkdir(cfg.TracksDir, 0755); err != nil {
|
||||
log.Fatal("Unable to create tracks directory:", err)
|
||||
}
|
||||
}
|
||||
log.Println("Playlist in use:", strings.Join(paths, " ; "))
|
||||
|
||||
var launched time.Time
|
||||
var volume *effects.Volume
|
||||
|
||||
dontUpdateVolume := false
|
||||
hasClaironed := claironTime == nil || *claironTime == -1
|
||||
hasSpokeWeather := weatherTime == nil || *weatherTime == -1
|
||||
playedItem := -1
|
||||
|
||||
// Create infinite stream
|
||||
stream := beep.Iterate(func() beep.Streamer {
|
||||
if !hasClaironed && time.Since(launched) >= *claironTime {
|
||||
log.Println("clairon time!")
|
||||
*claironTime += *claironTime / 2
|
||||
//_, sample, format, err := loadFile("/home/nemunaire/www/audio/miracle-morning/clairon-reveil.mp3")
|
||||
//_, sample, format, err := loadFile("/home/nemunaire/www/audio/miracle-morning/coq.flac")
|
||||
_, sample, format, err := loadFile("/home/nemunaire/www/audio/miracle-morning/NukeAnthem.flac")
|
||||
if err == nil {
|
||||
volume.Volume = 0.1
|
||||
dontUpdateVolume = true
|
||||
if format.SampleRate != sampleRate {
|
||||
return beep.Resample(3, format.SampleRate, sampleRate, sample)
|
||||
} else {
|
||||
return sample
|
||||
}
|
||||
} else {
|
||||
log.Println("Error loading clairon:", err)
|
||||
}
|
||||
if _, err := os.Stat(cfg.GongsDir); os.IsNotExist(err) {
|
||||
if err := os.Mkdir(cfg.GongsDir, 0755); err != nil {
|
||||
log.Fatal("Unable to create gongs directory:", err)
|
||||
}
|
||||
|
||||
if !hasSpokeWeather && time.Since(launched) >= *weatherTime {
|
||||
log.Println("weather time!")
|
||||
hasSpokeWeather = true
|
||||
return beep.Callback(speakWeather)
|
||||
}
|
||||
if _, err := os.Stat(cfg.ActionsDir); os.IsNotExist(err) {
|
||||
if err := os.Mkdir(cfg.ActionsDir, 0755); err != nil {
|
||||
log.Fatal("Unable to create actions directory:", err)
|
||||
}
|
||||
|
||||
dontUpdateVolume = false
|
||||
volume.Volume = -2 - math.Log(5/float64(ntick))/3
|
||||
|
||||
if reverseOrder {
|
||||
playedItem -= 1
|
||||
} else {
|
||||
playedItem += 1
|
||||
}
|
||||
|
||||
if playedItem >= len(playlist) {
|
||||
playedItem = 0
|
||||
} else if playedItem < 0 {
|
||||
playedItem = len(playlist) - 1
|
||||
}
|
||||
|
||||
if i, ok := playlist[playedItem].(beep.StreamSeekCloser); ok {
|
||||
// In case of loop, ensure we are at the beginning of the stream
|
||||
i.Seek(0)
|
||||
}
|
||||
|
||||
// Resample if needed
|
||||
log.Println("playing list item:", playedItem, "/", len(playlist))
|
||||
if formats[playedItem].SampleRate != sampleRate {
|
||||
return beep.Resample(3, formats[playedItem].SampleRate, sampleRate, playlist[playedItem])
|
||||
} else {
|
||||
return playlist[playedItem]
|
||||
}
|
||||
})
|
||||
|
||||
// Prepare sound player
|
||||
log.Println("Initializing sound player...")
|
||||
speaker.Init(sampleRate, sampleRate.N(time.Second/10))
|
||||
|
||||
volume = &effects.Volume{stream, 10, -2, false}
|
||||
speaker.Play(volume)
|
||||
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
launched = time.Now()
|
||||
idle := xPrintIdle()
|
||||
|
||||
// Prepare graceful shutdown
|
||||
maxRun := time.After(MaxRunTime)
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-maxRun:
|
||||
break loop
|
||||
case <-ticker.C:
|
||||
ntick += 1
|
||||
if !dontUpdateVolume {
|
||||
volume.Volume = -2 - math.Log(5/float64(ntick))/3
|
||||
}
|
||||
|
||||
if ignoreidle == nil || !*ignoreidle {
|
||||
if idle < 60000 {
|
||||
idle = xPrintIdle()
|
||||
} else if xPrintIdle() < idle {
|
||||
break loop
|
||||
}
|
||||
}
|
||||
case <-interrupt:
|
||||
break loop
|
||||
}
|
||||
if _, err := os.Stat(cfg.RoutinesDir); os.IsNotExist(err) {
|
||||
if err := os.Mkdir(cfg.RoutinesDir, 0755); err != nil {
|
||||
log.Fatal("Unable to create routines directory:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Tell parent process that it can launch the wake up procedure
|
||||
if time.Since(launched) < MaxRunTime && time.Since(launched) > 60*time.Second {
|
||||
if proc, err := os.FindProcess(os.Getppid()); err != nil {
|
||||
log.Println(err)
|
||||
} else if err := proc.Signal(syscall.SIGHUP); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
// Start app
|
||||
a := NewApp(cfg)
|
||||
go a.Start()
|
||||
|
||||
// Calm down music
|
||||
for i := 0; i < 2000; i += 1 {
|
||||
volume.Volume -= 0.001
|
||||
time.Sleep(4 * time.Millisecond)
|
||||
}
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("Stopping the service...")
|
||||
a.Stop()
|
||||
log.Println("Stopped")
|
||||
}
|
||||
|
|
|
|||
132
model/action.go
Normal file
132
model/action.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package reveil
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha512"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
)
|
||||
|
||||
type Action struct {
|
||||
Id Identifier `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func LoadAction(path string) (string, string, error) {
|
||||
fd, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
fileScanner := bufio.NewScanner(fd)
|
||||
fileScanner.Split(bufio.ScanLines)
|
||||
|
||||
var (
|
||||
shebang = false
|
||||
name = ""
|
||||
description = ""
|
||||
)
|
||||
for fileScanner.Scan() {
|
||||
line := strings.TrimSpace(fileScanner.Text())
|
||||
if !shebang {
|
||||
if len(line) < 2 || line[0] != '#' || line[1] != '!' {
|
||||
return name, description, errors.New("Not a valid action file (shebang not found).")
|
||||
}
|
||||
shebang = true
|
||||
continue
|
||||
}
|
||||
|
||||
if len(line) < 2 || line[0] != '#' {
|
||||
if len(description) > 0 {
|
||||
return name, strings.TrimSpace(description), nil
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if len(name) == 0 {
|
||||
name = strings.TrimSpace(line[1:])
|
||||
} else {
|
||||
description += strings.TrimSpace(line[1:]) + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return name, description, nil
|
||||
}
|
||||
|
||||
func LoadActions(cfg *config.Config) (actions []*Action, err error) {
|
||||
actionsDir, err := filepath.Abs(cfg.ActionsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = filepath.Walk(cfg.ActionsDir, func(path string, d fs.FileInfo, err error) error {
|
||||
if d.Mode().IsRegular() {
|
||||
hash := sha512.Sum512([]byte(path))
|
||||
|
||||
// Parse content
|
||||
name, description, err := LoadAction(path)
|
||||
if err != nil {
|
||||
log.Printf("Invalid action file (trying to parse %s): %s", path, err.Error())
|
||||
// Ignore invalid files
|
||||
return nil
|
||||
}
|
||||
|
||||
if description == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if apath, err := filepath.Abs(path); err == nil {
|
||||
path = apath
|
||||
}
|
||||
|
||||
actions = append(actions, &Action{
|
||||
Id: hash[:],
|
||||
Name: name,
|
||||
Description: description,
|
||||
Path: strings.TrimPrefix(path, actionsDir+"/"),
|
||||
Enabled: d.Mode().Perm()&0111 != 0,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (a *Action) Rename(name string) error {
|
||||
return errors.New("Not implemeted")
|
||||
}
|
||||
|
||||
func (a *Action) Enable() error {
|
||||
fi, err := os.Stat(a.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Chmod(a.Path, fi.Mode().Perm()|0111)
|
||||
}
|
||||
|
||||
func (a *Action) Disable() error {
|
||||
fi, err := os.Stat(a.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Chmod(a.Path, fi.Mode().Perm()&0666)
|
||||
}
|
||||
|
||||
func (a *Action) Remove() error {
|
||||
return os.Remove(a.Path)
|
||||
}
|
||||
321
model/alarm.go
Normal file
321
model/alarm.go
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
package reveil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Date time.Time
|
||||
|
||||
func (d *Date) MarshalJSON() (dst []byte, err error) {
|
||||
return []byte(fmt.Sprintf("\"%04d-%02d-%02d\"", time.Time(*d).Year(), time.Time(*d).Month(), time.Time(*d).Day())), nil
|
||||
}
|
||||
|
||||
func (d *Date) UnmarshalJSON(src []byte) error {
|
||||
tmp, err := time.Parse("\"2006-01-02\"", string(src))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Date(tmp)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Hour time.Time
|
||||
|
||||
func (h *Hour) MarshalJSON() (dst []byte, err error) {
|
||||
return []byte(fmt.Sprintf("\"%02d:%02d\"", time.Time(*h).Hour(), time.Time(*h).Minute())), nil
|
||||
}
|
||||
|
||||
func (h *Hour) UnmarshalJSON(src []byte) error {
|
||||
tmp, err := time.Parse("\"15:04\"", string(src))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*h = Hour(tmp)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetNextAlarm(db *LevelDBStorage) (*time.Time, error) {
|
||||
alarmsRepeated, err := GetAlarmsRepeated(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var closestAlarm *time.Time
|
||||
for _, alarm := range alarmsRepeated {
|
||||
next := alarm.GetNextOccurence(db)
|
||||
if next != nil && (closestAlarm == nil || closestAlarm.After(*next)) {
|
||||
closestAlarm = next
|
||||
}
|
||||
}
|
||||
|
||||
alarmsSingle, err := GetAlarmsSingle(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, alarm := range alarmsSingle {
|
||||
if closestAlarm == nil || (closestAlarm.After(alarm.Time) && alarm.Time.After(now)) {
|
||||
closestAlarm = &alarm.Time
|
||||
}
|
||||
}
|
||||
|
||||
return closestAlarm, nil
|
||||
}
|
||||
|
||||
type Exceptions []time.Time
|
||||
|
||||
func (e Exceptions) Len() int {
|
||||
return len(e)
|
||||
}
|
||||
|
||||
func (e Exceptions) Less(i, j int) bool {
|
||||
return e[i].Before(e[j])
|
||||
}
|
||||
|
||||
func (e Exceptions) Swap(i, j int) {
|
||||
e[i], e[j] = e[j], e[i]
|
||||
}
|
||||
|
||||
type AlarmRepeated struct {
|
||||
Id Identifier `json:"id"`
|
||||
Weekday time.Weekday `json:"weekday"`
|
||||
StartTime *Hour `json:"time"`
|
||||
FollowingRoutines []Identifier `json:"routines"`
|
||||
IgnoreExceptions bool `json:"ignore_exceptions"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Excepts Exceptions `json:"excepts,omitempty"`
|
||||
NextTime *time.Time `json:"next_time,omitempty"`
|
||||
}
|
||||
|
||||
func (a *AlarmRepeated) FillExcepts(db *LevelDBStorage) error {
|
||||
if a.IgnoreExceptions {
|
||||
return nil
|
||||
}
|
||||
|
||||
exceptions, err := GetAlarmExceptions(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for _, exception := range exceptions {
|
||||
if now.After(time.Time(*exception.Start)) {
|
||||
continue
|
||||
}
|
||||
|
||||
end := time.Time(*exception.End).AddDate(0, 0, 1)
|
||||
for t := time.Time(*exception.Start); end.After(t); t = t.AddDate(0, 0, 1) {
|
||||
if t.Weekday() == a.Weekday {
|
||||
a.Excepts = append(a.Excepts, time.Date(t.Year(), t.Month(), t.Day(), time.Time(*a.StartTime).Hour(), time.Time(*a.StartTime).Minute(), time.Time(*a.StartTime).Second(), 0, now.Location()))
|
||||
t.AddDate(0, 0, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(a.Excepts)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AlarmRepeated) GetNextOccurence(db *LevelDBStorage) *time.Time {
|
||||
if len(a.Excepts) == 0 {
|
||||
a.FillExcepts(db)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), time.Time(*a.StartTime).Hour(), time.Time(*a.StartTime).Minute(), time.Time(*a.StartTime).Second(), 0, now.Location())
|
||||
if now.After(today) {
|
||||
today = today.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
end := today.AddDate(0, 0, 7)
|
||||
var nextOccurence time.Time
|
||||
for nextOccurence = today; end.After(nextOccurence); nextOccurence = nextOccurence.AddDate(0, 0, 1) {
|
||||
if nextOccurence.Weekday() == a.Weekday {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, except := range a.Excepts {
|
||||
if except.Equal(nextOccurence) {
|
||||
nextOccurence = nextOccurence.AddDate(0, 0, 7)
|
||||
}
|
||||
}
|
||||
|
||||
return &nextOccurence
|
||||
}
|
||||
|
||||
func GetAlarmRepeated(db *LevelDBStorage, id Identifier) (alarm *AlarmRepeated, err error) {
|
||||
alarm = &AlarmRepeated{}
|
||||
err = db.get(fmt.Sprintf("alarm-repeated-%s", id.ToString()), alarm)
|
||||
return
|
||||
}
|
||||
|
||||
func GetAlarmsRepeated(db *LevelDBStorage) (alarms []*AlarmRepeated, err error) {
|
||||
iter := db.search("alarm-repeated-")
|
||||
defer iter.Release()
|
||||
|
||||
for iter.Next() {
|
||||
var a AlarmRepeated
|
||||
|
||||
err = decodeData(iter.Value(), &a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
alarms = append(alarms, &a)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func PutAlarmRepeated(db *LevelDBStorage, alarm *AlarmRepeated) (err error) {
|
||||
var key string
|
||||
var id Identifier
|
||||
|
||||
if alarm.Id.IsEmpty() {
|
||||
key, id, err = db.findBytesKey("alarm-repeated-", IDENTIFIER_LEN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
alarm.Id = id
|
||||
// Don't store this, this is autocalculated
|
||||
alarm.Excepts = nil
|
||||
alarm.NextTime = nil
|
||||
|
||||
return db.put(key, alarm)
|
||||
}
|
||||
|
||||
func DeleteAlarmRepeated(db *LevelDBStorage, alarm *AlarmRepeated) (err error) {
|
||||
return db.delete(fmt.Sprintf("alarm-repeated-%s", alarm.Id.ToString()))
|
||||
}
|
||||
|
||||
type AlarmSingle struct {
|
||||
Id Identifier `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
FollowingRoutines []Identifier `json:"routines"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
func GetAlarmSingle(db *LevelDBStorage, id Identifier) (alarm *AlarmSingle, err error) {
|
||||
alarm = &AlarmSingle{}
|
||||
err = db.get(fmt.Sprintf("alarm-single-%s", id.ToString()), alarm)
|
||||
return
|
||||
}
|
||||
|
||||
func GetAlarmsSingle(db *LevelDBStorage) (alarms []*AlarmSingle, err error) {
|
||||
iter := db.search("alarm-single-")
|
||||
defer iter.Release()
|
||||
|
||||
for iter.Next() {
|
||||
var a AlarmSingle
|
||||
|
||||
err = decodeData(iter.Value(), &a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
alarms = append(alarms, &a)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func PutAlarmSingle(db *LevelDBStorage, alarm *AlarmSingle) (err error) {
|
||||
var key string
|
||||
var id Identifier
|
||||
|
||||
if alarm.Id.IsEmpty() {
|
||||
key, id, err = db.findBytesKey("alarm-single-", IDENTIFIER_LEN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
alarm.Id = id
|
||||
|
||||
return db.put(key, alarm)
|
||||
}
|
||||
|
||||
func DeleteAlarmSingle(db *LevelDBStorage, alarm *AlarmSingle) (err error) {
|
||||
return db.delete(fmt.Sprintf("alarm-single-%s", alarm.Id.ToString()))
|
||||
}
|
||||
|
||||
func RemoveOldAlarmsSingle(db *LevelDBStorage) error {
|
||||
alarms, err := GetAlarmsSingle(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, alarm := range alarms {
|
||||
if now.After(time.Time(alarm.Time)) {
|
||||
err = DeleteAlarmSingle(db, alarm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type AlarmException struct {
|
||||
Id Identifier `json:"id"`
|
||||
Start *Date `json:"start"`
|
||||
End *Date `json:"end"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
func GetAlarmException(db *LevelDBStorage, id Identifier) (alarm *AlarmException, err error) {
|
||||
alarm = &AlarmException{}
|
||||
err = db.get(fmt.Sprintf("alarm-exception-%s", id.ToString()), alarm)
|
||||
return
|
||||
}
|
||||
|
||||
func GetAlarmExceptions(db *LevelDBStorage) (alarms []*AlarmException, err error) {
|
||||
iter := db.search("alarm-exception-")
|
||||
defer iter.Release()
|
||||
|
||||
for iter.Next() {
|
||||
var a AlarmException
|
||||
|
||||
err = decodeData(iter.Value(), &a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
alarms = append(alarms, &a)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func PutAlarmException(db *LevelDBStorage, alarm *AlarmException) (err error) {
|
||||
var key string
|
||||
var id Identifier
|
||||
|
||||
if alarm.Id.IsEmpty() {
|
||||
key, id, err = db.findBytesKey("alarm-exception-", IDENTIFIER_LEN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
alarm.Id = id
|
||||
|
||||
return db.put(key, alarm)
|
||||
}
|
||||
|
||||
func DeleteAlarmException(db *LevelDBStorage, alarm *AlarmException) (err error) {
|
||||
return db.delete(fmt.Sprintf("alarm-exception-%s", alarm.Id.ToString()))
|
||||
}
|
||||
90
model/database.go
Normal file
90
model/database.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package reveil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
|
||||
"github.com/syndtr/goleveldb/leveldb"
|
||||
"github.com/syndtr/goleveldb/leveldb/errors"
|
||||
"github.com/syndtr/goleveldb/leveldb/iterator"
|
||||
"github.com/syndtr/goleveldb/leveldb/util"
|
||||
)
|
||||
|
||||
type LevelDBStorage struct {
|
||||
db *leveldb.DB
|
||||
}
|
||||
|
||||
// NewMySQLStorage establishes the connection to the database
|
||||
func NewLevelDBStorage(path string) (s *LevelDBStorage, err error) {
|
||||
var db *leveldb.DB
|
||||
|
||||
db, err = leveldb.OpenFile(path, nil)
|
||||
if err != nil {
|
||||
if _, ok := err.(*errors.ErrCorrupted); ok {
|
||||
log.Println("LevelDB was corrupted; attempting recovery (%s)", err.Error())
|
||||
_, err = leveldb.RecoverFile(path, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Println("LevelDB recovery succeeded!")
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s = &LevelDBStorage{db}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *LevelDBStorage) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func decodeData(data []byte, v interface{}) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func (s *LevelDBStorage) get(key string, v interface{}) error {
|
||||
data, err := s.db.Get([]byte(key), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return decodeData(data, v)
|
||||
}
|
||||
|
||||
func (s *LevelDBStorage) put(key string, v interface{}) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Put([]byte(key), data, nil)
|
||||
}
|
||||
|
||||
func (s *LevelDBStorage) findBytesKey(prefix string, len int) (key string, id Identifier, err error) {
|
||||
id = make([]byte, len)
|
||||
found := true
|
||||
for found {
|
||||
if _, err = rand.Read(id); err != nil {
|
||||
return
|
||||
}
|
||||
key = fmt.Sprintf("%s%s", prefix, id.ToString())
|
||||
|
||||
found, err = s.db.Has([]byte(key), nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *LevelDBStorage) delete(key string) error {
|
||||
return s.db.Delete([]byte(key), nil)
|
||||
}
|
||||
|
||||
func (s *LevelDBStorage) search(prefix string) iterator.Iterator {
|
||||
return s.db.NewIterator(util.BytesPrefix([]byte(prefix)), nil)
|
||||
}
|
||||
130
model/gong.go
Normal file
130
model/gong.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package reveil
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
)
|
||||
|
||||
const CURRENT_GONG = "_current_gong"
|
||||
|
||||
type Gong struct {
|
||||
Id Identifier `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func CurrentGongPath(cfg *config.Config) string {
|
||||
return filepath.Join(cfg.GongsDir, CURRENT_GONG)
|
||||
}
|
||||
|
||||
func LoadGong(cfg *config.Config, path string, d fs.FileInfo) (gong *Gong, err error) {
|
||||
// Retrieve the path of the current gong
|
||||
current_gong, err := os.Readlink(CurrentGongPath(cfg))
|
||||
if err == nil {
|
||||
current_gong, _ = filepath.Abs(filepath.Join(cfg.GongsDir, current_gong))
|
||||
}
|
||||
|
||||
hash := sha512.Sum512([]byte(path))
|
||||
pabs, _ := filepath.Abs(path)
|
||||
return &Gong{
|
||||
Id: hash[:],
|
||||
Name: strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())),
|
||||
Path: path,
|
||||
Enabled: current_gong == pabs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func LoadGongs(cfg *config.Config) (gongs []*Gong, err error) {
|
||||
// Retrieve the path of the current gong
|
||||
current_gong, err := os.Readlink(CurrentGongPath(cfg))
|
||||
if err == nil {
|
||||
current_gong, _ = filepath.Abs(filepath.Join(cfg.GongsDir, current_gong))
|
||||
}
|
||||
|
||||
// Retrieve the list
|
||||
err = filepath.Walk(cfg.GongsDir, func(path string, d fs.FileInfo, err error) error {
|
||||
if d.Mode().IsRegular() {
|
||||
hash := sha512.Sum512([]byte(path))
|
||||
pabs, _ := filepath.Abs(path)
|
||||
gongs = append(gongs, &Gong{
|
||||
Id: hash[:],
|
||||
Name: strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())),
|
||||
Path: path,
|
||||
Enabled: current_gong == pabs,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (g *Gong) Open() (*os.File, error) {
|
||||
return os.Open(g.Path)
|
||||
}
|
||||
|
||||
func (g *Gong) Size() (int64, error) {
|
||||
if st, err := os.Stat(g.Path); err != nil {
|
||||
return 0, err
|
||||
} else {
|
||||
return st.Size(), err
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gong) ContentType() string {
|
||||
switch filepath.Ext(g.Path) {
|
||||
case ".flac":
|
||||
return "audio/flac"
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".ogg":
|
||||
return "audio/ogg"
|
||||
case ".wav":
|
||||
return "audio/vnd.wav"
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func (g *Gong) Rename(newName string) error {
|
||||
newPath := filepath.Join(filepath.Dir(g.Path), newName+filepath.Ext(g.Path))
|
||||
|
||||
err := os.Rename(
|
||||
g.Path,
|
||||
newPath,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
g.Path = newPath
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gong) SetDefault(cfg *config.Config) error {
|
||||
linkpath := CurrentGongPath(cfg)
|
||||
os.Remove(linkpath)
|
||||
|
||||
pabs, err := filepath.Abs(g.Path)
|
||||
if err != nil {
|
||||
pabs = g.Path
|
||||
}
|
||||
|
||||
gdirabs, err := filepath.Abs(cfg.GongsDir)
|
||||
if err != nil {
|
||||
gdirabs = cfg.GongsDir
|
||||
}
|
||||
|
||||
return os.Symlink(strings.TrimPrefix(strings.TrimPrefix(pabs, gdirabs), "/"), linkpath)
|
||||
}
|
||||
|
||||
func (g *Gong) Remove() error {
|
||||
return os.Remove(g.Path)
|
||||
}
|
||||
41
model/identifier.go
Normal file
41
model/identifier.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package reveil
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const IDENTIFIER_LEN = 48
|
||||
|
||||
type Identifier []byte
|
||||
|
||||
func NewIdentifierFromString(src string) (id Identifier, err error) {
|
||||
return base64.RawURLEncoding.DecodeString(src)
|
||||
}
|
||||
|
||||
func (i *Identifier) IsEmpty() bool {
|
||||
return len(*i) == 0
|
||||
}
|
||||
|
||||
func (i *Identifier) ToString() string {
|
||||
return base64.RawURLEncoding.EncodeToString(*i)
|
||||
}
|
||||
|
||||
func (i Identifier) MarshalJSON() (dst []byte, err error) {
|
||||
dst = make([]byte, base64.RawURLEncoding.EncodedLen(len(i)))
|
||||
base64.RawURLEncoding.Encode(dst, i)
|
||||
dst = append([]byte{'"'}, dst...)
|
||||
dst = append(dst, '"')
|
||||
return
|
||||
}
|
||||
|
||||
func (i *Identifier) UnmarshalJSON(src []byte) error {
|
||||
if len(src) < 2 || src[0] != '"' || src[len(src)-1] != '"' {
|
||||
return errors.New("Unvalid character found to encapsulate the JSON value")
|
||||
}
|
||||
|
||||
*i = make([]byte, base64.RawURLEncoding.DecodedLen(len(src)-2))
|
||||
_, err := base64.RawURLEncoding.Decode(*i, src[1:len(src)-1])
|
||||
|
||||
return err
|
||||
}
|
||||
119
model/routine.go
Normal file
119
model/routine.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package reveil
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
)
|
||||
|
||||
type RoutineStep struct {
|
||||
Delay uint64 `json:"delay"`
|
||||
Action string `json:"action"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
type Routine struct {
|
||||
Id Identifier `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Steps []RoutineStep `json:"steps"`
|
||||
}
|
||||
|
||||
func LoadRoutine(path string, cfg *config.Config) ([]RoutineStep, error) {
|
||||
ds, err := ioutil.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actionsDir, err := filepath.Abs(cfg.ActionsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var steps []RoutineStep
|
||||
|
||||
for _, f := range ds {
|
||||
fullpath := filepath.Join(path, f.Name())
|
||||
if f.Mode()&os.ModeSymlink == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dst, err := os.Readlink(fullpath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if adst, err := filepath.Abs(filepath.Join(path, dst)); err == nil {
|
||||
dst = adst
|
||||
}
|
||||
|
||||
args := strings.Split(f.Name(), "_")
|
||||
|
||||
delay, err := strconv.ParseUint(args[0], 10, 64)
|
||||
|
||||
step := RoutineStep{
|
||||
Delay: delay,
|
||||
Action: strings.TrimPrefix(dst, actionsDir+"/"),
|
||||
}
|
||||
if len(args) > 1 {
|
||||
step.Args = args[1:]
|
||||
}
|
||||
|
||||
steps = append(steps, step)
|
||||
}
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
func LoadRoutines(cfg *config.Config) (routines []*Routine, err error) {
|
||||
err = filepath.Walk(cfg.RoutinesDir, func(path string, d fs.FileInfo, err error) error {
|
||||
if d.IsDir() && path != cfg.RoutinesDir {
|
||||
hash := sha512.Sum512([]byte(path))
|
||||
|
||||
// Explore directory
|
||||
steps, err := LoadRoutine(path, cfg)
|
||||
if err != nil {
|
||||
log.Printf("Invalid routine directory (trying to walk through %s): %s", path, err.Error())
|
||||
// Ignore invalid routines
|
||||
return nil
|
||||
}
|
||||
|
||||
routines = append(routines, &Routine{
|
||||
Id: hash[:],
|
||||
Name: d.Name(),
|
||||
Path: path,
|
||||
Steps: steps,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (r *Routine) Rename(newName string) error {
|
||||
newPath := filepath.Join(filepath.Dir(r.Path), newName)
|
||||
|
||||
err := os.Rename(
|
||||
r.Path,
|
||||
newPath,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.Path = newPath
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Routine) Remove() error {
|
||||
return os.Remove(a.Path)
|
||||
}
|
||||
55
model/settings.go
Normal file
55
model/settings.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package reveil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Settings represents the settings panel.
|
||||
type Settings struct {
|
||||
Language string `json:"language"`
|
||||
GongInterval time.Duration `json:"gong_interval"`
|
||||
WeatherDelay time.Duration `json:"weather_delay"`
|
||||
WeatherAction string `json:"weather_action"`
|
||||
MaxRunTime time.Duration `json:"max_run_time"`
|
||||
}
|
||||
|
||||
// ExistsSettings checks if the settings file can by found at the given path.
|
||||
func ExistsSettings(settingsPath string) bool {
|
||||
_, err := os.Stat(settingsPath)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
// ReadSettings parses the file at the given location.
|
||||
func ReadSettings(path string) (*Settings, error) {
|
||||
var s Settings
|
||||
if fd, err := os.Open(path); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
defer fd.Close()
|
||||
jdec := json.NewDecoder(fd)
|
||||
|
||||
if err := jdec.Decode(&s); err != nil {
|
||||
return &s, err
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SaveSettings saves settings at the given location.
|
||||
func SaveSettings(path string, s interface{}) error {
|
||||
if fd, err := os.Create(path); err != nil {
|
||||
return err
|
||||
} else {
|
||||
defer fd.Close()
|
||||
jenc := json.NewEncoder(fd)
|
||||
|
||||
if err := jenc.Encode(s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
133
model/track.go
Normal file
133
model/track.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package reveil
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
)
|
||||
|
||||
type Track struct {
|
||||
Id Identifier `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func LoadTrack(path string, d fs.FileInfo) (track *Track, err error) {
|
||||
hash := sha512.Sum512([]byte(path))
|
||||
return &Track{
|
||||
Id: hash[:],
|
||||
Name: strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())),
|
||||
Path: path,
|
||||
Enabled: len(strings.Split(path, "/")) == 2,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func LoadTracks(cfg *config.Config) (tracks []*Track, err error) {
|
||||
err = filepath.Walk(cfg.TracksDir, func(path string, d fs.FileInfo, err error) error {
|
||||
if d.Mode().IsRegular() {
|
||||
hash := sha512.Sum512([]byte(path))
|
||||
tracks = append(tracks, &Track{
|
||||
Id: hash[:],
|
||||
Name: strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())),
|
||||
Path: path,
|
||||
Enabled: len(strings.Split(path, "/")) == 2,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (t *Track) Open() (*os.File, error) {
|
||||
return os.Open(t.Path)
|
||||
}
|
||||
|
||||
func (t *Track) Size() (int64, error) {
|
||||
if st, err := os.Stat(t.Path); err != nil {
|
||||
return 0, err
|
||||
} else {
|
||||
return st.Size(), err
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Track) ContentType() string {
|
||||
switch filepath.Ext(t.Path) {
|
||||
case ".flac":
|
||||
return "audio/flac"
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".ogg":
|
||||
return "audio/ogg"
|
||||
case ".wav":
|
||||
return "audio/vnd.wav"
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func (t *Track) Rename(newName string) error {
|
||||
newPath := filepath.Join(filepath.Dir(t.Path), newName+filepath.Ext(t.Path))
|
||||
|
||||
err := os.Rename(
|
||||
t.Path,
|
||||
newPath,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Path = newPath
|
||||
|
||||
// Recalculate hash
|
||||
hash := sha512.Sum512([]byte(t.Path))
|
||||
t.Id = hash[:]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Track) MoveTo(path string) error {
|
||||
os.Mkdir(filepath.Dir(path), 0755)
|
||||
|
||||
err := os.Rename(
|
||||
t.Path,
|
||||
path,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Path = path
|
||||
|
||||
// Recalculate hash
|
||||
hash := sha512.Sum512([]byte(t.Path))
|
||||
t.Id = hash[:]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Track) Disable() error {
|
||||
if t.Enabled {
|
||||
date := time.Now()
|
||||
return t.MoveTo(filepath.Join(filepath.Dir(t.Path), date.Format("20060102"), filepath.Base(t.Path)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Track) Enable(cfg *config.Config) error {
|
||||
if !t.Enabled {
|
||||
return t.MoveTo(filepath.Join(cfg.TracksDir, filepath.Base(t.Path)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Track) Remove() error {
|
||||
return os.Remove(t.Path)
|
||||
}
|
||||
256
player/player.go
Normal file
256
player/player.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
package player
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/faiface/beep"
|
||||
"github.com/faiface/beep/effects"
|
||||
"github.com/faiface/beep/flac"
|
||||
"github.com/faiface/beep/mp3"
|
||||
"github.com/faiface/beep/speaker"
|
||||
"github.com/faiface/beep/wav"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
"git.nemunai.re/nemunaire/reveil/model"
|
||||
)
|
||||
|
||||
var CommonPlayer *Player
|
||||
|
||||
type Player struct {
|
||||
Playlist []string
|
||||
MaxRunTime time.Duration
|
||||
Stopper chan bool
|
||||
|
||||
sampleRate beep.SampleRate
|
||||
|
||||
claironTime time.Duration
|
||||
claironFile string
|
||||
|
||||
ntick int64
|
||||
hasClaironed bool
|
||||
launched time.Time
|
||||
volume *effects.Volume
|
||||
dontUpdateVolume bool
|
||||
reverseOrder bool
|
||||
playedItem int
|
||||
}
|
||||
|
||||
func WakeUp(cfg *config.Config) (err error) {
|
||||
if CommonPlayer != nil {
|
||||
return fmt.Errorf("Unable to start the player: a player is already running")
|
||||
}
|
||||
|
||||
CommonPlayer, err = NewPlayer(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go CommonPlayer.WakeUp()
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPlayer(cfg *config.Config) (*Player, error) {
|
||||
// Load our settings
|
||||
settings, err := reveil.ReadSettings(cfg.SettingsFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Unable to read settings: %w", err)
|
||||
}
|
||||
|
||||
p := Player{
|
||||
Stopper: make(chan bool, 1),
|
||||
MaxRunTime: settings.MaxRunTime * time.Minute,
|
||||
sampleRate: beep.SampleRate(cfg.SampleRate),
|
||||
claironTime: settings.GongInterval * time.Minute,
|
||||
claironFile: reveil.CurrentGongPath(cfg),
|
||||
}
|
||||
|
||||
// Load our track list
|
||||
tracks, err := reveil.LoadTracks(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Unable to load tracks: %w", err)
|
||||
}
|
||||
|
||||
var playlist []string
|
||||
|
||||
// Creating playlist
|
||||
log.Println("Loading playlist...")
|
||||
for _, track := range tracks {
|
||||
if !track.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
p.Playlist = append(p.Playlist, track.Path)
|
||||
}
|
||||
|
||||
log.Println("Shuffling playlist...")
|
||||
// Shuffle the playlist
|
||||
rand.Shuffle(len(playlist), func(i, j int) {
|
||||
playlist[i], playlist[j] = playlist[j], playlist[i]
|
||||
})
|
||||
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func loadFile(filepath string) (name string, s beep.StreamSeekCloser, format beep.Format, err error) {
|
||||
var fd *os.File
|
||||
|
||||
name = path.Base(filepath)
|
||||
|
||||
fd, err = os.Open(filepath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(path.Ext(filepath)) {
|
||||
case ".flac":
|
||||
s, format, err = flac.Decode(fd)
|
||||
case ".mp3":
|
||||
s, format, err = mp3.Decode(fd)
|
||||
default:
|
||||
s, format, err = wav.Decode(fd)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fd.Close()
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Player) WakeUp() {
|
||||
log.Println("RUN WAKEUP FUNC")
|
||||
|
||||
log.Println("Playlist in use:", strings.Join(p.Playlist, " ; "))
|
||||
|
||||
// Create infinite stream
|
||||
stream := beep.Iterate(func() beep.Streamer {
|
||||
if !p.hasClaironed && time.Since(p.launched) >= p.claironTime {
|
||||
log.Println("clairon time!")
|
||||
p.claironTime += p.claironTime / 2
|
||||
_, sample, format, err := loadFile(p.claironFile)
|
||||
if err == nil {
|
||||
p.volume.Volume = 0.1
|
||||
p.dontUpdateVolume = true
|
||||
if format.SampleRate != p.sampleRate {
|
||||
return beep.Resample(3, format.SampleRate, p.sampleRate, sample)
|
||||
} else {
|
||||
return sample
|
||||
}
|
||||
} else {
|
||||
log.Println("Error loading clairon:", err)
|
||||
}
|
||||
}
|
||||
|
||||
p.dontUpdateVolume = false
|
||||
p.volume.Volume = -2 - math.Log(5/float64(p.ntick))/3
|
||||
|
||||
if p.reverseOrder {
|
||||
p.playedItem -= 1
|
||||
} else {
|
||||
p.playedItem += 1
|
||||
}
|
||||
|
||||
if p.playedItem >= len(p.Playlist) {
|
||||
p.playedItem = 0
|
||||
} else if p.playedItem < 0 {
|
||||
p.playedItem = len(p.Playlist) - 1
|
||||
}
|
||||
|
||||
// Load our current item
|
||||
_, sample, format, err := loadFile(p.Playlist[p.playedItem])
|
||||
if err != nil {
|
||||
log.Println("Error loading audio file %s: %s", p.Playlist[p.playedItem], err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resample if needed
|
||||
log.Println("playing list item:", p.playedItem, "/", len(p.Playlist), ":", p.Playlist[p.playedItem])
|
||||
if format.SampleRate != p.sampleRate {
|
||||
return beep.Resample(3, format.SampleRate, p.sampleRate, sample)
|
||||
} else {
|
||||
return sample
|
||||
}
|
||||
})
|
||||
|
||||
// Prepare sound player
|
||||
log.Println("Initializing sound player...")
|
||||
speaker.Init(p.sampleRate, p.sampleRate.N(time.Second/10))
|
||||
defer speaker.Close()
|
||||
|
||||
p.volume = &effects.Volume{stream, 10, -2, false}
|
||||
speaker.Play(p.volume)
|
||||
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
p.launched = time.Now()
|
||||
|
||||
// Prepare graceful shutdown
|
||||
maxRun := time.After(p.MaxRunTime)
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt, syscall.SIGHUP)
|
||||
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-p.Stopper:
|
||||
log.Println("Stopper activated")
|
||||
break loop
|
||||
case <-maxRun:
|
||||
log.Println("Max run time exhausted")
|
||||
break loop
|
||||
case <-ticker.C:
|
||||
p.ntick += 1
|
||||
if !p.dontUpdateVolume {
|
||||
p.volume.Volume = -2 - math.Log(5/float64(p.ntick))/3
|
||||
}
|
||||
case <-interrupt:
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Stopping the player...")
|
||||
|
||||
// Calm down music
|
||||
loopcalm:
|
||||
for i := 0; i < 2000; i += 1 {
|
||||
p.volume.Volume -= 0.001
|
||||
|
||||
timer := time.NewTimer(4 * time.Millisecond)
|
||||
select {
|
||||
case <-p.Stopper:
|
||||
log.Println("Hard stop received...")
|
||||
timer.Stop()
|
||||
p.volume.Volume = 0
|
||||
break loopcalm
|
||||
case <-timer.C:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if p == CommonPlayer {
|
||||
log.Println("Destoying common player")
|
||||
|
||||
CommonPlayer = nil
|
||||
|
||||
// TODO: find a better way to deallocate the card
|
||||
os.Exit(42)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Player) Stop() error {
|
||||
log.Println("Trying to stop the player")
|
||||
p.Stopper <- true
|
||||
|
||||
return nil
|
||||
}
|
||||
10
renovate.json
Normal file
10
renovate.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["alpine", "github.com/gin-gonic/gin"],
|
||||
"automerge": true,
|
||||
"automergeType": "branch"
|
||||
}
|
||||
]
|
||||
}
|
||||
20
ui/.eslintrc.cjs
Normal file
20
ui/.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['svelte3', '@typescript-eslint'],
|
||||
ignorePatterns: ['*.cjs'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
}
|
||||
};
|
||||
8
ui/.gitignore
vendored
Normal file
8
ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
1
ui/.npmrc
Normal file
1
ui/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
6
ui/.prettierrc
Normal file
6
ui/.prettierrc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
38
ui/README.md
Normal file
38
ui/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# create-svelte
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte);
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm init svelte@next
|
||||
|
||||
# create a new project in my-app
|
||||
npm init svelte@next my-app
|
||||
```
|
||||
|
||||
> Note: the `@next` is temporary
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Before creating a production version of your app, install an [adapter](https://kit.svelte.dev/docs#adapters) for your target environment. Then:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
> You can preview the built app with `npm run preview`, regardless of whether you installed an adapter. This should _not_ be used to serve your app in production.
|
||||
32
ui/assets-dev.go
Normal file
32
ui/assets-dev.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//go:build dev
|
||||
// +build dev
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var (
|
||||
Assets http.FileSystem
|
||||
StaticDir string = "ui/"
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&StaticDir, "static", StaticDir, "Directory containing static files")
|
||||
}
|
||||
|
||||
func sanitizeStaticOptions() error {
|
||||
StaticDir, _ = filepath.Abs(StaticDir)
|
||||
if _, err := os.Stat(StaticDir); os.IsNotExist(err) {
|
||||
StaticDir, _ = filepath.Abs(filepath.Join(filepath.Dir(os.Args[0]), "ui"))
|
||||
if _, err := os.Stat(StaticDir); os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
Assets = http.Dir(StaticDir)
|
||||
return nil
|
||||
}
|
||||
28
ui/assets.go
Normal file
28
ui/assets.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
//go:build !dev
|
||||
// +build !dev
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed all:build
|
||||
var _assets embed.FS
|
||||
|
||||
var Assets http.FileSystem
|
||||
|
||||
func init() {
|
||||
sub, err := fs.Sub(_assets, "build")
|
||||
if err != nil {
|
||||
log.Fatal("Unable to cd to ui/build directory:", err)
|
||||
}
|
||||
Assets = http.FS(sub)
|
||||
}
|
||||
|
||||
func sanitizeStaticOptions() error {
|
||||
return nil
|
||||
}
|
||||
42
ui/package.json
Normal file
42
ui/package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "reveil",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"package": "vite package",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
|
||||
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^1.0.0-next.18",
|
||||
"@sveltejs/adapter-static": "^1.0.0-next.26",
|
||||
"@sveltejs/kit": "^1.0.0-next.260",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"bootstrap-icons": "^1.8.0",
|
||||
"bootswatch": "^5.1.3",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.4.1",
|
||||
"prettier-plugin-svelte": "^2.6.0",
|
||||
"svelte": "^3.46.4",
|
||||
"svelte-check": "^2.4.2",
|
||||
"svelte-preprocess": "^4.10.2",
|
||||
"tslib": "^2.3.1",
|
||||
"typescript": "^4.5.5"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.5",
|
||||
"sass": "^1.49.7",
|
||||
"sass-loader": "^13.0.0",
|
||||
"sveltestrap": "^5.8.3",
|
||||
"vite": "^3.0.0"
|
||||
}
|
||||
}
|
||||
82
ui/routes.go
Normal file
82
ui/routes.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.nemunai.re/nemunaire/reveil/config"
|
||||
)
|
||||
|
||||
func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
|
||||
if cfg.DevProxy != "" {
|
||||
// Forward to the Vue dev proxy
|
||||
return func(c *gin.Context) {
|
||||
if u, err := url.Parse(cfg.DevProxy); err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
|
||||
} else {
|
||||
if forced_url != "" {
|
||||
u.Path = path.Join(u.Path, forced_url)
|
||||
} else {
|
||||
u.Path = path.Join(u.Path, c.Request.URL.Path)
|
||||
}
|
||||
|
||||
if r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body); err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
|
||||
} else if resp, err := http.DefaultClient.Do(r); err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
|
||||
for key := range resp.Header {
|
||||
c.Writer.Header().Add(key, resp.Header.Get(key))
|
||||
}
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if forced_url != "" {
|
||||
// Serve forced_url
|
||||
return func(c *gin.Context) {
|
||||
c.FileFromFS(forced_url, Assets)
|
||||
}
|
||||
} else {
|
||||
// Serve requested file
|
||||
return func(c *gin.Context) {
|
||||
c.FileFromFS(c.Request.URL.Path, Assets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func DeclareRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
if cfg.DevProxy != "" {
|
||||
router.GET("/.svelte-kit/*_", serveOrReverse("", cfg))
|
||||
router.GET("/node_modules/*_", serveOrReverse("", cfg))
|
||||
router.GET("/@vite/*_", serveOrReverse("", cfg))
|
||||
router.GET("/@fs/*_", serveOrReverse("", cfg))
|
||||
router.GET("/src/*_", serveOrReverse("", cfg))
|
||||
}
|
||||
|
||||
router.GET("/", serveOrReverse("", cfg))
|
||||
router.GET("/alarms", serveOrReverse("/", cfg))
|
||||
router.GET("/alarms/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/settings", serveOrReverse("/", cfg))
|
||||
router.GET("/settings/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/routines", serveOrReverse("/", cfg))
|
||||
router.GET("/routines/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/musiks", serveOrReverse("/", cfg))
|
||||
router.GET("/musiks/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/history", serveOrReverse("/", cfg))
|
||||
router.GET("/history/*_", serveOrReverse("/", cfg))
|
||||
|
||||
router.GET("/_app/*_", serveOrReverse("", cfg))
|
||||
router.GET("/img/*_", serveOrReverse("", cfg))
|
||||
router.GET("/favicon.ico", serveOrReverse("", cfg))
|
||||
router.GET("/manifest.json", serveOrReverse("", cfg))
|
||||
router.GET("/service-worker.js", serveOrReverse("", cfg))
|
||||
}
|
||||
19
ui/src/app.html
Normal file
19
ui/src/app.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr" class="d-flex flex-column mh-100 h-100">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#ffffff"/>
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/img/apple-touch-icon.png">
|
||||
<meta name="author" content="nemucorp">
|
||||
<meta name="robots" content="none">
|
||||
<base href="/">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body class="flex-fill d-flex flex-column">
|
||||
<div class="flex-fill d-flex flex-column justify-content-between" style="min-height: 100%">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
1
ui/src/global.d.ts
vendored
Normal file
1
ui/src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="@sveltejs/kit" />
|
||||
66
ui/src/lib/action.js
Normal file
66
ui/src/lib/action.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
export class Action {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
this.update(res);
|
||||
}
|
||||
}
|
||||
|
||||
update({ id, name, description, path, enabled }) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.path = path;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const res = await fetch(`api/actions/${this.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
toggleEnable() {
|
||||
this.enabled = !this.enabled;
|
||||
this.save();
|
||||
return this;
|
||||
}
|
||||
|
||||
async save() {
|
||||
const res = await fetch(this.id?`api/actions/${this.id}`:'api/actions', {
|
||||
method: this.id?'PUT':'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
this.update(data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActions() {
|
||||
const res = await fetch(`api/actions`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return (await res.json()).map((t) => new Action(t));
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAction(aid) {
|
||||
const res = await fetch(`api/actions/${aid}`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return new Action(await res.json());
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
46
ui/src/lib/alarm.js
Normal file
46
ui/src/lib/alarm.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export async function isAlarmActive() {
|
||||
const res = await fetch('api/alarm', {
|
||||
headers: {'Accept': 'application/json'},
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return await res.json();
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAlarm() {
|
||||
const res = await fetch('api/alarm/run', {
|
||||
method: 'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return await res.json();
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function alarmNextTrack() {
|
||||
const res = await fetch('api/alarm/next', {
|
||||
method: 'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return await res.json();
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function alarmStop() {
|
||||
const res = await fetch('api/alarm', {
|
||||
method: 'DELETE',
|
||||
headers: {'Accept': 'application/json'},
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return await res.json();
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
71
ui/src/lib/alarmexception.js
Normal file
71
ui/src/lib/alarmexception.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export class AlarmException {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
this.update(res);
|
||||
}
|
||||
}
|
||||
|
||||
update({ id, start, end, comment }) {
|
||||
this.id = id;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
_start() {
|
||||
return new Date(this.start);
|
||||
}
|
||||
|
||||
_end() {
|
||||
return new Date(this.end);
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const res = await fetch(`api/alarms/exceptions/${this.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
const res = await fetch(this.id?`api/alarms/exceptions/${this.id}`:'api/alarms/exceptions', {
|
||||
method: this.id?'PUT':'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
this.update(data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlarmsException() {
|
||||
const res = await fetch(`api/alarms/exceptions`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
if (data === null)
|
||||
return [];
|
||||
else
|
||||
return data.map((t) => new AlarmException(t));
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlarmException(aid) {
|
||||
const res = await fetch(`api/alarms/exceptions/${aid}`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return new AlarmException(await res.json());
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
93
ui/src/lib/alarmrepeated.js
Normal file
93
ui/src/lib/alarmrepeated.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
export class AlarmRepeated {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
this.update(res);
|
||||
}
|
||||
}
|
||||
|
||||
update({ id, weekday, time, routines, ignore_exceptions, comment, excepts, next_time }) {
|
||||
this.id = id;
|
||||
this.weekday = weekday;
|
||||
this.time = time;
|
||||
this.routines = routines;
|
||||
this.ignore_exceptions = ignore_exceptions;
|
||||
this.comment = comment;
|
||||
this.excepts = excepts;
|
||||
this.next_time = next_time;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const res = await fetch(`api/alarms/repeated/${this.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
const res = await fetch(this.id?`api/alarms/repeated/${this.id}`:'api/alarms/repeated', {
|
||||
method: this.id?'PUT':'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
this.update(data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlarmsRepeated() {
|
||||
const res = await fetch(`api/alarms/repeated`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
if (data === null)
|
||||
return [];
|
||||
else
|
||||
return data.map((t) => new AlarmRepeated(t));
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlarmRepeated(aid) {
|
||||
const res = await fetch(`api/alarms/repeated/${aid}`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return new AlarmRepeated(await res.json());
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export function weekdayStr(weekday) {
|
||||
switch (weekday) {
|
||||
case 0:
|
||||
case "0":
|
||||
return "dimanche";
|
||||
case 1:
|
||||
case "1":
|
||||
return "lundi";
|
||||
case 2:
|
||||
case "2":
|
||||
return "mardi";
|
||||
case 3:
|
||||
case "3":
|
||||
return "mercredi";
|
||||
case 4:
|
||||
case "4":
|
||||
return "jeudi";
|
||||
case 5:
|
||||
case "5":
|
||||
return "vendredi";
|
||||
case 6:
|
||||
case "6":
|
||||
return "samedi";
|
||||
}
|
||||
}
|
||||
92
ui/src/lib/alarmsingle.js
Normal file
92
ui/src/lib/alarmsingle.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
export class AlarmSingle {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
this.update(res);
|
||||
}
|
||||
}
|
||||
|
||||
update({ id, time, routines, comment }) {
|
||||
this.id = id;
|
||||
this.time = new Date(time);
|
||||
this.routines = routines;
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const res = await fetch(`api/alarms/single/${this.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
const res = await fetch(this.id?`api/alarms/single/${this.id}`:'api/alarms/single', {
|
||||
method: this.id?'PUT':'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
this.update(data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlarmsSingle() {
|
||||
const res = await fetch(`api/alarms/single`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
if (data === null)
|
||||
return [];
|
||||
else
|
||||
return data.map((t) => new AlarmSingle(t));
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlarmSingle(aid) {
|
||||
const res = await fetch(`api/alarms/single/${aid}`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return new AlarmSingle(await res.json());
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNextAlarm() {
|
||||
const res = await fetch(`api/alarms/next`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
if (data)
|
||||
return new Date(data);
|
||||
else
|
||||
return data;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function newNCyclesAlarm(nCycles) {
|
||||
const res = await fetch('api/alarms/single', {
|
||||
method: 'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
time: new Date(Date.now() + 600000 + 5400000 * nCycles)
|
||||
}),
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
return new AlarmSingle(data);
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
76
ui/src/lib/components/ActionList.svelte
Normal file
76
ui/src/lib/components/ActionList.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { actions } from '$lib/stores/actions';
|
||||
|
||||
export let flush = false;
|
||||
|
||||
export { className as class };
|
||||
let className = '';
|
||||
|
||||
let refreshInProgress = false;
|
||||
function refresh_actions() {
|
||||
refreshInProgress = true;
|
||||
actions.refresh().then(() => {
|
||||
refreshInProgress = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center" class:px-2={flush}>
|
||||
<h2>
|
||||
Actions
|
||||
</h2>
|
||||
<div>
|
||||
{#if !flush}
|
||||
<Button
|
||||
href="routines/actions"
|
||||
color="outline-info"
|
||||
size="sm"
|
||||
>
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
color="outline-dark"
|
||||
size="sm"
|
||||
title="Rafraîchir la liste des actions"
|
||||
on:click={refresh_actions}
|
||||
disabled={refreshInProgress}
|
||||
>
|
||||
{#if !refreshInProgress}
|
||||
<Icon name="arrow-clockwise" />
|
||||
{:else}
|
||||
<Spinner color="dark" size="sm" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group {className}" class:list-group-flush={flush}>
|
||||
{#if $actions.list}
|
||||
{#each $actions.list as action (action.id)}
|
||||
<a
|
||||
href="routines/actions/{action.id}"
|
||||
class="list-group-item list-group-item-action"
|
||||
class:active={$page.url.pathname.indexOf('/actions/') !== -1 && $page.params.aid == action.id}
|
||||
aria-current="true"
|
||||
>
|
||||
<span class:fw-bold={action.enabled}>{action.name}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{:else}
|
||||
{#await actions.refresh()}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{:then}
|
||||
test
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
53
ui/src/lib/components/AlarmExceptionList.svelte
Normal file
53
ui/src/lib/components/AlarmExceptionList.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import DateRangeFormat from '$lib/components/DateRangeFormat.svelte';
|
||||
import { alarmsExceptions } from '$lib/stores/alarmexceptions';
|
||||
|
||||
export let flush = false;
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center" class:mx-2={flush}>
|
||||
<h2>
|
||||
Exceptions
|
||||
</h2>
|
||||
<Button
|
||||
href="alarms/exceptions/new"
|
||||
color="outline-primary"
|
||||
size="sm"
|
||||
class="float-end {($page.params.kind === 'exceptions' && $page.url.pathname.endsWith('/new'))?'active':''}"
|
||||
>
|
||||
<Icon name="plus-lg" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{#if $alarmsExceptions.list !== null}
|
||||
{#if $alarmsExceptions.list.length}
|
||||
<div class="list-group" class:list-group-flush={flush}>
|
||||
{#each $alarmsExceptions.list as alarm (alarm.id)}
|
||||
<a
|
||||
href="alarms/exceptions/{alarm.id}"
|
||||
class="list-group-item list-group-item-action"
|
||||
class:active={$page.params.kind === "exceptions" && $page.params.aid === alarm.id}
|
||||
>
|
||||
Du <DateRangeFormat startDate={alarm._start()} endDate={alarm._end()} dateStyle="long" />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="fst-italic">Pas d'exception programmée</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{#await alarmsExceptions.refresh()}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
53
ui/src/lib/components/AlarmRepeatedList.svelte
Normal file
53
ui/src/lib/components/AlarmRepeatedList.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { weekdayStr } from '$lib/alarmrepeated';
|
||||
import { alarmsRepeated } from '$lib/stores/alarmrepeated';
|
||||
|
||||
export let flush = false;
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center" class:mx-2={flush}>
|
||||
<h2>
|
||||
Réveils habituels
|
||||
</h2>
|
||||
<Button
|
||||
href="alarms/repeated/new"
|
||||
color="outline-primary"
|
||||
size="sm"
|
||||
class="float-end {($page.params.kind === 'repeated' && $page.url.pathname.endsWith('/new'))?'active':''}"
|
||||
>
|
||||
<Icon name="plus-lg" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{#if $alarmsRepeated.list !== null}
|
||||
{#if $alarmsRepeated.list.length}
|
||||
<div class="list-group" class:list-group-flush={flush}>
|
||||
{#each $alarmsRepeated.list as alarm (alarm.id)}
|
||||
<a
|
||||
href="alarms/repeated/{alarm.id}"
|
||||
class="list-group-item list-group-item-action"
|
||||
class:active={$page.params.kind === "repeated" && $page.params.aid === alarm.id}
|
||||
>
|
||||
Les {weekdayStr(alarm.weekday)}s à {alarm.time}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="fst-italic">Pas de réveil habituel programmé</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{#await alarmsRepeated.refresh()}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
53
ui/src/lib/components/AlarmSingleList.svelte
Normal file
53
ui/src/lib/components/AlarmSingleList.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
import { alarmsSingle } from '$lib/stores/alarmsingle';
|
||||
|
||||
export let flush = false;
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center" class:mx-2={flush}>
|
||||
<h2>
|
||||
Réveils manuels
|
||||
</h2>
|
||||
<Button
|
||||
href="alarms/single/new"
|
||||
color="outline-primary"
|
||||
size="sm"
|
||||
class="float-end {($page.params.kind === 'single' && $page.url.pathname.endsWith('/new'))?'active':''}"
|
||||
>
|
||||
<Icon name="plus-lg" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{#if $alarmsSingle.list !== null}
|
||||
{#if $alarmsSingle.list.length}
|
||||
<div class="list-group" class:list-group-flush={flush}>
|
||||
{#each $alarmsSingle.list as alarm (alarm.id)}
|
||||
<a
|
||||
href="alarms/single/{alarm.id}"
|
||||
class="list-group-item list-group-item-action"
|
||||
class:active={$page.params.kind === "single" && $page.params.aid === alarm.id}
|
||||
>
|
||||
Le <DateFormat date={alarm.time} dateStyle="long" timeStyle="long" />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="fst-italic">Pas de prochain réveil manuel programmé</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{#await alarmsSingle.refresh()}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
62
ui/src/lib/components/CardRoutine.svelte
Normal file
62
ui/src/lib/components/CardRoutine.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<script>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Col,
|
||||
Container,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Row,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { actions_idx } from '$lib/stores/actions';
|
||||
|
||||
export let routine = {
|
||||
name: "Classique",
|
||||
steps: [],
|
||||
};
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Button
|
||||
color="outline-danger"
|
||||
size="sm"
|
||||
class="float-end ms-1"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
<Button
|
||||
color="outline-info"
|
||||
size="sm"
|
||||
class="float-end ms-1"
|
||||
>
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
{routine.name}
|
||||
</CardHeader>
|
||||
{#if routine.steps}
|
||||
<ListGroup>
|
||||
{#each routine.steps as step}
|
||||
<ListGroupItem action>
|
||||
{#if $actions_idx && $actions_idx[step.action]}
|
||||
{$actions_idx[step.action].name}
|
||||
{:else}
|
||||
{step.action}
|
||||
{/if}
|
||||
<Badge class="float-end">
|
||||
{step.delay/60} min
|
||||
</Badge>
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
{:else}
|
||||
<CardBody>
|
||||
Aucune action définie.
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
||||
27
ui/src/lib/components/CardStatAlarms.svelte
Normal file
27
ui/src/lib/components/CardStatAlarms.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
export let awakingList = [
|
||||
{
|
||||
id: 1,
|
||||
date: new Date("2022-10-01T09:15:00.000Z"),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
Liste des réveils
|
||||
</CardHeader>
|
||||
<ListGroup>
|
||||
{#each awakingList as awaking (awaking.id)}
|
||||
<ListGroupItem>{awaking.date}</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
</Card>
|
||||
34
ui/src/lib/components/CardStatRoutines.svelte
Normal file
34
ui/src/lib/components/CardStatRoutines.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script>
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardHeader,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
export let routinesStats = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Classique",
|
||||
nb: 10,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
Routines favorites
|
||||
</CardHeader>
|
||||
<ListGroup numbered>
|
||||
{#each routinesStats as routine (routine.id)}
|
||||
<ListGroupItem>
|
||||
{routine.name}
|
||||
<Badge color="primary" class="float-end">
|
||||
{routine.nb}
|
||||
</Badge>
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
</Card>
|
||||
19
ui/src/lib/components/CardStatTimeAwaking.svelte
Normal file
19
ui/src/lib/components/CardStatTimeAwaking.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script>
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
Temps moyen de éteindre le réveil
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<p class="card-text">
|
||||
10 minutes
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
35
ui/src/lib/components/CycleCounter.svelte
Normal file
35
ui/src/lib/components/CycleCounter.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script>
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
|
||||
export let begins = null;
|
||||
export let ends;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let interval;
|
||||
onMount(() => {
|
||||
if (!begins) {
|
||||
interval = setInterval(() => {
|
||||
begins = new Date();
|
||||
if (begins > ends) {
|
||||
dispatch("reload")
|
||||
}
|
||||
}, 15000);
|
||||
begins = new Date();
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
});
|
||||
|
||||
export { className as class };
|
||||
let className = 'text-muted';
|
||||
</script>
|
||||
|
||||
{#if begins && ends}
|
||||
<span class="{className}">
|
||||
(dans {Math.trunc((ends.getTime()-begins.getTime())/5400000)} cycles + {Math.trunc(((ends.getTime()-begins.getTime())%5400000)/60000)} min)
|
||||
</span>
|
||||
{/if}
|
||||
17
ui/src/lib/components/DateFormat.svelte
Normal file
17
ui/src/lib/components/DateFormat.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script>
|
||||
export let date;
|
||||
export let dateStyle;
|
||||
export let timeStyle;
|
||||
|
||||
function formatDate(input, dateStyle, timeStyle) {
|
||||
if (typeof input === 'string') {
|
||||
input = new Date(input);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle,
|
||||
timeStyle,
|
||||
}).format(input);
|
||||
}
|
||||
</script>
|
||||
|
||||
{formatDate(date, dateStyle, timeStyle)}
|
||||
18
ui/src/lib/components/DateRangeFormat.svelte
Normal file
18
ui/src/lib/components/DateRangeFormat.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script>
|
||||
export let startDate;
|
||||
export let endDate;
|
||||
export let dateStyle;
|
||||
export let timeStyle;
|
||||
|
||||
function formatRange(startDate, endDate, dateStyle, timeStyle) {
|
||||
if (typeof input === 'string') {
|
||||
input = new Date(input);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle,
|
||||
timeStyle,
|
||||
}).formatRange(startDate, endDate);
|
||||
}
|
||||
</script>
|
||||
|
||||
{formatRange(startDate, endDate, dateStyle, timeStyle)}
|
||||
31
ui/src/lib/components/DateTimeInput.svelte
Normal file
31
ui/src/lib/components/DateTimeInput.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script>
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
Input,
|
||||
} from 'sveltestrap';
|
||||
|
||||
export let format = 'YYYY-MM-DD HH:mm';
|
||||
export let date = new Date();
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
export let id = null;
|
||||
export let required = false;
|
||||
|
||||
let internal;
|
||||
|
||||
const input = (x) => (internal = dayjs(x).format(format));
|
||||
const output = (x) => {
|
||||
const d = dayjs(x, format).toDate();
|
||||
if (d) {
|
||||
date = d.toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
$: input(date)
|
||||
$: output(internal)
|
||||
</script>
|
||||
|
||||
<Input type="datetime-local" class={className} id={id} {required} bind:value={internal} />
|
||||
96
ui/src/lib/components/GongsList.svelte
Normal file
96
ui/src/lib/components/GongsList.svelte
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { gongs } from '$lib/stores/gongs';
|
||||
|
||||
function chooseGong(gong) {
|
||||
gong.setDefault().then(() => {
|
||||
gongs.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
export let flush = false;
|
||||
export let edit = false;
|
||||
|
||||
export { className as class };
|
||||
let className = '';
|
||||
|
||||
let refreshInProgress = false;
|
||||
function refresh_gongs() {
|
||||
refreshInProgress = true;
|
||||
gongs.refresh().then(() => {
|
||||
refreshInProgress = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center" class:px-2={flush}>
|
||||
<h2>
|
||||
Gongs
|
||||
</h2>
|
||||
<div>
|
||||
{#if !edit}
|
||||
<Button
|
||||
href="musiks/gongs"
|
||||
color="outline-info"
|
||||
size="sm"
|
||||
>
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
href="musiks/gongs/new"
|
||||
color="outline-primary"
|
||||
size="sm"
|
||||
>
|
||||
<Icon name="plus-lg" />
|
||||
</Button>
|
||||
<Button
|
||||
color="outline-dark"
|
||||
size="sm"
|
||||
title="Rafraîchir la liste des gongs"
|
||||
on:click={refresh_gongs}
|
||||
disabled={refreshInProgress}
|
||||
>
|
||||
{#if !refreshInProgress}
|
||||
<Icon name="arrow-clockwise" />
|
||||
{:else}
|
||||
<Spinner color="dark" size="sm" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group {className}" class:list-group-flush={flush}>
|
||||
{#if $gongs.list}
|
||||
{#each $gongs.list as gong (gong.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="list-group-item list-group-item-action"
|
||||
class:active={(edit && $page.url.pathname.indexOf('/gongs/') !== -1 && $page.params.gid == gong.id) || (!edit && gong.enabled)}
|
||||
aria-current="true"
|
||||
on:click={() => {
|
||||
if (edit) {
|
||||
goto('musiks/gongs/' + gong.id);
|
||||
} else {
|
||||
chooseGong(gong);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{gong.name}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
{#await gongs.refresh()}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
99
ui/src/lib/components/Header.svelte
Normal file
99
ui/src/lib/components/Header.svelte
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<script>
|
||||
import { page } from '$app/stores'
|
||||
|
||||
import {
|
||||
Icon,
|
||||
Navbar,
|
||||
NavbarBrand,
|
||||
Nav,
|
||||
NavItem,
|
||||
NavLink,
|
||||
} from 'sveltestrap';
|
||||
|
||||
const version = fetch('api/version', {headers: {'Accept': 'application/json'}}).then((res) => res.json())
|
||||
|
||||
export let activemenu = "";
|
||||
$: {
|
||||
const path = $page.url.pathname.split("/");
|
||||
if (path.length > 1) {
|
||||
activemenu = path[1];
|
||||
}
|
||||
}
|
||||
export { className as class };
|
||||
let className = '';
|
||||
</script>
|
||||
|
||||
<Navbar container={false} class="{className} px-md-2" color="primary" dark expand="xs" style="overflow-x: auto">
|
||||
<NavbarBrand href="." class="d-none d-md-block" style="padding: 0; margin: -.5rem 0;">
|
||||
Réveil
|
||||
</NavbarBrand>
|
||||
<Nav navbar>
|
||||
<NavItem class="d-block d-md-none">
|
||||
<NavLink
|
||||
active={activemenu === ''}
|
||||
class="text-center"
|
||||
href="."
|
||||
>
|
||||
<Icon name="house-fill" /><br class="d-inline d-md-none">
|
||||
Accueil
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink
|
||||
active={activemenu === 'alarms'}
|
||||
class="text-center"
|
||||
href="alarms"
|
||||
>
|
||||
<Icon name="alarm-fill" /><br class="d-inline d-md-none">
|
||||
Réveils
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink
|
||||
href="musiks"
|
||||
class="text-center"
|
||||
active={activemenu === 'musiks'}
|
||||
>
|
||||
<Icon name="music-note-list" /><br class="d-inline d-md-none">
|
||||
Musiques
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink
|
||||
href="routines"
|
||||
class="text-center"
|
||||
active={activemenu === 'routines'}
|
||||
>
|
||||
<Icon name="activity" /><br class="d-inline d-md-none">
|
||||
Routines
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink
|
||||
href="history"
|
||||
class="text-center"
|
||||
active={activemenu === 'history'}
|
||||
>
|
||||
<Icon name="clipboard-pulse" /><br class="d-inline d-md-none">
|
||||
Historique
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink
|
||||
href="settings"
|
||||
class="text-center"
|
||||
active={activemenu === 'settings'}
|
||||
>
|
||||
<Icon name="gear-fill" /><br class="d-inline d-md-none">
|
||||
Paramètres
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
</Nav>
|
||||
<Nav class="ms-auto text-light" navbar>
|
||||
<NavItem>
|
||||
{#await version then v}
|
||||
{v.version}
|
||||
{/await}
|
||||
</NavItem>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
32
ui/src/lib/components/MusiksLastPlayedList.svelte
Normal file
32
ui/src/lib/components/MusiksLastPlayedList.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script>
|
||||
let musiks = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Hall Of Fame",
|
||||
artist: "The Script",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Poker face",
|
||||
artist: "Lady Gaga",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Puisque tu m'aimes encore",
|
||||
artist: "Céline Dion",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
let tracks = [2,0,1];
|
||||
</script>
|
||||
|
||||
<h2>
|
||||
Dernières musiques jouées
|
||||
</h2>
|
||||
<ol class="list-group list-group-numbered">
|
||||
{#each tracks as track}
|
||||
<li class="list-group-item">{musiks[track].artist} – {musiks[track].title}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
22
ui/src/lib/components/Toaster.svelte
Normal file
22
ui/src/lib/components/Toaster.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script>
|
||||
import {
|
||||
Toast,
|
||||
ToastBody,
|
||||
ToastHeader,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
</script>
|
||||
|
||||
<div class="toast-container position-absolute top-0 end-0 p-3">
|
||||
{#each $ToastsStore.toasts as toast}
|
||||
<Toast>
|
||||
<ToastHeader toggle={toast.close} icon={toast.color}>
|
||||
{#if toast.title}{toast.title}{:else}Gustus{/if}
|
||||
</ToastHeader>
|
||||
<ToastBody>
|
||||
{toast.msg}
|
||||
</ToastBody>
|
||||
</Toast>
|
||||
{/each}
|
||||
</div>
|
||||
97
ui/src/lib/components/TrackList.svelte
Normal file
97
ui/src/lib/components/TrackList.svelte
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { tracks } from '$lib/stores/tracks';
|
||||
|
||||
export let flush = false;
|
||||
export let edit = false;
|
||||
|
||||
export { className as class };
|
||||
let className = '';
|
||||
|
||||
let refreshInProgress = false;
|
||||
function refresh_tracks() {
|
||||
refreshInProgress = true;
|
||||
tracks.refresh().then(() => {
|
||||
refreshInProgress = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center" class:px-2={flush}>
|
||||
<h2>
|
||||
Musiques {#if !flush}du réveil{/if}
|
||||
</h2>
|
||||
<div>
|
||||
{#if !edit}
|
||||
<Button
|
||||
href="musiks/tracks"
|
||||
color="outline-info"
|
||||
size="sm"
|
||||
>
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
href="musiks/tracks/new"
|
||||
color="outline-primary"
|
||||
size="sm"
|
||||
>
|
||||
<Icon name="plus-lg" />
|
||||
</Button>
|
||||
<Button
|
||||
color="outline-dark"
|
||||
size="sm"
|
||||
title="Rafraîchir la liste des pistes"
|
||||
on:click={refresh_tracks}
|
||||
disabled={refreshInProgress}
|
||||
>
|
||||
{#if !refreshInProgress}
|
||||
<Icon name="arrow-clockwise" />
|
||||
{:else}
|
||||
<Spinner color="dark" size="sm" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group {className}" class:list-group-flush={flush}>
|
||||
{#if $tracks.list}
|
||||
{#each $tracks.list as track (track.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="list-group-item list-group-item-action"
|
||||
class:active={$page.url.pathname.indexOf('/tracks/') !== -1 && $page.params.tid == track.id}
|
||||
aria-current="true"
|
||||
on:click={() => {
|
||||
if (edit) {
|
||||
goto('musiks/tracks/' + track.id);
|
||||
} else {
|
||||
track.toggleEnable().then((t) => {
|
||||
refresh_tracks();
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if !edit}
|
||||
<input class="form-check-input me-1" type="checkbox" checked={track.enabled}>
|
||||
{/if}
|
||||
<span class:fw-bold={!edit && track.enabled}>{track.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
{#await tracks.refresh()}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{:then}
|
||||
test
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
83
ui/src/lib/gong.js
Normal file
83
ui/src/lib/gong.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
export class Gong {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
this.update(res);
|
||||
}
|
||||
}
|
||||
|
||||
update({ id, name, path, enabled }) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const res = await fetch(`api/gongs/${this.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
async setDefault() {
|
||||
this.enabled = !this.enabled;
|
||||
return await this.save();
|
||||
}
|
||||
|
||||
async save() {
|
||||
const res = await fetch(this.id?`api/gongs/${this.id}`:'api/gongs', {
|
||||
method: this.id?'PUT':'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
this.update(data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadGong(files, meta) {
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append("gongfile", file);
|
||||
formData.append("meta", JSON.stringify(meta));
|
||||
|
||||
const res = await fetch('/api/gongs', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return new Gong(data)
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGongs() {
|
||||
const res = await fetch(`api/gongs`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return (await res.json()).map((g) => new Gong(g));
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGong(gid) {
|
||||
const res = await fetch(`api/gongs/${gid}`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return new Gong(await res.json());
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
61
ui/src/lib/routine.js
Normal file
61
ui/src/lib/routine.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
export class Routine {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
this.update(res);
|
||||
}
|
||||
}
|
||||
|
||||
update({ id, name, path, steps }) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
|
||||
steps.sort((a, b) => a.delay - b.delay);
|
||||
this.steps = steps;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const res = await fetch(`api/routines/${this.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
const res = await fetch(this.id?`api/routines/${this.id}`:'api/routines', {
|
||||
method: this.id?'PUT':'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
this.update(data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoutines() {
|
||||
const res = await fetch(`api/routines`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return (await res.json()).map((r) => new Routine(r));
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRoutine(rid) {
|
||||
const res = await fetch(`api/routines/${rid}`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return new Routine(await res.json());
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
39
ui/src/lib/settings.js
Normal file
39
ui/src/lib/settings.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export class Settings {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
this.update(res);
|
||||
}
|
||||
}
|
||||
|
||||
update({ language, gong_interval, weather_delay, weather_action, max_run_time }) {
|
||||
this.language = language;
|
||||
this.gong_interval = gong_interval;
|
||||
this.weather_delay = weather_delay;
|
||||
this.weather_action = weather_action;
|
||||
this.max_run_time = max_run_time;
|
||||
}
|
||||
|
||||
async save() {
|
||||
const res = await fetch(`api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
this.update(data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSettings() {
|
||||
const res = await fetch(`api/settings`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return new Settings(await res.json());
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
51
ui/src/lib/stores/actions.js
Normal file
51
ui/src/lib/stores/actions.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { derived, writable } from 'svelte/store';
|
||||
|
||||
import { getActions } from '$lib/action'
|
||||
|
||||
function createActionsStore() {
|
||||
const { subscribe, set, update } = writable({list: null, fileIdx: null});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
set: (v) => {
|
||||
update((m) => Object.assign(m, v));
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const list = await getActions();
|
||||
const fileIdx = {};
|
||||
list.forEach(function(action, k) {
|
||||
fileIdx[action.path] = action;
|
||||
});
|
||||
|
||||
update((m) => (Object.assign(m, {list, fileIdx})));
|
||||
return list;
|
||||
},
|
||||
|
||||
update: (res_actions, cb=null) => {
|
||||
if (res_actions.status === 200) {
|
||||
res_actions.json().then((list) => {
|
||||
const fileIdx = {};
|
||||
list.forEach(function(action, k) {
|
||||
fileIdx[action.path] = action;
|
||||
})
|
||||
|
||||
update((m) => (Object.assign(m, {list, fileIdx})));
|
||||
|
||||
if (cb) {
|
||||
cb(list);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const actions = createActionsStore();
|
||||
|
||||
export const actions_idx = derived(
|
||||
actions,
|
||||
($actions) => ($actions.fileIdx),
|
||||
);
|
||||
41
ui/src/lib/stores/alarmexceptions.js
Normal file
41
ui/src/lib/stores/alarmexceptions.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { derived, writable } from 'svelte/store';
|
||||
|
||||
import { getAlarmsException } from '$lib/alarmexception'
|
||||
|
||||
function createAlarmsExceptionStore() {
|
||||
const { subscribe, set, update } = writable({list: null});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
set: (v) => {
|
||||
update((m) => Object.assign(m, v));
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
update((m) => m = {list: null});
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const list = await getAlarmsException();
|
||||
|
||||
update((m) => (Object.assign(m, {list})));
|
||||
return list;
|
||||
},
|
||||
|
||||
update: (res_AlarmsException, cb=null) => {
|
||||
if (res_AlarmsException.status === 200) {
|
||||
res_AlarmsException.json().then((list) => {
|
||||
update((m) => (Object.assign(m, {list})));
|
||||
|
||||
if (cb) {
|
||||
cb(list);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const alarmsExceptions = createAlarmsExceptionStore();
|
||||
41
ui/src/lib/stores/alarmrepeated.js
Normal file
41
ui/src/lib/stores/alarmrepeated.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { derived, writable } from 'svelte/store';
|
||||
|
||||
import { getAlarmsRepeated } from '$lib/alarmrepeated'
|
||||
|
||||
function createAlarmsRepeatedStore() {
|
||||
const { subscribe, set, update } = writable({list: null});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
set: (v) => {
|
||||
update((m) => Object.assign(m, v));
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
update((m) => m = {list: null});
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const list = await getAlarmsRepeated();
|
||||
|
||||
update((m) => (Object.assign(m, {list})));
|
||||
return list;
|
||||
},
|
||||
|
||||
update: (res_AlarmsRepeated, cb=null) => {
|
||||
if (res_AlarmsRepeated.status === 200) {
|
||||
res_AlarmsRepeated.json().then((list) => {
|
||||
update((m) => (Object.assign(m, {list})));
|
||||
|
||||
if (cb) {
|
||||
cb(list);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const alarmsRepeated = createAlarmsRepeatedStore();
|
||||
41
ui/src/lib/stores/alarmsingle.js
Normal file
41
ui/src/lib/stores/alarmsingle.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { derived, writable } from 'svelte/store';
|
||||
|
||||
import { getAlarmsSingle } from '$lib/alarmsingle'
|
||||
|
||||
function createAlarmsSingleStore() {
|
||||
const { subscribe, set, update } = writable({list: null});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
set: (v) => {
|
||||
update((m) => Object.assign(m, v));
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
update((m) => m = {list: null});
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const list = await getAlarmsSingle();
|
||||
|
||||
update((m) => (Object.assign(m, {list})));
|
||||
return list;
|
||||
},
|
||||
|
||||
update: (res_AlarmsSingle, cb=null) => {
|
||||
if (res_AlarmsSingle.status === 200) {
|
||||
res_AlarmsSingle.json().then((list) => {
|
||||
update((m) => (Object.assign(m, {list})));
|
||||
|
||||
if (cb) {
|
||||
cb(list);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const alarmsSingle = createAlarmsSingleStore();
|
||||
36
ui/src/lib/stores/gongs.js
Normal file
36
ui/src/lib/stores/gongs.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
import { getGongs } from '$lib/gong'
|
||||
|
||||
function createGongsStore() {
|
||||
const { subscribe, set, update } = writable({list: null});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
set: (v) => {
|
||||
update((m) => Object.assign(m, v));
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const list = await getGongs();
|
||||
update((m) => Object.assign(m, {list}));
|
||||
return list;
|
||||
},
|
||||
|
||||
update: (res_gongs, cb=null) => {
|
||||
if (res_gongs.status === 200) {
|
||||
res_gongs.json().then((list) => {
|
||||
update((m) => (Object.assign(m, {list})));
|
||||
|
||||
if (cb) {
|
||||
cb(list);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const gongs = createGongsStore();
|
||||
43
ui/src/lib/stores/quotes.js
Normal file
43
ui/src/lib/stores/quotes.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
function createQuotesStore() {
|
||||
const { subscribe, set, update } = writable({quotes: [], quoteOfTheDay: null});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
set: (quotes) => {
|
||||
update((m) => Object.assign(m, {quotes}));
|
||||
},
|
||||
|
||||
setQOTD: (quoteOfTheDay) => {
|
||||
update((m) => Object.assign(m, {quoteOfTheDay}));
|
||||
},
|
||||
|
||||
refreshQOTD: async () => {
|
||||
const res = await fetch(`api/quoteoftheday`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
const quoteOfTheDay = await res.json();
|
||||
update((m) => Object.assign(m, {quoteOfTheDay}));
|
||||
return quoteOfTheDay;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
},
|
||||
|
||||
update: (res_quotes, cb=null) => {
|
||||
if (res_quotes.status === 200) {
|
||||
res_quotes.json().then((quotes) => {
|
||||
update((m) => (Object.assign(m, {quotes})));
|
||||
|
||||
if (cb) {
|
||||
cb(quotes);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const quotes = createQuotesStore();
|
||||
36
ui/src/lib/stores/routines.js
Normal file
36
ui/src/lib/stores/routines.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
import { getRoutines } from '$lib/routine'
|
||||
|
||||
function createRoutinesStore() {
|
||||
const { subscribe, set, update } = writable({list: null});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
set: (v) => {
|
||||
update((m) => Object.assign(m, v));
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const list = await getRoutines();
|
||||
update((m) => Object.assign(m, {list}));
|
||||
return list;
|
||||
},
|
||||
|
||||
update: (res_routines, cb=null) => {
|
||||
if (res_routines.status === 200) {
|
||||
res_routines.json().then((list) => {
|
||||
update((m) => (Object.assign(m, {list})));
|
||||
|
||||
if (cb) {
|
||||
cb(list);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const routines = createRoutinesStore();
|
||||
41
ui/src/lib/stores/toasts.js
Normal file
41
ui/src/lib/stores/toasts.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
function createToastsStore() {
|
||||
const { subscribe, update } = writable({toasts: []});
|
||||
|
||||
const addToast = (o) => {
|
||||
o.timestamp = new Date();
|
||||
|
||||
o.close = () => {
|
||||
update((i) => {
|
||||
i.toasts = i.toasts.filter((j) => {
|
||||
return !(j.title === o.title && j.msg === o.msg && j.timestamp === o.timestamp)
|
||||
});
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
update((i) => {
|
||||
i.toasts.unshift(o);
|
||||
return i;
|
||||
});
|
||||
|
||||
o.cancel = setTimeout(o.close, o.dismiss?o.dismiss:5000);
|
||||
};
|
||||
|
||||
const addErrorToast = (o) => {
|
||||
if (!o.title) o.title = 'Une erreur est survenue !';
|
||||
if (!o.color) o.color = 'danger';
|
||||
|
||||
return addToast(o);
|
||||
};
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
addToast,
|
||||
addErrorToast,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const ToastsStore = createToastsStore();
|
||||
36
ui/src/lib/stores/tracks.js
Normal file
36
ui/src/lib/stores/tracks.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
import { getTracks } from '$lib/track'
|
||||
|
||||
function createTracksStore() {
|
||||
const { subscribe, set, update } = writable({list: null});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
set: (v) => {
|
||||
update((m) => Object.assign(m, v));
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const list = await getTracks();
|
||||
update((m) => Object.assign(m, {list}));
|
||||
return list;
|
||||
},
|
||||
|
||||
update: (res_tracks, cb=null) => {
|
||||
if (res_tracks.status === 200) {
|
||||
res_tracks.json().then((list) => {
|
||||
update((m) => (Object.assign(m, {list})));
|
||||
|
||||
if (cb) {
|
||||
cb(list);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const tracks = createTracksStore();
|
||||
83
ui/src/lib/track.js
Normal file
83
ui/src/lib/track.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
export class Track {
|
||||
constructor(res) {
|
||||
if (res) {
|
||||
this.update(res);
|
||||
}
|
||||
}
|
||||
|
||||
update({ id, name, path, enabled }) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const res = await fetch(`api/tracks/${this.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
if (res.status == 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleEnable() {
|
||||
this.enabled = !this.enabled;
|
||||
return await this.save();
|
||||
}
|
||||
|
||||
async save() {
|
||||
const res = await fetch(this.id?`api/tracks/${this.id}`:'api/tracks', {
|
||||
method: this.id?'PUT':'POST',
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
if (res.status == 200) {
|
||||
const data = await res.json();
|
||||
this.update(data);
|
||||
return this;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadTrack(files, meta) {
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append("trackfile", file);
|
||||
formData.append("meta", JSON.stringify(meta));
|
||||
|
||||
const res = await fetch('/api/tracks', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return new Track(data)
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTracks() {
|
||||
const res = await fetch(`api/tracks`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return (await res.json()).map((t) => new Track(t));
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrack(tid) {
|
||||
const res = await fetch(`api/tracks/${tid}`, {headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return new Track(await res.json());
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
46
ui/src/reveil.scss
Normal file
46
ui/src/reveil.scss
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Your variable overrides can go here, e.g.:
|
||||
// $h1-font-size: 3rem;
|
||||
//$primary: #ff485a;
|
||||
//$secondary: #ff7b88;
|
||||
|
||||
$blue: #2a9fd6;
|
||||
$indigo: #6610f2;
|
||||
$purple: #6f42c1;
|
||||
$pink: #e83e8c;
|
||||
$red: #c00;
|
||||
$orange: #fd7e14;
|
||||
$yellow: #f80;
|
||||
$green: #77b300;
|
||||
$teal: #20c997;
|
||||
$cyan: #93c;
|
||||
|
||||
$primary: $pink;
|
||||
$success: $green;
|
||||
$info: $cyan;
|
||||
$warning: $yellow;
|
||||
$danger: $red;
|
||||
|
||||
$min-contrast-ratio: 2.25;
|
||||
|
||||
$enable-shadows: true;
|
||||
$enable-gradients: true;
|
||||
$enable-responsive-font-sizes: true;
|
||||
|
||||
$link-color: $primary;
|
||||
|
||||
$navbar-padding-y: 0;
|
||||
$nav-link-padding-y: 0.2rem;
|
||||
|
||||
@import "bootstrap/scss/bootstrap";
|
||||
|
||||
a.btn, button.btn {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.fixed-bottom .nav-item a {
|
||||
border-top: 1px solid $pink;
|
||||
}
|
||||
.fixed-bottom .nav-item a.active {
|
||||
background: white;
|
||||
color: $pink;
|
||||
}
|
||||
2
ui/src/routes/+layout.js
Normal file
2
ui/src/routes/+layout.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
35
ui/src/routes/+layout.svelte
Normal file
35
ui/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script>
|
||||
import '../reveil.scss'
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
|
||||
import {
|
||||
//Styles,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Réveil</title>
|
||||
</svelte:head>
|
||||
|
||||
<!--Styles /-->
|
||||
|
||||
<Header
|
||||
class="d-none d-lg-flex py-2"
|
||||
/>
|
||||
<div class="flex-fill d-flex flex-column bg-light">
|
||||
<slot></slot>
|
||||
<div class="d-flex d-lg-none mt-1 mb-4"></div>
|
||||
</div>
|
||||
<Toaster />
|
||||
<Header
|
||||
class="d-flex d-lg-none fixed-bottom"
|
||||
/>
|
||||
|
||||
<style>
|
||||
:global(a.badge) {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
155
ui/src/routes/+page.svelte
Normal file
155
ui/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<script>
|
||||
import {
|
||||
Container,
|
||||
Icon,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import CycleCounter from '$lib/components/CycleCounter.svelte';
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
import { isAlarmActive, alarmNextTrack, runAlarm, alarmStop } from '$lib/alarm';
|
||||
import { getNextAlarm, newNCyclesAlarm } from '$lib/alarmsingle';
|
||||
import { alarmsSingle } from '$lib/stores/alarmsingle';
|
||||
import { quotes } from '$lib/stores/quotes';
|
||||
|
||||
let nextAlarmP = getNextAlarm();
|
||||
let isActiveP = isAlarmActive();
|
||||
|
||||
function reloadNextAlarm() {
|
||||
nextAlarmP = getNextAlarm();
|
||||
alarmsSingle.clear();
|
||||
}
|
||||
function reloadIsActiveAlarm() {
|
||||
isActiveP = isAlarmActive();
|
||||
isActiveP.then((isActive) => {
|
||||
if (!isActive) {
|
||||
extinctionInProgress = false;
|
||||
}
|
||||
})
|
||||
return isActiveP;
|
||||
}
|
||||
|
||||
function newCyclesAlarm(ncycles) {
|
||||
newNCyclesAlarm(ncycles).then(reloadNextAlarm);
|
||||
}
|
||||
|
||||
function stopAlarm() {
|
||||
extinctionInProgress = true;
|
||||
alarmStop();
|
||||
reloadIsActiveAlarm().then((isActive) => {
|
||||
if (isActive) {
|
||||
setTimeout(reloadIsActiveAlarm, 10000);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let extinctionInProgress = false;
|
||||
</script>
|
||||
|
||||
<Container class="flex-fill d-flex flex-column justify-content-center text-center">
|
||||
{#if $quotes.quoteOfTheDay}
|
||||
<figure>
|
||||
<blockquote class="blockquote text-muted">
|
||||
<p class="display-6">{$quotes.quoteOfTheDay.content}</p>
|
||||
</blockquote>
|
||||
<figcaption class="blockquote-footer">
|
||||
{$quotes.quoteOfTheDay.author}
|
||||
</figcaption>
|
||||
</figure>
|
||||
{:else}
|
||||
{#await quotes.refreshQOTD()}
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
<div class="display-5 mb-5">
|
||||
{#await nextAlarmP}
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
{:then nextalarm}
|
||||
{#if nextalarm === null}
|
||||
<Icon name="x-octagon" /> Pas de prochain réveil programmé
|
||||
{:else}
|
||||
<Icon name="alarm-fill" /> Prochain réveil :
|
||||
{#if nextalarm.getDay() == new Date().getDay() && nextalarm.getMonth() == new Date().getMonth() && nextalarm.getFullYear() == new Date().getFullYear()}
|
||||
aujourd'hui à
|
||||
<DateFormat date={nextalarm} timeStyle="long" />
|
||||
<br class="d-block d-md-none" />
|
||||
<CycleCounter ends={nextalarm} on:reload={reloadNextAlarm} />
|
||||
{:else if nextalarm.getDay() == new Date(Date.now() + 86400000).getDay() && nextalarm.getMonth() == new Date(Date.now() + 86400000).getMonth() && nextalarm.getFullYear() == new Date(Date.now() + 86400000).getFullYear()}
|
||||
demain à
|
||||
<DateFormat date={nextalarm} timeStyle="long" />
|
||||
<br class="d-block d-md-none" />
|
||||
<CycleCounter ends={nextalarm} on:reload={reloadNextAlarm} />
|
||||
{:else if nextalarm.getTime() < Date.now() + 604800000}
|
||||
<span title={nextalarm}>{nextalarm.toLocaleString('default', {weekday: 'long'})}</span>
|
||||
à
|
||||
<DateFormat date={nextalarm} timeStyle="long" />
|
||||
{:else}
|
||||
<DateFormat date={nextalarm} dateStyle="short" timeStyle="long" />
|
||||
{/if}
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
{#await isActiveP then isActive}
|
||||
{#if !isActive}
|
||||
<div class="d-flex gap-3 justify-content-center">
|
||||
<a
|
||||
href="alarms/single/new"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<Icon name="node-plus" />
|
||||
Programmer un nouveau réveil
|
||||
</a>
|
||||
<button
|
||||
class="btn btn-info"
|
||||
on:click={() => newCyclesAlarm(5)}
|
||||
>
|
||||
<Icon name="node-plus" />
|
||||
5 cycles
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-info"
|
||||
on:click={() => newCyclesAlarm(6)}
|
||||
>
|
||||
<Icon name="node-plus" />
|
||||
6 cycles
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-warning"
|
||||
on:click={() => { runAlarm(); reloadIsActiveAlarm(); }}
|
||||
>
|
||||
<Icon name="play-circle" />
|
||||
Lancer le réveil
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="d-flex gap-3 mt-3 justify-content-center">
|
||||
<button
|
||||
class="btn btn-outline-info"
|
||||
on:click={alarmNextTrack}
|
||||
>
|
||||
<Icon name="skip-end-fill" />
|
||||
Chanson suivante
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
on:click={stopAlarm}
|
||||
>
|
||||
{#if extinctionInProgress}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Icon name="stop-circle" />
|
||||
{/if}
|
||||
Éteindre le réveil
|
||||
</button>
|
||||
<button class="btn btn-outline-info">
|
||||
<Icon name="fast-forward-fill" />
|
||||
Passer cette étape de la routine
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</Container>
|
||||
26
ui/src/routes/alarms/+page.svelte
Normal file
26
ui/src/routes/alarms/+page.svelte
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script>
|
||||
import {
|
||||
Col,
|
||||
Container,
|
||||
Row,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import AlarmSingleList from '$lib/components/AlarmSingleList.svelte';
|
||||
import AlarmRepeatedList from '$lib/components/AlarmRepeatedList.svelte';
|
||||
import AlarmExceptionList from '$lib/components/AlarmExceptionList.svelte';
|
||||
</script>
|
||||
|
||||
<Container fluid class="flex-fill d-flex flex-column py-2">
|
||||
<Row>
|
||||
<Col class="mb-4" md={4}>
|
||||
<AlarmSingleList />
|
||||
</Col>
|
||||
<Col class="mb-4" md={4}>
|
||||
<AlarmRepeatedList />
|
||||
</Col>
|
||||
<Col class="mb-4" md={4}>
|
||||
<AlarmExceptionList />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
1
ui/src/routes/alarms/[kind]/+layout.js
Normal file
1
ui/src/routes/alarms/[kind]/+layout.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = false;
|
||||
44
ui/src/routes/alarms/[kind]/+layout.svelte
Normal file
44
ui/src/routes/alarms/[kind]/+layout.svelte
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<script>
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Container,
|
||||
Row,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import AlarmSingleList from '$lib/components/AlarmSingleList.svelte';
|
||||
import AlarmRepeatedList from '$lib/components/AlarmRepeatedList.svelte';
|
||||
import AlarmExceptionList from '$lib/components/AlarmExceptionList.svelte';
|
||||
|
||||
function slugToComponent(slug) {
|
||||
switch(slug) {
|
||||
case "single":
|
||||
return AlarmSingleList;
|
||||
case "repeated":
|
||||
return AlarmRepeatedList;
|
||||
case "exceptions":
|
||||
return AlarmExceptionList;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="d-flex flex-fill flex-column">
|
||||
<div class="d-flex flex-row justify-content-between d-block d-md-none">
|
||||
<Button href="alarms" color="link" class="p-0">
|
||||
<Icon name="chevron-left" /> Réveils
|
||||
</Button>
|
||||
</div>
|
||||
<Container fluid class="flex-fill">
|
||||
<Row class="mh-100 h-100">
|
||||
<Col md={3} class="d-none d-md-block px-0 py-2" style="background: #e7e8e9">
|
||||
<svelte:component this={slugToComponent($page.params["kind"])} flush={true} />
|
||||
</Col>
|
||||
<Col md={9} class="d-flex py-2">
|
||||
<slot></slot>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</div>
|
||||
42
ui/src/routes/alarms/[kind]/+page.svelte
Normal file
42
ui/src/routes/alarms/[kind]/+page.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import {
|
||||
Container,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import AlarmSingleList from '$lib/components/AlarmSingleList.svelte';
|
||||
import AlarmRepeatedList from '$lib/components/AlarmRepeatedList.svelte';
|
||||
import AlarmExceptionList from '$lib/components/AlarmExceptionList.svelte';
|
||||
|
||||
function slugToComponent(slug) {
|
||||
switch(slug) {
|
||||
case "single":
|
||||
return AlarmSingleList;
|
||||
case "repeated":
|
||||
return AlarmRepeatedList;
|
||||
case "exceptions":
|
||||
return AlarmExceptionList;
|
||||
}
|
||||
}
|
||||
|
||||
function slugToText(slug) {
|
||||
switch(slug) {
|
||||
case "single":
|
||||
return "un réveil manuel";
|
||||
case "repeated":
|
||||
return "un réveil habituel";
|
||||
case "exceptions":
|
||||
return "une exception";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container fluid class="py-2 d-none d-md-flex flex-column justify-content-center align-items-center">
|
||||
<p class="fst-italic text-muted">
|
||||
Choisissez {slugToText($page.params.kind)} dans la liste.
|
||||
</p>
|
||||
</Container>
|
||||
<Container fluid class="d-block d-md-none">
|
||||
<svelte:component this={slugToComponent($page.params.kind)} />
|
||||
</Container>
|
||||
182
ui/src/routes/alarms/[kind]/[aid]/+page.svelte
Normal file
182
ui/src/routes/alarms/[kind]/[aid]/+page.svelte
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<script>
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Row,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
import DateRangeFormat from '$lib/components/DateRangeFormat.svelte';
|
||||
import { getAlarmSingle } from '$lib/alarmsingle';
|
||||
import { getAlarmRepeated, weekdayStr } from '$lib/alarmrepeated';
|
||||
import { getAlarmException } from '$lib/alarmexception';
|
||||
import { alarmsRepeated } from '$lib/stores/alarmrepeated';
|
||||
import { alarmsSingle } from '$lib/stores/alarmsingle';
|
||||
import { alarmsExceptions } from '$lib/stores/alarmexceptions';
|
||||
|
||||
function slugToTitle(slug) {
|
||||
switch(slug) {
|
||||
case "single":
|
||||
return "Réveil manuel";
|
||||
case "repeated":
|
||||
return "Réveil habituel";
|
||||
case "exceptions":
|
||||
return "Exception";
|
||||
}
|
||||
}
|
||||
|
||||
let objP;
|
||||
let obj;
|
||||
|
||||
$: {
|
||||
switch ($page.params["kind"]) {
|
||||
case "single":
|
||||
objP = getAlarmSingle($page.params["aid"]);
|
||||
break;
|
||||
case "repeated":
|
||||
objP = getAlarmRepeated($page.params["aid"]);
|
||||
break;
|
||||
case "exceptions":
|
||||
objP = getAlarmException($page.params["aid"]);
|
||||
break;
|
||||
}
|
||||
objP.then((o) => obj = o);
|
||||
}
|
||||
|
||||
let edit = false;
|
||||
|
||||
function deleteThis() {
|
||||
obj.delete().then(() => {
|
||||
switch($page.params["kind"]) {
|
||||
case "single":
|
||||
alarmsSingle.clear();
|
||||
break;
|
||||
case "repeated":
|
||||
alarmsRepeated.clear();
|
||||
break;
|
||||
case "exceptions":
|
||||
alarmsExceptions.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
goto('alarms/' + $page.params["kind"]);
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container fluid class="flex-fill">
|
||||
{#if $page.params["kind"] == "single"}
|
||||
{#await objP}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{:then alarm}
|
||||
<h2 class="mb-0">
|
||||
{slugToTitle($page.params["kind"])} du <DateFormat date={alarm.time} dateStyle="long" />
|
||||
</h2>
|
||||
{#if alarm.comment}
|
||||
<p>
|
||||
{alarm.comment}
|
||||
</p>
|
||||
{/if}
|
||||
<ListGroup class="my-2">
|
||||
<ListGroupItem>
|
||||
<strong>Date du réveil</strong> <DateFormat date={alarm.time} dateStyle="long" />
|
||||
</ListGroupItem>
|
||||
<ListGroupItem>
|
||||
<strong>Heure du réveil</strong> <DateFormat date={alarm.time} timeStyle="long" />
|
||||
</ListGroupItem>
|
||||
</ListGroup>
|
||||
{/await}
|
||||
{:else if $page.params["kind"] == "repeated"}
|
||||
{#await objP}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{:then alarm}
|
||||
<h2 class="mb-0">
|
||||
{slugToTitle($page.params["kind"])} des {weekdayStr(alarm.weekday)}s à {alarm.time}
|
||||
</h2>
|
||||
{#if alarm.comment}
|
||||
<p>
|
||||
{alarm.comment}
|
||||
</p>
|
||||
{/if}
|
||||
<ListGroup class="my-2">
|
||||
<ListGroupItem>
|
||||
<strong>Jour de la semaine</strong> {weekdayStr(alarm.weekday)}
|
||||
</ListGroupItem>
|
||||
<ListGroupItem>
|
||||
<strong>Heure du réveil</strong> {alarm.time}
|
||||
</ListGroupItem>
|
||||
<ListGroupItem>
|
||||
<strong>Ignorer les exceptions ?</strong> {alarm.ignore_exceptions?"oui":"non"}
|
||||
</ListGroupItem>
|
||||
{#if alarm.next_time}
|
||||
<ListGroupItem>
|
||||
<strong>Prochaine occurrence</strong> <DateFormat date={new Date(alarm.next_time)} dateStyle="long" />
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
</ListGroup>
|
||||
{#if alarm.excepts}
|
||||
<h3>Prochaines exceptions</h3>
|
||||
<ListGroup class="my-2">
|
||||
{#each alarm.excepts as except}
|
||||
<ListGroupItem>
|
||||
<DateFormat date={new Date(except)} dateStyle="long" />
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
{/if}
|
||||
{/await}
|
||||
{:else if $page.params["kind"] == "exceptions"}
|
||||
{#await objP}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{:then exception}
|
||||
<h2 class="mb-0">
|
||||
{slugToTitle($page.params["kind"])} du <DateRangeFormat startDate={exception._start()} endDate={exception._end()} dateStyle="long" />
|
||||
</h2>
|
||||
{#if exception.comment}
|
||||
<p>
|
||||
{exception.comment}
|
||||
</p>
|
||||
{/if}
|
||||
Entre le <DateRangeFormat startDate={exception._start()} endDate={exception._end()} dateStyle="long" />
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
{#if !edit}
|
||||
{#await objP then alarm}
|
||||
<ListGroup class="my-2 text-center">
|
||||
<ListGroupItem
|
||||
action
|
||||
tag="button"
|
||||
class="text-info fw-bold"
|
||||
on:click={() => edit = !edit}
|
||||
>
|
||||
<Icon name="pencil" />
|
||||
Éditer ce {slugToTitle($page.params["kind"]).toLowerCase()}
|
||||
</ListGroupItem>
|
||||
<ListGroupItem
|
||||
action
|
||||
tag="button"
|
||||
class="text-danger fw-bold"
|
||||
on:click={deleteThis}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
Supprimer ce {slugToTitle($page.params["kind"]).toLowerCase()}
|
||||
</ListGroupItem>
|
||||
</ListGroup>
|
||||
{/await}
|
||||
{/if}
|
||||
</Container>
|
||||
160
ui/src/routes/alarms/[kind]/new/+page.svelte
Normal file
160
ui/src/routes/alarms/[kind]/new/+page.svelte
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Container,
|
||||
Form,
|
||||
FormGroup,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
Row,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import DateTimeInput from '$lib/components/DateTimeInput.svelte';
|
||||
import { AlarmSingle } from '$lib/alarmsingle';
|
||||
import { AlarmRepeated } from '$lib/alarmrepeated';
|
||||
import { AlarmException } from '$lib/alarmexception';
|
||||
import { alarmsRepeated } from '$lib/stores/alarmrepeated';
|
||||
import { alarmsSingle } from '$lib/stores/alarmsingle';
|
||||
import { alarmsExceptions } from '$lib/stores/alarmexceptions';
|
||||
import { routines } from '$lib/stores/routines';
|
||||
|
||||
function slugToTitle(slug) {
|
||||
switch(slug) {
|
||||
case "single":
|
||||
return "Nouveau réveil manuel";
|
||||
case "repeated":
|
||||
return "Nouveau réveil habituel";
|
||||
case "exceptions":
|
||||
return "Nouvelle exception";
|
||||
}
|
||||
}
|
||||
|
||||
let obj;
|
||||
|
||||
switch($page.params["kind"]) {
|
||||
case "single":
|
||||
obj = new AlarmSingle();
|
||||
break;
|
||||
case "repeated":
|
||||
obj = new AlarmRepeated();
|
||||
break;
|
||||
case "exceptions":
|
||||
obj = new AlarmException();
|
||||
break;
|
||||
}
|
||||
|
||||
function submit() {
|
||||
obj.save().then((res) => {
|
||||
switch($page.params["kind"]) {
|
||||
case "single":
|
||||
alarmsSingle.clear();
|
||||
break;
|
||||
case "repeated":
|
||||
alarmsRepeated.clear();
|
||||
break;
|
||||
case "exceptions":
|
||||
alarmsExceptions.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
goto('alarms/' + $page.params["kind"] + '/' + res.id);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container fluid class="flex-fill">
|
||||
<form on:submit|preventDefault={submit}>
|
||||
<Button type="submit" color="link" class="d-block d-md-none float-end">
|
||||
Ajouter
|
||||
</Button>
|
||||
<h2 class="mb-0">
|
||||
{slugToTitle($page.params["kind"])}
|
||||
</h2>
|
||||
{#if $page.params["kind"] == "single"}
|
||||
<FormGroup>
|
||||
<Label for="exampleHour">Heure</Label>
|
||||
<DateTimeInput id="exampleHour" required bind:date={obj.time} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Label for="routineSelect">Routines</Label>
|
||||
{#if $routines.list}
|
||||
<Input type="select" id="routineSelect" placeholder="Choissez une nouvelle routine">
|
||||
{#each $routines.list as routine (routine.id)}
|
||||
<option value="{routine.id}">{routine.name}</option>
|
||||
{/each}
|
||||
</Input>
|
||||
{:else}
|
||||
{#await routines.refresh()}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
</FormGroup>
|
||||
{:else if $page.params["kind"] == "repeated"}
|
||||
<FormGroup>
|
||||
<Label for="daySelect">Jour de la semaine</Label>
|
||||
<Input type="select" id="daySelect" required bind:value={obj.weekday}>
|
||||
<option value={1}>Lundi</option>
|
||||
<option value={2}>Mardi</option>
|
||||
<option value={3}>Mercredi</option>
|
||||
<option value={4}>Jeudi</option>
|
||||
<option value={5}>Vendredi</option>
|
||||
<option value={6}>Samedi</option>
|
||||
<option value={0}>Dimanche</option>
|
||||
</Input>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Label for="exampleHour">Heure</Label>
|
||||
<Input id="exampleHour" type="time" required bind:value={obj.time} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Label for="routineSelect">Routines</Label>
|
||||
{#if $routines.list}
|
||||
<Input type="select" id="routineSelect" placeholder="Choissez une nouvelle routine">
|
||||
{#each $routines.list as routine (routine.id)}
|
||||
<option value="{routine.id}">{routine.name}</option>
|
||||
{/each}
|
||||
</Input>
|
||||
{:else}
|
||||
{#await routines.refresh()}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Input id="ignoreExceptions" type="checkbox" label="Ignorer les exceptions" bind:checked={obj.ignore_exceptions} />
|
||||
</FormGroup>
|
||||
{:else if $page.params["kind"] == "exceptions"}
|
||||
<FormGroup>
|
||||
<Label for="exceptionStart">Date de début</Label>
|
||||
<Input id="exceptionStart" type="date" required bind:value={obj.start} />
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<Label for="exceptionEnd">Date de fin</Label>
|
||||
<Input id="exceptionEnd" type="date" required bind:value={obj.end} />
|
||||
</FormGroup>
|
||||
{/if}
|
||||
|
||||
<FormGroup>
|
||||
<Label for="comment">Commentaire</Label>
|
||||
<Input id="comment" type="text" bind:value={obj.comment} />
|
||||
</FormGroup>
|
||||
|
||||
<Button type="submit" color="primary" class="d-none d-md-block">
|
||||
Ajouter
|
||||
</Button>
|
||||
</form>
|
||||
</Container>
|
||||
26
ui/src/routes/history/+page.svelte
Normal file
26
ui/src/routes/history/+page.svelte
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script>
|
||||
import {
|
||||
Col,
|
||||
Container,
|
||||
Row,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import CardStatAlarms from '$lib/components/CardStatAlarms.svelte';
|
||||
import CardStatTimeAwaking from '$lib/components/CardStatTimeAwaking.svelte';
|
||||
import CardStatRoutines from '$lib/components/CardStatRoutines.svelte';
|
||||
</script>
|
||||
|
||||
<Container fluid class="flex-fill d-flex flex-column justify-content-center py-2">
|
||||
<Row cols={{xs: 1, md: 2, lg: 3}}>
|
||||
<Col class="mb-4">
|
||||
<CardStatAlarms />
|
||||
</Col>
|
||||
<Col class="mb-4">
|
||||
<CardStatTimeAwaking />
|
||||
</Col>
|
||||
<Col class="mb-4">
|
||||
<CardStatRoutines />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
24
ui/src/routes/musiks/+page.svelte
Normal file
24
ui/src/routes/musiks/+page.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script>
|
||||
import {
|
||||
Col,
|
||||
Container,
|
||||
Row,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import MusiksLastPlayedList from '$lib/components/MusiksLastPlayedList.svelte';
|
||||
import TrackList from '$lib/components/TrackList.svelte';
|
||||
import GongsList from '$lib/components/GongsList.svelte';
|
||||
</script>
|
||||
|
||||
<Container fluid class="flex-fill d-flex flex-column py-2">
|
||||
<Row>
|
||||
<Col class="mb-4" md={8}>
|
||||
<TrackList class="mb-4" />
|
||||
<GongsList />
|
||||
</Col>
|
||||
<Col class="mb-4" md={4}>
|
||||
<MusiksLastPlayedList />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
29
ui/src/routes/musiks/gongs/+layout.svelte
Normal file
29
ui/src/routes/musiks/gongs/+layout.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script>
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Container,
|
||||
Row,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import GongsList from '$lib/components/GongsList.svelte';
|
||||
</script>
|
||||
|
||||
<div class="d-flex flex-fill flex-column">
|
||||
<div class="d-flex flex-row justify-content-between d-block d-md-none">
|
||||
<Button href="musiks" color="link" class="p-0">
|
||||
<Icon name="chevron-left" /> Musiques
|
||||
</Button>
|
||||
</div>
|
||||
<Container fluid class="flex-fill">
|
||||
<Row class="mh-100 h-100">
|
||||
<Col md={3} class="d-none d-md-block px-0 py-2" style="background: #e7e8e9">
|
||||
<GongsList edit flush />
|
||||
</Col>
|
||||
<Col md={9} class="d-flex py-2">
|
||||
<slot></slot>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</div>
|
||||
17
ui/src/routes/musiks/gongs/+page.svelte
Normal file
17
ui/src/routes/musiks/gongs/+page.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script>
|
||||
import {
|
||||
Container,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import TrackList from '$lib/components/TrackList.svelte';
|
||||
</script>
|
||||
|
||||
<Container fluid class="py-2 d-none d-md-flex flex-column justify-content-center align-items-center">
|
||||
<p class="fst-italic text-muted">
|
||||
Choisissez la piste dans la liste.
|
||||
</p>
|
||||
</Container>
|
||||
<Container fluid class="d-block d-md-none">
|
||||
<TrackList edit />
|
||||
</Container>
|
||||
1
ui/src/routes/musiks/gongs/[gid]/+page.js
Normal file
1
ui/src/routes/musiks/gongs/[gid]/+page.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = false;
|
||||
94
ui/src/routes/musiks/gongs/[gid]/+page.svelte
Normal file
94
ui/src/routes/musiks/gongs/[gid]/+page.svelte
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Input,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { getGong } from '$lib/gong';
|
||||
import { gongs } from '$lib/stores/gongs';
|
||||
|
||||
function deleteThis(gong) {
|
||||
gong.delete().then(() => {
|
||||
gongs.refresh();
|
||||
goto('musiks/gongs/');
|
||||
})
|
||||
}
|
||||
|
||||
let confirmDeletion = false;
|
||||
</script>
|
||||
|
||||
{#await getGong($page.params.gid)}
|
||||
<div class="d-flex flex-fill justify-content-center align-items-center gap-2">
|
||||
<Spinner color="primary" /> Chargement en cours…
|
||||
</div>
|
||||
{:then gong}
|
||||
<Container>
|
||||
<h2>
|
||||
{gong.name}
|
||||
</h2>
|
||||
<ListGroup>
|
||||
<ListGroupItem>
|
||||
<strong>Chemin</strong>
|
||||
{gong.path}
|
||||
</ListGroupItem>
|
||||
<ListGroupItem class="d-flex gap-2">
|
||||
<strong>Par défaut ?</strong>
|
||||
<Input type="switch" on:change={() => gong.setDefault()} checked={gong.enabled} disabled={gong.enabled} />
|
||||
</ListGroupItem>
|
||||
<ListGroupItem>
|
||||
<strong>ID</strong>
|
||||
<span class="text-muted">{gong.id}</span>
|
||||
</ListGroupItem>
|
||||
</ListGroup>
|
||||
|
||||
<div class="my-2 d-flex justify-content-center">
|
||||
<audio controls class="w-100 rounded">
|
||||
<source src="api/gongs/{gong.id}/stream">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<ListGroup class="my-2 text-center">
|
||||
{#if !confirmDeletion}
|
||||
<ListGroupItem
|
||||
action
|
||||
tag="button"
|
||||
class={gong.enabled?"fw-bold":"text-danger fw-bold"}
|
||||
disabled={gong.enabled}
|
||||
on:click={() => confirmDeletion = !confirmDeletion}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
Supprimer ce gong
|
||||
</ListGroupItem>
|
||||
{:else}
|
||||
<ListGroupItem>
|
||||
<p class="fw-bold">
|
||||
Êtes-vous sûr ?
|
||||
</p>
|
||||
<div class="d-flex justify-content-around">
|
||||
<Button
|
||||
color="danger"
|
||||
on:click={() => deleteThis(gong)}
|
||||
>
|
||||
<Icon name="heartbreak-fill" /> Oui, supprimer ce gong
|
||||
</Button>
|
||||
<Button
|
||||
color="outline-success"
|
||||
on:click={() => confirmDeletion = !confirmDeletion}
|
||||
>
|
||||
<Icon name="balloon-heart" /> Non, garder le gong
|
||||
</Button>
|
||||
</div>
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
</ListGroup>
|
||||
</Container>
|
||||
{/await}
|
||||
45
ui/src/routes/musiks/gongs/new/+page.svelte
Normal file
45
ui/src/routes/musiks/gongs/new/+page.svelte
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Form,
|
||||
Icon,
|
||||
Input,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Spinner,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import { gongs } from '$lib/stores/gongs';
|
||||
import { uploadGong } from '$lib/gong';
|
||||
|
||||
function submitGong() {
|
||||
if (files.length == 0) {
|
||||
alert("Vous n'avez sélectionné aucun fichier !")
|
||||
return false;
|
||||
}
|
||||
|
||||
uploadGong(files).then((gong) => {
|
||||
gongs.refresh();
|
||||
goto('musiks/gongs/' + gong.id);
|
||||
})
|
||||
}
|
||||
|
||||
export let files = [];
|
||||
</script>
|
||||
|
||||
<Container>
|
||||
<h2>
|
||||
Nouveau gong
|
||||
</h2>
|
||||
|
||||
<form on:submit|preventDefault={submitGong}>
|
||||
<Input type="file" bind:files />
|
||||
|
||||
<Button type="submit" color="primary" class="mt-2" disabled={files.length == 0}>
|
||||
Ajouter ce gong
|
||||
</Button>
|
||||
</form>
|
||||
</Container>
|
||||
29
ui/src/routes/musiks/tracks/+layout.svelte
Normal file
29
ui/src/routes/musiks/tracks/+layout.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script>
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Container,
|
||||
Row,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import TrackList from '$lib/components/TrackList.svelte';
|
||||
</script>
|
||||
|
||||
<div class="d-flex flex-fill flex-column">
|
||||
<div class="d-flex flex-row justify-content-between d-block d-md-none">
|
||||
<Button href="musiks" color="link" class="p-0">
|
||||
<Icon name="chevron-left" /> Musiques
|
||||
</Button>
|
||||
</div>
|
||||
<Container fluid class="flex-fill">
|
||||
<Row class="mh-100 h-100">
|
||||
<Col md={3} class="d-none d-md-block px-0 py-2" style="background: #e7e8e9">
|
||||
<TrackList edit flush />
|
||||
</Col>
|
||||
<Col md={9} class="d-flex py-2">
|
||||
<slot></slot>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</div>
|
||||
17
ui/src/routes/musiks/tracks/+page.svelte
Normal file
17
ui/src/routes/musiks/tracks/+page.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script>
|
||||
import {
|
||||
Container,
|
||||
Icon,
|
||||
} from 'sveltestrap';
|
||||
|
||||
import TrackList from '$lib/components/TrackList.svelte';
|
||||
</script>
|
||||
|
||||
<Container fluid class="py-2 d-none d-md-flex flex-column justify-content-center align-items-center">
|
||||
<p class="fst-italic text-muted">
|
||||
Choisissez la piste dans la liste.
|
||||
</p>
|
||||
</Container>
|
||||
<Container fluid class="d-block d-md-none">
|
||||
<TrackList edit />
|
||||
</Container>
|
||||
1
ui/src/routes/musiks/tracks/[tid]/+page.js
Normal file
1
ui/src/routes/musiks/tracks/[tid]/+page.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = false;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue