Add grace period and fix bugs

This commit is contained in:
Andrew-71 2024-05-05 13:06:20 +03:00
parent e565c7be6d
commit 349e964364
14 changed files with 148 additions and 35 deletions

25
CHANGELOG.md Normal file
View file

@ -0,0 +1,25 @@
# Changelog
This file keeps track of changes in more human-readable fashion
# 5 May 2024
* Added this changelog
* Added grace period (as per suggestions)
* Set in config like `grace_period=3h26m` (via Go's `time.ParseDuration`)
* Defines how long the app should wait before switching to next day.
The example provided means you will still be editing yesterday's file at 3am, but at 3:27am it will be a new day
* When in effect, the header will show "(grace period active)" next to actual date
* Fun fact: if you suddenly increase the setting mid-day, you can have a "Tomorrow" in previous days list! :)
* This feature came with some free minor bug-fixes 'cause I had to re-check time management code.
Now let's hope it doesn't introduce any new ones! :D
* Began adding PWA manifest.json
* This will allow for (slightly) better mobile experience
* Still missing an icon, will be likely installable once I make one and test the app :D
* Known issue: making notes is impossible in the PWA, since you can't navigate to arbitrary page.
I might leave it as a WONTFIX or try to find a workaround
* Date is now shown in local language if possible (in case you add your own or use Russian)
* Bug fixes
* "Today" redirect from days list no longer uses UTC
* Date JS script no longer uses UTC
* The API no longer uses UTC for today
* `/public/` files is no longer behind auth
* Removed Sigmund® Corp. integration :)

View file

@ -6,7 +6,7 @@ This project is *very* opinionated and minimal, and is designed primarily for my
As a result, I can't guarantee that it's either secure or stable.
## Features:
* Each day, you get a text file. You have until 23:59 of that very day to finalise it.
* Each day, you get a new text file. You have until the end of that very day to finalise it.
* You can save named notes to document milestones, big events, or just a nice game you played this month
* You can easily export entire `data` dir in a `.zip` archive for backups
@ -35,6 +35,7 @@ data
config
+-- config.txt
```
Deleting notes is done by clearing contents and clicking "Save" - the app deletes empty files when saving.
### Config options:
Below are defaults of config.txt. The settings are defined in newline separated key=value pairs.
@ -45,6 +46,7 @@ username=admin # Your username
password=admin # Your password
port=7101 # What port to run on (probably leave on 7101 if using docker)
timezone=Local # IANA Time zone database identifier ("UTC", Local", "Europe/Moscow" etc.), Defaults to Local if can't parse.
grace_period=0s # Time after a new day begins, but before the switch to next day's file. e.g. 2h30m - files will change at 2:30am
language=en # ISO-639 language code (currently supported - en, ru)
log_to_file=false # Whether to write logs to a file
log_file=config/log.txt # Where to store the log file if it is enabled
@ -74,4 +76,22 @@ If you for some reason decide to run plain executable instead of docker, it supp
override port
-debug
show debug log
```
### API methods
You can access the API at `/api/<method>`. They are protected by same HTTP Basic Auth as "normal" site.
```
GET /today - get file contents for today
POST /today - save request body into today's file
GET /day - get JSON list of all daily entries
GET /day/<name> - get file contents for a specific day
GET /notes - get JSON list of all named notes
GET /notes/<name> - get file contents for a specific note
POST /notes/<name> - save request body into a named note
GET /export - get .zip archive of entire data directory
GET /readme - get file contents for readme.txt in data dir's root
POST /readme - save request body into readme.txt
```

View file

@ -2,7 +2,7 @@
List of things to add to this project
* CI/CD pipeline
* Better docs in case others want to use ths for some reason
* Better docs in case others want to use this for some reason
* PWA support? I heard it's like installing websites as apps, could be useful!
* Check export function for improvements
* *Go* dependency-less? <-- this is a terrible idea

5
api.go
View file

@ -8,7 +8,6 @@ import (
"log/slog"
"net/http"
"os"
"time"
)
// HandleWrite checks for error in ResponseWriter.Write output
@ -78,12 +77,12 @@ func GetDayApi(w http.ResponseWriter, r *http.Request) {
// GetTodayApi runs GetFile with today's date as filename
func GetTodayApi(w http.ResponseWriter, _ *http.Request) {
GetFile("day/"+time.Now().Format(time.DateOnly), w)
GetFile("day/"+TodayDate(), w)
}
// PostTodayApi runs PostFile with today's date as filename
func PostTodayApi(w http.ResponseWriter, r *http.Request) {
PostFile("day/"+time.Now().Format(time.DateOnly), w, r)
PostFile("day/"+TodayDate(), w, r)
}
// GetNoteApi returns contents of a note specified in URL

View file

@ -20,6 +20,7 @@ type Config struct {
Password string `config:"password" type:"string"`
Port int `config:"port" type:"int"`
Timezone *time.Location `config:"timezone" type:"location"`
GraceTime time.Duration `config:"grace_period" type:"duration"`
Language string `config:"language" type:"string"`
LogToFile bool `config:"log_to_file" type:"bool"`
LogFile string `config:"log_file" type:"string"`
@ -108,6 +109,13 @@ func (c *Config) Reload() error {
}
case "location":
timezone = v
case "duration":
{
numVal, err := time.ParseDuration(v)
if err == nil {
fieldElem.SetInt(int64(numVal))
}
}
default:
fieldElem.SetString(v)
}
@ -128,12 +136,13 @@ func (c *Config) Reload() error {
// Some defaults are declared here
func ConfigInit() Config {
cfg := Config{
Port: 7101,
Username: "admin",
Password: "admin",
Timezone: time.Local,
Language: "en",
LogFile: "config/log.txt",
Port: 7101,
Username: "admin",
Password: "admin",
Timezone: time.Local,
Language: "en",
LogFile: "config/log.txt",
GraceTime: 0,
}
err := cfg.Reload()
if err != nil {

View file

@ -2,6 +2,7 @@ username=admin
password=admin
port=7101
timezone=Local
grace_period=0s
language=en
log_to_file=false
log_file=config/log.txt

View file

@ -75,12 +75,29 @@ func ListFiles(directory string) ([]string, error) {
return filenames, nil
}
// GraceActive returns whether the grace period (Cfg.GraceTime) is active
func GraceActive() bool {
return (60*time.Now().In(Cfg.Timezone).Hour() + time.Now().In(Cfg.Timezone).Minute()) <= int(Cfg.GraceTime.Minutes())
}
// TodayDate returns today's formatted date. It accounts for Config.GraceTime
func TodayDate() string {
dateFormatted := time.Now().In(Cfg.Timezone).Format(time.DateOnly)
if GraceActive() {
slog.Debug("grace period active",
"time", 60*time.Now().In(Cfg.Timezone).Hour()+time.Now().In(Cfg.Timezone).Minute(),
"grace", Cfg.GraceTime.Minutes())
dateFormatted = time.Now().In(Cfg.Timezone).AddDate(0, 0, -1).Format(time.DateOnly)
}
return dateFormatted
}
// ReadToday runs ReadFile with today's date as filename
func ReadToday() ([]byte, error) {
return ReadFile("day/" + time.Now().In(Cfg.Timezone).Format(time.DateOnly))
return ReadFile("day/" + TodayDate())
}
// SaveToday runs SaveFile with today's date as filename
func SaveToday(contents []byte) error {
return SaveFile("day/"+time.Now().In(Cfg.Timezone).Format(time.DateOnly), contents)
return SaveFile("day/"+TodayDate(), contents)
}

View file

@ -6,10 +6,12 @@
"title.notes": "Notes",
"link.today": "today",
"link.tomorrow": "tomorrow",
"link.days": "previous days",
"link.notes": "notes",
"description.notes": "/notes/<name> for a new note",
"misc.date": "Today is",
"time.date": "Today is",
"time.grace": "grace period active",
"button.save": "Save"
}

View file

@ -6,10 +6,12 @@
"title.notes": "Заметки",
"link.today": "сегодня",
"link.tomorrow": "завтра",
"link.days": "раньше",
"link.notes": "заметки",
"description.notes": "/notes/<название> для новой заметки",
"misc.date": "Сегодня",
"time.date": "Сегодня",
"time.grace": "льготный период",
"button.save": "Сохранить"
}

View file

@ -1,7 +1,7 @@
{{define "header"}}
<header>
<h1>🌺 Hibiscus.txt</h1>
<p>{{translatableText "misc.date"}} <span id="today-date">a place</span></p>
<p>{{translatableText "time.date"}} <span id="today-date">a place</span> <span id="grace" hidden>({{ translatableText "time.grace" }})</span></p>
</header>
{{end}}
@ -11,10 +11,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="manifest" href="/public/manifest.json" />
<link rel="icon" type="image/x-icon" href="/public/favicon.ico">
<link rel="stylesheet" href="/public/main.css">
<script src="/public/date.js"></script>
<title>Hibiscus</title>
<title>Hibiscus.txt</title>
</head>
<body>
{{template "header" .}}
@ -22,7 +23,11 @@
{{template "main" .}}
</main>
{{template "footer" .}}
<script defer>updateDate();beginDateUpdater()</script>
<script defer>
const langName="{{ translatableText "lang" }}";
const graceActive={{ graceActive }};
updateDate();beginDateUpdater()
</script>
</body>
</html>
{{end}}

View file

@ -1,18 +1,21 @@
// Format time in "Jan 02, 2006" format
function formatDate(date) {
let dateFormat = new Intl.DateTimeFormat('en', {
let dateFormat = new Intl.DateTimeFormat([langName, "en"], {
year: 'numeric',
month: 'short',
day: 'numeric'
})
return dateFormat.format(Date.parse(date))
return dateFormat.format(date)
}
// Set today's date
function updateDate() {
let todayDate = new Date()
let timeField = document.getElementById("today-date")
timeField.innerText = formatDate(todayDate.toISOString().split('T')[0])
if (graceActive) {
let graceField = document.getElementById("grace")
graceField.hidden = false
}
timeField.innerText = formatDate(Date.now())
}

22
public/manifest.json Normal file
View file

@ -0,0 +1,22 @@
{
"short_name": "Hibiscus",
"name": "Hibiscus.txt",
"description": "A plaintext diary",
"icons": [
{
"src": "/public/TODO.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/public/favicon.ico",
"type": "image/x-icon",
"sizes": "16x16"
}
],
"start_url": "/",
"display": "minimal-ui",
"scope": "/",
"background_color": "#f5f0e1",
"theme_color": "#f85552"
}

View file

@ -25,7 +25,7 @@ type Entry struct {
type formatEntries func([]string) []Entry
var templateFuncs = map[string]interface{}{"translatableText": TranslatableText}
var templateFuncs = map[string]interface{}{"translatableText": TranslatableText, "graceActive": GraceActive}
var editTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFiles("./pages/base.html", "./pages/edit.html"))
var viewTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFiles("./pages/base.html", "./pages/entry.html"))
var listTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFiles("./pages/base.html", "./pages/list.html"))
@ -101,11 +101,16 @@ func GetDays(w http.ResponseWriter, r *http.Request) {
if err == nil {
dayString = t.Format("02 Jan 2006")
}
if v == time.Now().Format(time.DateOnly) {
// Fancy text for today
// This looks bad, but strings.Title is deprecated, and I'm not importing a golang.org/x package for this...
// Fancy text for today and tomorrow
// This looks bad, but strings.Title is deprecated, and I'm not importing a golang.org/x package for this...
// ( chances we ever run into tomorrow are really low)
if v == TodayDate() {
dayString = TranslatableText("link.today")
dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:])
} else if GraceActive() && v > TodayDate() {
dayString = TranslatableText("link.tomorrow")
dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:])
}
filesFormatted = append(filesFormatted, Entry{Title: dayString, Link: "day/" + v})
}
@ -163,7 +168,7 @@ func GetDay(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("day not specified")))
return
}
if dayString == time.Now().Format(time.DateOnly) { // Today can still be edited
if dayString == TodayDate() { // Today can still be edited
http.Redirect(w, r, "/", 302)
return
}

View file

@ -13,20 +13,23 @@ import (
func Serve() {
r := chi.NewRouter()
r.Use(middleware.Logger, middleware.CleanPath, middleware.StripSlashes)
r.Use(BasicAuth) // Is this good enough? Sure hope so
r.NotFound(NotFound)
// Routes ==========
r.Get("/", GetToday)
r.Post("/", PostToday)
r.Get("/day", GetDays)
r.Get("/day/{day}", GetDay)
r.Get("/notes", GetNotes)
r.Get("/notes/{note}", GetNote)
r.Post("/notes/{note}", PostNote)
userRouter := chi.NewRouter()
userRouter.Use(BasicAuth)
userRouter.Get("/", GetToday)
userRouter.Post("/", PostToday)
userRouter.Get("/day", GetDays)
userRouter.Get("/day/{day}", GetDay)
userRouter.Get("/notes", GetNotes)
userRouter.Get("/notes/{note}", GetNote)
userRouter.Post("/notes/{note}", PostNote)
r.Mount("/", userRouter)
// API =============
apiRouter := chi.NewRouter()
apiRouter.Use(BasicAuth)
apiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetFile("readme", w) })
apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFile("readme", w, r) })
apiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { GetFileList("day", w) })