~aleteoryx/webjrnl

413e986d04cd1d0105a2234b5485413c9086c713 — Aleteoryx 10 days ago 821b974 fileent
I don't think I'll use this
9 files changed, 229 insertions(+), 110 deletions(-)

M README.md
M db.go
M jrnl.go
M parse.go
M plan.txt
M template/base.html
M template/dash.html
M template/ntries.html
M web.go
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>&nbsp;</td>
		{{if not $hasToday}}
		{{if not $Today.Exists}}
		<td><form method="POST" action="./jrnl/{{ .Today.Year }}">
			<input type="hidden" name="text" value="&gt; {{ .Today }}&#10;" />
			<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="&gt; {{ .Yesterday }} &#10;" />
			<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 } )
}