M README.md => README.md +7 -0
@@ 135,6 135,13 @@ connection.
the contents of the database are stored in plaintext.
+## versioning
+
+this project is v0. when I consider it feature-complete, it will be v1. it may be a while until it is v1. presumably, if ever I decide there should be even moar features (unlikely), it will become v2
+
+I promise to not intentionally destroy your database, but it is your responsibility to stop me. take backups often!
+
+
## compatibility
needs testing:
M db.go => db.go +131 -45
@@ 13,6 13,7 @@ import "regexp"
import "slices"
import "sort"
import "strconv"
+import "strings"
import "time"
@@ 38,12 39,22 @@ type Passhash struct {
Hash [20]byte
}
type Location time.Location
-type Entry struct {
+type Entry interface {
+ Name() string
+ Title() string
+ URL() string
+ Path() string
+}
+type DateEntry struct {
Jrnl *Jrnl
Date Date
}
+type FileEntry struct {
+ Jrnl *Jrnl
+ File string
+}
type EntryVersion struct {
- Entry *Entry
+ Entry Entry
Id int
Body string
ModTime time.Time
@@ 149,12 160,7 @@ func (j *Jrnl) GetYearText(year int) (s string, err error) {
}
for _, e := range ents {
- n, err := e.GetLatest()
- if err != nil {
- return "", err
- }
-
- v, err := e.GetVersion(n)
+ v, err := j.GetLatestVersion(e)
if err != nil {
return "", err
}
@@ 181,13 187,19 @@ func (j *Jrnl) GetText() (s string, err error) {
return s, nil
}
func (j *Jrnl) GetBackup() (s string, err error) {
- ents, err := j.ListEntries()
+ ents1, err := j.ListDateEntries()
+ if err != nil {
+ return s, err
+ }
+ ents2, err := j.ListFileEntries()
if err != nil {
return s, err
}
+ ents := append(ents1, ents2...)
+
for _, e := range ents {
- vers, err := e.GetVersions()
+ vers, err := j.GetVersions(e)
if err != nil {
return s, err
}
@@ 202,10 214,12 @@ func (j *Jrnl) GetBackup() (s string, err error) {
return s, nil
}
-func (j *Jrnl) PutText(s string) (vers []EntryVersion, err error) {
+func (j *Jrnl) PutText(s string) (vers []*EntryVersion, err error) {
for {
- if ts, body, ok := NextEntry(&s); ok {
- v, new, err := j.PutEntry(Time2Date(ts), body)
+ if name, body, ok := NextEntry(&s); ok {
+ ent := j.GetEntry(name)
+
+ v, new, err := j.PutVersion(ent, body)
if err != nil {
return vers, err
}
@@ 241,17 255,18 @@ func (j *Jrnl) ListYearEntries(year int) (ents []Entry, err error) {
ents = make([]Entry, 0, 365)
for month := 12; month > 0; month-- {
for day := 31; day > 0; day-- {
- if e, err := j.GetEntry(Date { year, month, day }); err == nil {
- ents = append(ents, e)
- } else if err != NotFound {
+ e := Entry(j.GetDateEntry(Date { year, month, day }))
+ if ex, err := j.Exists(e); err != nil {
return ents, err
+ } else if ex {
+ ents = append(ents, e)
}
}
}
return ents, err
}
-func (j *Jrnl) ListEntries() (ents []Entry, err error) {
+func (j *Jrnl) ListDateEntries() (ents []Entry, err error) {
years, err := j.ListYears()
if err != nil {
return ents, err
@@ 268,50 283,116 @@ func (j *Jrnl) ListEntries() (ents []Entry, err error) {
return ents, err
}
+func (j *Jrnl) ListFileEntries() (ents []Entry, err error) {
+ readdir, err := os.ReadDir(filepath.Join(j.Path(), "file"))
+ if err != nil && err.(*os.PathError).Err.Error() == "no such file or directory" {
+ return ents, nil
+ } else if err != nil {
+ return ents, nil
+ }
+
+ ents = make([]Entry, 0, len(readdir))
+ mods := make([]time.Time, 0, len(ents))
+ for _, ent := range readdir {
+ if !ent.IsDir() { continue }
+ name := ent.Name()
+ if len(name) < 1 || name[0] != '_' { continue }
-func (j *Jrnl) newEntry(date Date) (e Entry) {
+ e := Entry(j.GetFileEntry(name[1:]))
+ ents = append(ents, e)
+
+ v, err := j.GetLatestVersion(e)
+ if err != nil {
+ return ents, err
+ }
+ mods = append(mods, v.ModTime)
+ }
+
+ sort.Slice(ents, func(i, j int) bool { return mods[i].Before(mods[j]) })
+
+ return
+}
+
+func (j *Jrnl) GetDateEntry(date Date) (e *DateEntry) {
+ e = new(DateEntry)
e.Jrnl = j
e.Date = date
+
return
}
-func (j *Jrnl) HasEntry(date Date) (exists bool, err error) {
- e := j.newEntry(date)
+func (e *DateEntry) Name() string {
+ return e.Date.String()
+}
+func (e *DateEntry) Title() string {
+ return fmt.Sprintf("Jrnl Entry for %s", e.Date)
+}
+func (e *DateEntry) URL() string {
+ return e.Date.URL()
+}
+func (e *DateEntry) Path() string {
+ return filepath.Join(e.Jrnl.Path(), e.Date.URL())
+}
- if fi, err := os.Stat(e.Path()); fi != nil && fi.IsDir() {
- return true, nil
- } else if err.(*os.PathError).Err.Error() == "no such file or directory" {
- return false, nil
+func (j *Jrnl) GetFileEntry(file string) (e *FileEntry) {
+ if strings.ContainsRune(file, '/') {
+ return nil
+ }
+
+ e = new(FileEntry)
+ e.Jrnl = j
+ e.File = file
+
+ return
+}
+func (e *FileEntry) Name() string {
+ return e.File
+}
+func (e *FileEntry) Title() string {
+ return fmt.Sprintf("Jrnl File: %s", e.File)
+}
+func (e *FileEntry) URL() string {
+ return filepath.Join("ntry", e.File)
+}
+func (e *FileEntry) Path() string {
+ return filepath.Join(e.Jrnl.Path(), "file/_" + e.File)
+}
+
+func (j *Jrnl) GetEntry(slug string) Entry {
+ ts, err := time.Parse(HeadFormat, slug)
+ if err != nil {
+ return j.GetDateEntry(Time2Date(ts))
} else {
- return false, err
+ slug = strings.ReplaceAll(slug, "/", "_")
+ return j.GetFileEntry(slug)
}
+
}
-func (j *Jrnl) GetEntry(date Date) (e Entry, err error) {
- e = j.newEntry(date)
+func (j *Jrnl) Exists(e Entry) (exists bool, err error) {
if fi, err := os.Stat(e.Path()); fi != nil && fi.IsDir() {
- return e, nil
+ return true, nil
} else if err.(*os.PathError).Err.Error() == "no such file or directory" {
- return e, NotFound
+ return false, nil
} else {
- return e, err
+ return false, err
}
}
-func (j *Jrnl) PutEntry(date Date, text string) (v EntryVersion, new bool, err error) {
- e := j.newEntry(date)
+
+func (j *Jrnl) PutVersion(e Entry, text string) (v *EntryVersion, new bool, err error) {
path := e.Path()
if err = os.MkdirAll(path, 0750); err != nil {
return
}
- n, err := e.GetLatest()
+ n, err := j.GetLatest(e)
if err != nil {
return v, new, err
}
// don't add a duplicate version
if n >= 0 {
- latest, err := e.GetVersion(n)
+ latest, err := j.GetVersion(e, n)
if err != nil {
return v, new, err
}
@@ 343,16 424,20 @@ func (j *Jrnl) PutEntry(date Date, text string) (v EntryVersion, new bool, err e
if err != nil {
return v, new, err
} else {
- v, err = e.GetVersion(n)
+ v, err = j.GetVersion(e, n)
return v, true, err
}
}
}
-func (e *Entry) Path() string {
- return filepath.Join(e.Jrnl.Path(), e.Date.URL())
+func (j *Jrnl) GetLatestVersion(e Entry) (v *EntryVersion, err error) {
+ n, err := j.GetLatest(e)
+ if err != nil {
+ return v, err
+ }
+ return j.GetVersion(e, n)
}
-func (e *Entry) GetLatest() (n int, err error) {
+func (*Jrnl) GetLatest(e Entry) (n int, err error) {
data, err := os.ReadFile(filepath.Join(e.Path(), "latest"))
if err != nil && err.(*os.PathError).Err.Error() == "no such file or directory" {
return -1, nil
@@ 367,7 452,8 @@ func (e *Entry) GetLatest() (n int, err error) {
return int(parsed), nil
}
}
-func (e *Entry) GetVersion(n int) (v EntryVersion, err error) {
+func (j *Jrnl) GetVersion(e Entry, n int) (v *EntryVersion, err error) {
+ v = new(EntryVersion)
v.Entry = e
v.Id = n
@@ 379,7 465,7 @@ func (e *Entry) GetVersion(n int) (v EntryVersion, err error) {
} else if err != nil {
return v, err
}
- v.ModTime = fi.ModTime().In((*time.Location)(e.Jrnl.Timezone))
+ v.ModTime = fi.ModTime().In((*time.Location)(j.Timezone))
body, err := os.ReadFile(path)
if err != nil {
@@ 389,15 475,15 @@ func (e *Entry) GetVersion(n int) (v EntryVersion, err error) {
return v, nil
}
-func (e *Entry) GetVersions() (vers []EntryVersion, err error) {
- n, err := e.GetLatest()
+func (j *Jrnl) GetVersions(e Entry) (vers []*EntryVersion, err error) {
+ n, err := j.GetLatest(e)
if err != nil {
return vers, err
}
- vers = make([]EntryVersion, n + 1)
+ vers = make([]*EntryVersion, n + 1)
for i := 0; i <= n; i++ {
- v, err := e.GetVersion(i)
+ v, err := j.GetVersion(e, i)
if err != nil {
return vers, err
}
@@ 412,5 498,5 @@ func (v *EntryVersion) Path() string {
}
func (v *EntryVersion) GetText() string {
e := v.Entry
- return fmt.Sprintf("> %s\n\n%s\n\n\n", e.Date, v.Body)
+ return fmt.Sprintf("> %s\n\n%s\n\n\n", e.Name, v.Body)
}
M jrnl.go => jrnl.go +2 -0
@@ 106,6 106,8 @@ func main() {
http.HandleFunc("POST /jrnl/{year}", JrnlPost)
http.HandleFunc("GET /ntry/{$}", NtriesGet)
+ http.HandleFunc("GET /ntry/{file}/{$}", NtryGet)
+ http.HandleFunc("POST /ntry/{file}/{$}", NtryPost)
http.HandleFunc("GET /ntry/{year}/{month}/{day}/{$}", NtryGet)
http.HandleFunc("POST /ntry/{year}/{month}/{day}/{$}", NtryPost)
M parse.go => parse.go +9 -10
@@ 1,9 1,8 @@
package main
import "strings"
-import "time"
-const HeadFormat = "> 2006-01-02"
+const HeadFormat = "2006-01-02"
func NextLine(s string) (line, rest string) {
line, rest, _ = strings.Cut(s, "\n")
@@ 13,17 12,17 @@ func NextLine(s string) (line, rest string) {
return
}
-func NextEntry(s *string) (ts time.Time, body string, ok bool) {
- var err error
-
+func NextEntry(s *string) (name, body string, ok bool) {
for {
if *s == "" {
- return ts, body, false
+ return name, body, false
}
line, rest := NextLine(*s)
- *s = rest
- if ts, err = time.Parse(HeadFormat, line); err == nil {
+ *s = rest
+
+ if len(line) > 1 && line[0] == '>' {
+ name = line[1:]
break
}
}
@@ 34,7 33,7 @@ func NextEntry(s *string) (ts time.Time, body string, ok bool) {
}
line, rest := NextLine(*s)
- if _, err = time.Parse(HeadFormat, line); err != nil {
+ if len(line) < 1 || line[0] != '>' {
body += line + "\n"
*s = rest
} else {
@@ 42,5 41,5 @@ func NextEntry(s *string) (ts time.Time, body string, ok bool) {
}
}
- return ts, strings.TrimSpace(body), true
+ return strings.TrimSpace(name), strings.TrimSpace(body), true
}
M plan.txt => plan.txt +8 -5
@@ 12,6 12,8 @@ endpoints:
will lead to the entries in it being created.
/ntry - GET
full log
+ /ntry/{file}/ - GET, POST
+ file entry. these have any name and work like text files
/ntry/{year}/{month}/{day}/ - GET, POST
entry info.
/edit - GET
@@ 35,8 37,9 @@ frontend:
db:
/users.json
user list, assumed to be immutable
- /{username}/{year}/{month}/{day}
- /latest
- latest entry version, just a base 10 number
- /{n}
- entry text for version n
+ /{username}
+ /{year}/{month}/{day}, /{file}
+ /latest
+ latest entry version, just a base 10 number
+ /{n}
+ entry text for version n
M template/base.html => template/base.html +2 -2
@@ 7,11 7,11 @@
<style>
textarea {
background-color: #ffe;
- color: #223
+ color: #223;
}
html {
background-color: #eef;
- color: #322
+ color: #322;
}
</style>
</head>
M template/dash.html => template/dash.html +4 -4
@@ 1,18 1,18 @@
{{template "head" .}}
<p>o/ {{.Jrnl.Id}}</p>
- {{ $hasToday := .Jrnl.HasEntry .Today }}
- {{ $hasYesterday := .Jrnl.HasEntry .Yesterday }}
+ {{ $Today := .Jrnl.GetDateEntry .Today }}
+ {{ $Yesterday := .Jrnl.GetDateEntry .Yesterday }}
<table cellspacing="0"><tr>
<td>Today is {{ .Today }}.</td>
<td> </td>
- {{if not $hasToday}}
+ {{if not $Today.Exists}}
<td><form method="POST" action="./jrnl/{{ .Today.Year }}">
<input type="hidden" name="text" value="> {{ .Today }} " />
<input type="submit" value="Start today's entry!" />
</form></td>
{{end}}
- {{if not $hasYesterday}}
+ {{if not $Yesterday.Exists}}
<td><form method="POST" action="./jrnl/{{ .Yesterday.Year }}">
<input type="hidden" name="text" value="> {{ .Yesterday }} " />
<input type="submit" value="Start yesterday's entry!" />
M template/ntries.html => template/ntries.html +13 -3
@@ 1,11 1,21 @@
{{template "head" .}}
{{ $pfx := .Pfx }}
<p>{{ .Blurb }}</p>
- {{if ne (len .Ntries) 0}}
+ {{if ne (len .Ntries1) 0}}
<ul>
- {{range $i, $e := .Ntries}}
+ {{range $i, $e := .Ntries1}}
<li>
- <a href="./{{ $pfx }}ntry/{{ $e.Entry.Date.URL }}">{{ $e.Entry.Date }}</a>
+ <a href="./{{ $pfx }}ntry/{{ $e.Entry.URL }}">{{ $e.Entry.Name }}</a>
+ v{{ $e.Id }}
+ </li>
+ {{end}}
+ </ul>
+ {{end}}
+ {{if ne (len .Ntries2) 0}}
+ <ul>
+ {{range $i, $e := .Ntries2}}
+ <li>
+ <a href="./{{ $pfx }}ntry/{{ $e.Entry.URL }}">{{ $e.Entry.Name }}</a>
v{{ $e.Id }}
</li>
{{end}}
M web.go => web.go +53 -41
@@ 52,6 52,15 @@ func Req2Date(r *http.Request) (date Date, ok bool) {
return date, true
}
+func Req2Entry(r *http.Request, j *Jrnl) Entry {
+ if file := r.PathValue("file"); file != "" {
+ return j.GetFileEntry(file)
+ } else if date, ok := Req2Date(r); ok {
+ return j.GetDateEntry(date)
+ } else {
+ return nil
+ }
+}
func NotImplemented(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not Implemented!", 400)
@@ 217,20 226,15 @@ func NtriesGet(w http.ResponseWriter, r *http.Request) {
return
}
- ents, err := jrnl.ListEntries()
+ ents, err := jrnl.ListDateEntries()
if err != nil {
Err500(w, r, jrnl, err)
return
}
- vers := make([]EntryVersion, len(ents))
+ vers := make([]*EntryVersion, len(ents))
for i, e := range ents {
- n, err := e.GetLatest()
- if err != nil {
- Err500(w, r, jrnl, err)
- return
- }
- v, err := e.GetVersion(n)
+ v, err := jrnl.GetLatestVersion(e)
if err != nil {
Err500(w, r, jrnl, err)
return
@@ 243,23 247,26 @@ func NtriesGet(w http.ResponseWriter, r *http.Request) {
w, r, jrnl,
"Jrnl Entry Index",
"This is a log of every jrnl entry you've made.",
- vers )
+ vers, nil )
}
type NtriesPageData struct {
Links template.HTML
Title, Blurb string
- Ntries []EntryVersion
+ Ntries1, Ntries2 []*EntryVersion
Pfx string
}
-func NtriesPage(w http.ResponseWriter, r *http.Request, jrnl *Jrnl, Title, Blurb string, ntries []EntryVersion) {
+func NtriesPage(
+ w http.ResponseWriter, r *http.Request, jrnl *Jrnl,
+ Title, Blurb string, ntries1, ntries2 []*EntryVersion,
+) {
pfx := strings.Repeat("../", strings.Count(r.URL.Path, "/") - 1)
ReplyTemplate(
w, r, jrnl, "ntries.html",
NtriesPageData {
Links: BuildLinks(r, jrnl),
Title: Title, Blurb: Blurb,
- Ntries: ntries,
+ Ntries1: ntries1, Ntries2: ntries2,
Pfx: pfx } )
}
@@ 335,6 342,13 @@ func JrnlGet(w http.ResponseWriter, r *http.Request) {
JrnlPage(w, r, jrnl, int(year))
}
+func YearMatch(e Entry, year int) bool {
+ if e, ok := e.(*DateEntry); ok {
+ return e.Date.Year == year
+ } else {
+ return false
+ }
+}
func JrnlPost(w http.ResponseWriter, r *http.Request) {
jrnl := MustLogin(w, r)
if jrnl == nil {
@@ 353,16 367,20 @@ func JrnlPost(w http.ResponseWriter, r *http.Request) {
return
}
- if len(vers) == 0 || (len(vers) == 1 && vers[0].Entry.Date.Year == int(urlYear)) {
+ if len(vers) == 0 || (len(vers) == 1 && YearMatch(vers[0].Entry, int(urlYear))) {
JrnlPage(w, r, jrnl, int(urlYear))
} else if len(vers) == 1 {
- http.Redirect(w, r, fmt.Sprintf("%04d", vers[0].Entry.Date.Year), 302)
+ if e, ok := vers[0].Entry.(*DateEntry); ok {
+ http.Redirect(w, r, fmt.Sprintf("%04d", e.Date.Year), 302)
+ } else {
+ http.Redirect(w, r, "/" + e.URL(), 302)
+ }
} else {
NtriesPage(
w, r, jrnl,
"Jrnl Update",
"The following entries were changed:",
- vers )
+ vers, nil )
}
}
@@ 379,7 397,7 @@ func JrnlPage(w http.ResponseWriter, r *http.Request, jrnl *Jrnl, year int) {
Links: BuildLinks(r, jrnl),
Title: fmt.Sprintf("Jrnl for Year %04d", year),
Jrnl: jrnl,
- Year: year} )
+ Year: year } )
}
@@ 418,7 436,7 @@ func InitPost(w http.ResponseWriter, r *http.Request) {
blurb = "The following entries were imported:"
}
- NtriesPage(w, r, jrnl, "Jrnl Import", blurb, vers)
+ NtriesPage(w, r, jrnl, "Jrnl Import", blurb, vers, nil)
}
type SavePageData struct {
@@ 459,13 477,21 @@ func NtryGet(w http.ResponseWriter, r *http.Request) {
return
}
- date, ok := Req2Date(r)
- if !ok {
+ e := Req2Entry(r, jrnl)
+ if e == nil {
+ NotFoundPage(w, r, jrnl)
+ return
+ }
+ ex, err := jrnl.Exists(e)
+ if err != nil {
+ Err500(w, r, jrnl, err)
+ return
+ } else if ex == false {
NotFoundPage(w, r, jrnl)
return
}
- NtryPage(w, r, jrnl, date)
+ NtryPage(w, r, jrnl, e)
}
func NtryPost(w http.ResponseWriter, r *http.Request) {
jrnl := MustLogin(w, r)
@@ 473,42 499,28 @@ func NtryPost(w http.ResponseWriter, r *http.Request) {
return
}
- date, ok := Req2Date(r)
- if !ok {
- NotFoundPage(w, r, jrnl)
- return
- }
+ e := Req2Entry(r, jrnl)
text := strings.TrimSpace(r.FormValue("text"))
- _, _, err := jrnl.PutEntry(date, text)
+ _, _, err := jrnl.PutVersion(e, text)
if err != nil {
Err500(w, r, jrnl, err)
return
}
- NtryPage(w, r, jrnl, date)
+ NtryPage(w, r, jrnl, e)
}
type NtryPageData struct {
Links template.HTML
Title string
- Entry *Entry
+ Entry Entry
}
-func NtryPage(w http.ResponseWriter, r *http.Request, jrnl *Jrnl, date Date) {
-
- ent, err := jrnl.GetEntry(date)
- if err == NotFound {
- NotFoundPage(w, r, jrnl)
- return
- } else if err != nil {
- Err500(w, r, jrnl, err)
- return
- }
-
+func NtryPage(w http.ResponseWriter, r *http.Request, jrnl *Jrnl, e Entry) {
ReplyTemplate(
w, r, jrnl, "ntry.html",
NtryPageData {
Links: BuildLinks(r, jrnl),
- Title: fmt.Sprintf("Jrnl Entry for %s", date),
- Entry: &ent } )
+ Title: e.Title(),
+ Entry: e } )
}