diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d610f87 --- /dev/null +++ b/CHANGELOG.md @@ -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 :) \ No newline at end of file diff --git a/README.md b/README.md index 0fcb641..4fb37d7 100644 --- a/README.md +++ b/README.md @@ -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/`. 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/ - get file contents for a specific day + +GET /notes - get JSON list of all named notes +GET /notes/ - get file contents for a specific note +POST /notes/ - 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 ``` \ No newline at end of file diff --git a/TODO.md b/TODO.md index 96292cf..d134465 100644 --- a/TODO.md +++ b/TODO.md @@ -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 \ No newline at end of file diff --git a/api.go b/api.go index 483b90a..aee91d4 100644 --- a/api.go +++ b/api.go @@ -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 diff --git a/config.go b/config.go index 3579aa9..d3be98e 100644 --- a/config.go +++ b/config.go @@ -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 { diff --git a/config/config.txt b/config/config.txt index d0a665e..20fa453 100644 --- a/config/config.txt +++ b/config/config.txt @@ -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 diff --git a/files.go b/files.go index 920684c..8e45870 100644 --- a/files.go +++ b/files.go @@ -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) } diff --git a/i18n/en.json b/i18n/en.json index 5d7a4ba..b3d3860 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -6,10 +6,12 @@ "title.notes": "Notes", "link.today": "today", + "link.tomorrow": "tomorrow", "link.days": "previous days", "link.notes": "notes", "description.notes": "/notes/ for a new note", - "misc.date": "Today is", + "time.date": "Today is", + "time.grace": "grace period active", "button.save": "Save" } \ No newline at end of file diff --git a/i18n/ru.json b/i18n/ru.json index b883cad..6e16bbf 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -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": "Сохранить" } \ No newline at end of file diff --git a/pages/base.html b/pages/base.html index d457a49..2f799d0 100644 --- a/pages/base.html +++ b/pages/base.html @@ -1,7 +1,7 @@ {{define "header"}}

🌺 Hibiscus.txt

-

{{translatableText "misc.date"}} a place

+

{{translatableText "time.date"}} a place

{{end}} @@ -11,10 +11,11 @@ + - Hibiscus + Hibiscus.txt {{template "header" .}} @@ -22,7 +23,11 @@ {{template "main" .}} {{template "footer" .}} - + {{end}} diff --git a/public/date.js b/public/date.js index a372f04..931b270 100644 --- a/public/date.js +++ b/public/date.js @@ -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()) } diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..308f96a --- /dev/null +++ b/public/manifest.json @@ -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" +} diff --git a/routes.go b/routes.go index 32e8cf7..48404d2 100644 --- a/routes.go +++ b/routes.go @@ -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 } diff --git a/serve.go b/serve.go index d3f21a7..e2a158f 100644 --- a/serve.go +++ b/serve.go @@ -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) })