Compare commits

...

No commits in common. "190017d20edb79cc331350d5d54008bde420a7d7" and "30a50b775fe10624f41fb1462236b0332ab32108" have entirely different histories.

118 changed files with 6568 additions and 324 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
ui/node_modules
ui/build

22
.drone-manifest.yml Normal file
View 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
View 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
View file

@ -1 +1,10 @@
reveil
/actions
/alarms.db
/gongs
/reveil
/routines
/settings.json
/tracks/
/vendor
/ui/build
/ui/node_modules

30
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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é dun 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, dautres 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, "Lhomme le plus heureux est celui qui fait le bonheur dun plus grand nombre dautres.", "Denis Diderot"},
Quote{5, "La joie est en tout ; il faut savoir lextraire.", "Confucius"},
Quote{6, "Loptimiste ne refuse jamais de voir le côté négatif des choses ; il refuse simplement de sattarder dessus.", "Alexandre Lockhart"},
Quote{7, "Lobstination est le chemin de la réussite.", "Charlie chaplin"},
Quote{8, "Si on veut obtenir quelque chose que lon na jamais eu, il faut tenter quelque chose que lon na jamais fait.", "Péricles"},
Quote{9, "La plus grande erreur que puisse faire un homme est davoir peur den faire une.", "Elbert Hubbard"},
Quote{10, "La définition de la folie, cest de refaire toujours la même chose, et dattendre 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é nont pas réalisé quils étaient aussi près du succès quand ils ont abandonné.", "Thomas Edison"},
Quote{14, "La vie, cest 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, cest 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 quil y a eu dans la vie. Cest la vie quil y a eu dans les années.", "Abraham Lincoln"},
Quote{20, "La logique peut vous mener dun point A à un point B. Limagination peut vous mener partout.", "Albert Einstein"},
Quote{21, "Nallez pas où va le chemin. Allez là où il ny 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 cest 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, cest parvenir aux grandes, avec le temps.", "Samuel Beckett"},
Quote{28, "Le succès cest davoir ce que vous désirez. Le bonheur cest aimer ce que vous avez. H.", "Jackson Brown"},
Quote{29, "La chose la plus difficile est de nattribuer aucune importance aux choses qui nont 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 davoir 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, "Lune des meilleures façons daider quelquun 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 daujourdhui.", "Franklin Roosevelt"},
Quote{35, "Cest dans les moments les plus sombres quon 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 dagir et laissez les autres parler.", "Baltasar Gracian"},
Quote{38, "Mettez en tout un grain daudace.", "Baltazar Gracian"},
Quote{39, "Un sourire coûte moins cher que lélectricité, mais donne autant de lumière.", "Abbé Pierre"},
Quote{40, "Nattendez 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 quil est stupide.", "Albert Einstein"},
Quote{42, "Le succès est la capacité daller déchec en échec sans perdre son enthousiasme.", "Winston Churchill"},
Quote{43, "Rien ne sert de défendre le monde dhier 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 daccepter les choses comme elles sont et la deuxième est de prendre la décision de les changer.", "Denis Waitley"},
Quote{46, "Nacceptez jamais la défaite, vous êtes peut-être à un pas de la réussite.", "Jack E Addington"},
Quote{47, "Léchec est seulement lopportunité de recommencer dune façon plus intelligente.", "Henry Ford"},
Quote{48, "Il ny a quune façon déchouer, cest dabandonner avant davoir réussi.", "Georges Clemenceau"},
Quote{49, "Vous navez rien à craindre car léchec est impossible. Vous ne pouvez quapprendre, évoluer et devenir meilleur que vous ne lavez jamais été.", "Hal Elrod"},
Quote{50, "Appréciez déchouer, et apprenez de léchec, car on napprend 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 nai pas échoué. Jai simplement trouvé 10 000 façons de ne pas y arriver.", "Thomas Edison"},
Quote{55, "Les plus belles années dune vie sont celles que lon na pas encore vécues.", "Victor Hugo"},
Quote{56, "Si vous voulez que la vie vous sourie, apportez-lui dabord votre bonne humeur.", "Baruch Spinoza"},
Quote{57, "Ce nest pas parce que les choses sont difficiles que nous nosons pas, cest parce que nous nosons pas quelles sont difficiles.", "Sénèque"},
Quote{58, "Si on veut obtenir quelque chose que lon na jamais eu, il faut tenter quelque chose que lon na 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 nauras 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 dessayer 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 lont 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 quune œuvre inachevée, cest une œuvre jamais commencée.", "Christinna Rosseti"},
Quote{69, "Lobscurité ne peut pas chasser lobscurité, seule la lumière le peut. La haine ne peut pas chasser la haine, seul lamour le peut.", "Martin Luther King"},
Quote{70, "Je nai pas peur de demain, car jai vu hier et jaime aujourdhui.", "William Allen White"},
Quote{71, "Un pessimiste voit la difficulté dans chaque opportunité, un optimiste voit lopportunité 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 lUnivers conspire à nous permettre de réaliser notre rêve.", "Paulo Coelho"},
Quote{78, "Tout est possible à qui rêve, ose, travaille et nabandonne jamais.", "Xavier Dolan"},
Quote{79, "La sagesse, cest davoir des rêves suffisamment grands pour ne pas les perdre de vue lorsquon les poursuit.", "Oscar Wilde"},
Quote{80, "Quand vous osez rêver grand, cest là où votre système nerveux va créer du plaisir et être orienté solutions.", "David Laroche"},
Quote{81, "Choisir sa vie, cest se demander si lon doit réaliser ses rêves ou subir le quotidien.", "Sonia Lahsaini"},
Quote{82, "Fais de ta vie un rêve, et dun 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -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
View file

@ -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
View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,8 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example

1
ui/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

6
ui/.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100
}

38
ui/README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="@sveltejs/kit" />

66
ui/src/lib/action.js Normal file
View 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
View 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);
}
}

View 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);
}
}

View 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
View 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);
}
}

View 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&hellip;
</div>
{:then}
test
{/await}
{/if}
</div>

View 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&hellip;
</div>
{/await}
{/if}
</div>

View 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&hellip;
</div>
{/await}
{/if}
</div>

View 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&hellip;
</div>
{/await}
{/if}
</div>

View 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>

View 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>

View 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>

View 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>

View 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)}&nbsp;cycles + {Math.trunc(((ends.getTime()-begins.getTime())%5400000)/60000)}&nbsp;min)
</span>
{/if}

View 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)}

View 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)}

View 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} />

View 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&hellip;
</div>
{/await}
{/if}
</div>

View 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>

View 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} &ndash; {musiks[track].title}</li>
{/each}
</ol>

View 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>

View 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&hellip;
</div>
{:then}
test
{/await}
{/if}
</div>

83
ui/src/lib/gong.js Normal file
View 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
View 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
View 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);
}
}

View 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),
);

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = true;

View 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
View 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&nbsp;:
{#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>

View 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>

View file

@ -0,0 +1 @@
export const prerender = false;

View 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>

View 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>

View 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&hellip;
</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&hellip;
</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&nbsp;?</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&hellip;
</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>

View 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&hellip;
</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&hellip;
</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>

View 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>

View 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>

View 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>

View 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>

View file

@ -0,0 +1 @@
export const prerender = false;

View 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&hellip;
</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&nbsp;?</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&nbsp;?
</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}

View 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>

View 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>

View 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>

View file

@ -0,0 +1 @@
export const prerender = false;

Some files were not shown because too many files have changed in this diff Show more