~aleteoryx/webjrnl

e5970b54b302888a18d39d90bba4615674386235 — Aleteoryx 22 days ago 627b98e
everything but logging, I think
M db.go => db.go +128 -43
@@ 10,6 10,7 @@ import "io"
import "os"
import "path/filepath"
import "regexp"
import "slices"
import "sort"
import "strconv"
import "time"


@@ 38,16 39,29 @@ type Passhash struct {
}
type Location time.Location
type Entry struct {
	jrnl *Jrnl
	Year, Month, Day int
	Jrnl *Jrnl
	Date Date
}
type EntryVersion struct {
	entry *Entry
	Entry *Entry
	Id int
	Body string
	ModTime time.Time
}

type Date struct {
	Year, Month, Day int
}
func (d Date) URL() string {
	return fmt.Sprintf("%04d/%02d/%02d", d.Year, d.Month, d.Day)
}
func (d Date) String() string {
	return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day)
}
func Time2Date(ts time.Time) Date {
	return Date { ts.Year(), int(ts.Month()), ts.Day() }
}


func (p *Passhash) UnmarshalJSON(b []byte) error {
	var s string


@@ 124,8 138,8 @@ func (j *Jrnl) Path() string {
	return filepath.Join(Mtpt, j.Id)
}

func (j *Jrnl) GetText() (s string, err error) {
	ents, err := j.ListEntries()
func (j *Jrnl) GetYearText(year int) (s string, err error) {
	ents, err := j.ListYearEntries(year)
	if err != nil {
		return s, err
	}


@@ 141,33 155,73 @@ func (j *Jrnl) GetText() (s string, err error) {
			return "", err
		}

		s += fmt.Sprintf("> %04d-%02d-%02d\n\n%s\n\n\n", e.Year, e.Month, e.Day, v.Body)
		s += v.GetText()
	}

	return s, nil
}
func (j *Jrnl) GetText() (s string, err error) {
	years, err := j.ListYears()
	if err != nil {
		return s, err
	}

	for _, year := range years {
		yBody, err := j.GetYearText(year)
		if err != nil {
			return "", err
		}
		s += yBody
	}

	return s, nil
}
func (j *Jrnl) GetBackup() (s string, err error) {
	ents, err := j.ListEntries()
	if err != nil {
		return s, err
	}

	for _, e := range ents {
		vers, err := e.GetVersions()
		if err != nil {
			return s, err
		}

		slices.Reverse(vers)

		for _, v := range vers {
			s += v.GetText()
		}
	}

	return s, nil
}

func (j *Jrnl) PutText(s string) error {
func (j *Jrnl) PutText(s string) (vers []EntryVersion, err error) {
	for {
		if ts, body, ok := NextEntry(&s); ok {
			_, err := j.PutEntry(ts.Year(), int(ts.Month()), ts.Day(), body)
			v, new, err := j.PutEntry(Time2Date(ts), body)
			if err != nil {
				return err
				return vers, err
			}
			if new {
				vers = append(vers, v)
			}
		} else {
			break
		}
	}
	return nil
	return vers, nil
}

func (j *Jrnl) ListEntries() (ents []Entry, err error) {
func (j *Jrnl) ListYears() (years []int, err error) {
	readdir, err := os.ReadDir(j.Path())
	if err != nil {
		return ents, err
		return years, err
	}

	years := make([]int, 0, len(readdir))
	years = make([]int, 0, len(readdir))
	for _, ent := range readdir {
		if !ent.IsDir() { continue }
		if n, err := strconv.ParseUint(ent.Name(), 10, 32); err == nil {


@@ 177,30 231,58 @@ func (j *Jrnl) ListEntries() (ents []Entry, err error) {

	sort.Slice(years, func(i, j int) bool { return years[i] > years[j] })

	return
}
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 {
				return ents, err
			}
		}
	}

	return ents, err
}
func (j *Jrnl) ListEntries() (ents []Entry, err error) {
	years, err := j.ListYears()
	if err != nil {
		return ents, err
	}

	ents = make([]Entry, 0, len(years) * 365)
	for _, year := range years {
		for month := 12; month > 0; month-- {
			for day := 31; day > 0; day-- {
				if e, err := j.GetEntry(year, month, day); err == nil {
					ents = append(ents, e)
				} else if err != NotFound {
					return ents, err
				}
			}
		yEnts, err := j.ListYearEntries(year)
		if err != nil {
			return ents, err
		}
		ents = append(ents, yEnts...)
	}

	return ents, err
}
func (j *Jrnl) newEntry(year, month, day int) (e Entry) {
	e.jrnl = j
	e.Year = year
	e.Month = month
	e.Day = day

func (j *Jrnl) newEntry(date Date) (e Entry) {
	e.Jrnl = j
	e.Date = date
	return
}
func (j *Jrnl) GetEntry(year, month, day int) (e Entry, err error) {
	e = j.newEntry(year, month, day)
func (j *Jrnl) HasEntry(date Date) (exists bool, err error) {
	e := j.newEntry(date)

	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
	} else {
		return false, err
	}
}
func (j *Jrnl) GetEntry(date Date) (e Entry, err error) {
	e = j.newEntry(date)

	if fi, err := os.Stat(e.Path()); fi != nil && fi.IsDir() {
		return e, nil


@@ 210,8 292,8 @@ func (j *Jrnl) GetEntry(year, month, day int) (e Entry, err error) {
		return e, err
	}
}
func (j *Jrnl) PutEntry(year, month, day int, text string) (v EntryVersion, err error) {
	e := j.newEntry(year, month, day)
func (j *Jrnl) PutEntry(date Date, text string) (v EntryVersion, new bool, err error) {
	e := j.newEntry(date)
	path := e.Path()

	if err = os.MkdirAll(path, 0750); err != nil {


@@ 220,18 302,18 @@ func (j *Jrnl) PutEntry(year, month, day int, text string) (v EntryVersion, err 

	n, err := e.GetLatest()
	if err != nil {
		return
		return v, new, err
	}

	// don't add a duplicate version
	if n >= 0 {
		latest, err := e.GetVersion(n)
		if err != nil {
			return v, err
			return v, new, err
		}

		if latest.Body == text {
			return latest, err
			return latest, false, err
		}
	}



@@ 241,7 323,7 @@ func (j *Jrnl) PutEntry(year, month, day int, text string) (v EntryVersion, err 
		latestpath := filepath.Join(path, "latest")
		err = os.WriteFile(latestpath, []byte(fmt.Sprint(n)), 0640)
		if err != nil {
			return
			return v, new, err
		}

		verpath := filepath.Join(path, fmt.Sprint(n))


@@ 249,23 331,22 @@ func (j *Jrnl) PutEntry(year, month, day int, text string) (v EntryVersion, err 
		if err != nil && err.(*os.PathError).Err.Error() == "file exists" {
			continue
		} else if err != nil {
			return v, err
			return v, new, err
		}

		_, err = fp.WriteString(text)
		fp.Close()
		if err != nil {
			return v, err
			return v, new, err
		} else {
			return e.GetVersion(n)
			v, err = e.GetVersion(n)
			return v, true, err
		}
	}
}

func (e *Entry) Path() string {
	return filepath.Join(
		e.jrnl.Path(),
		fmt.Sprintf("%04d/%02d/%02d", e.Year, e.Month, e.Day) )
	return filepath.Join(e.Jrnl.Path(), e.Date.URL())
}
func (e *Entry) GetLatest() (n int, err error) {
	data, err := os.ReadFile(filepath.Join(e.Path(), "latest"))


@@ 283,7 364,7 @@ func (e *Entry) GetLatest() (n int, err error) {
	}
}
func (e *Entry) GetVersion(n int) (v EntryVersion, err error) {
	v.entry = e
	v.Entry = e
	v.Id = n

	path := v.Path()


@@ 294,7 375,7 @@ func (e *Entry) GetVersion(n int) (v EntryVersion, err error) {
	} else if err != nil {
		return v, err
	}
	v.ModTime = fi.ModTime()
	v.ModTime = fi.ModTime().In((*time.Location)(e.Jrnl.Timezone))

	body, err := os.ReadFile(path)
	if err != nil {


@@ 323,5 404,9 @@ func (e *Entry) GetVersions() (vers []EntryVersion, err error) {
}

func (v *EntryVersion) Path() string {
	return filepath.Join(v.entry.Path(), fmt.Sprint(v.Id))
	return filepath.Join(v.Entry.Path(), fmt.Sprint(v.Id))
}
func (v *EntryVersion) GetText() string {
	e := v.Entry
	return fmt.Sprintf("> %s\n\n%s\n\n\n", e.Date, v.Body)
}

M jrnl.go => jrnl.go +26 -5
@@ 1,6 1,8 @@
package main

import "fmt"
import "log"
import "net/http"
import "os"

func fatal(err error) {


@@ 18,7 20,7 @@ func main() {
		usage()
	} 

//	addr := os.Args[1]
	addr := os.Args[1]
	if len(os.Args) == 3 {
		Mtpt = os.Args[2]
	} else {


@@ 29,8 31,27 @@ func main() {
		fatal(err)
	}

	data, err := os.ReadFile("/home/amity/Documents/jrnl2.txt")
	if err != nil { panic(err) }
	Jrnls["amity"].PutText(string(data))
	fmt.Println(Jrnls["amity"].GetText())
	http.HandleFunc("/{$}", Redirect("/dash", 301))
	http.HandleFunc("GET /dash", Dash)

	http.HandleFunc("GET /goto", GotoRedirect)

	http.HandleFunc("GET /init", InitGet)
	http.HandleFunc("POST /init", InitPost)

	http.HandleFunc("GET /save", SaveGet)

	http.HandleFunc("GET /jrnl/{$}", JrnlRedirect)
	http.HandleFunc("GET /jrnl/{year}", JrnlGet)
	http.HandleFunc("POST /jrnl/{year}", JrnlPost)

	http.HandleFunc("GET /ntry/{$}", NtriesGet)
	http.HandleFunc("GET /ntry/{year}/{month}/{day}/{$}", NtryGet)
	http.HandleFunc("POST /ntry/{year}/{month}/{day}/{$}", NtryPost)

	http.HandleFunc("/", NotFoundGet)

	fmt.Println("initialized!")

	log.Fatal(http.ListenAndServe(addr, nil))
}

M parse.go => parse.go +3 -0
@@ 7,6 7,9 @@ const HeadFormat = "> 2006-01-02"

func NextLine(s string) (line, rest string) {
	line, rest, _ = strings.Cut(s, "\n")
	if line[len(line) - 1] == '\r' {
		line = line[:len(line) - 1]
	}
	return
}


M plan.txt => plan.txt +15 -10
@@ 1,19 1,24 @@
endpoints:
	/
		redirect to /dash
	/dash - GET
		convenient links
	/jrnl - GET, POST
		the full text of the jrnl. POSTing text will lead to
		the entries in it being created.
	/ntry/ - GET, POST
		list of entry slugs, hyperlinks in html
	/ntry/{slug}/ - GET, PATCH
		entry info. list of versions, text of the most recent
		one. allows one to rename an entry or post a new ver.
	/ntry/{slug}/{ver}/ - GET
		text of a given version
	/init - GET, POST
		paste in existing jrnl contents, get back an import log
	/jrnl/ - GET
		redirect to /jrnl/currentYear
	/jrnl/(year} - GET, POST
		the full text of the jrnl for that year. POSTing text
		will lead to the entries in it being created.
	/ntry - GET
		full log
	/ntry/{year}/{month}/{day}/ - GET, POST
		entry info.
	/edit - GET
		log of edits. each edit has an ID, so polling this will
		allow js code to edit the <textarea> in realtime
	/save - GET
		complete jrnl text for backup

	HTTP basic auth


A template/404.html => template/404.html +4 -0
@@ 0,0 1,4 @@
{{template "head" .}}
	<p>Whatever you're looking for, it isn't here.</p>
{{template "foot" .}}


A template/500.html => template/500.html +6 -0
@@ 0,0 1,6 @@
{{template "head" .}}
	<p>Something went wrong.</p>
	<pre>Error Type: {{ .Type }}
Error Info: {{ .Error }}</pre>
{{template "foot" .}}


A template/base.html => template/base.html +27 -0
@@ 0,0 1,27 @@
{{define "head"}}
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
	"http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
	<title>{{.Title}}</title>
	<style>
		textarea {
			background-color: #ffe;
			color: #223
		}
		html {
			background-color: #eef;
			color: #322
		}
	</style>
</head>
<body>
{{.Links}}
<h1>{{.Title}}</h1>
{{end}}

{{define "foot"}}
<p class="footer"><em>powered by <a href="https://git.amehut.dev/~aleteoryx/webjrnl">jrnl v0</a></em></p>
</body>
</html>
{{end}}

A template/dash.html => template/dash.html +32 -0
@@ 0,0 1,32 @@
{{template "head" .}}
	<p>o/ {{.Jrnl.Id}}</p>
	{{ $hasToday := .Jrnl.HasEntry .Today }}
	{{ $hasYesterday := .Jrnl.HasEntry .Yesterday }}

	<table cellspacing="0"><tr>
		<td>Today is {{ .Today }}.</td>
		<td>&nbsp;</td>
		{{if not $hasToday}}
		<td><form method="POST" action="jrnl/{{ .Today.Year }}">
			<input type="hidden" name="text" value="&gt; {{ .Today.URL }}&#10;" />
			<button type="submit">Start today's entry!</button>
		</form></td>
		{{end}}
		{{if not $hasYesterday}}
		<td><form method="POST" action="jrnl/{{ .Yesterday.Year }}">
			<input type="hidden" name="text" value="&gt; {{ .Yesterday.URL }} &#10;" />
			<button type="submit">Start yesterday's entry!</button>
		</form></td>
		{{end}}
	</tr></table>

	<hr/>

	<p>
		{{range $i, $year := .Jrnl.ListYears}}
		{{if ne $i 0}} • {{end}}
		<a href="jrnl/{{ $year }}">{{ $year }}</a>
		{{end}}
	</p>
{{template "foot" .}}


A template/init.html => template/init.html +9 -0
@@ 0,0 1,9 @@
{{template "head" .}}
	<p>Paste jrnl text below.</p>
	<form method="POST">
		<textarea name="text" cols="72" rows="24"></textarea>
		<br/>
		<button type="submit">Import!</button>
	</form>
{{template "foot" .}}


A template/jrnl.html => template/jrnl.html +29 -0
@@ 0,0 1,29 @@
{{template "head" .}}
	{{$text := .Jrnl.GetYearText .Year}}
	{{if eq $text ""}}
	<p>It's empty right now. Write a <code>&gt; date heading</code> to get started.</p>
	{{else}}
	<p>Edit an entry, or write a <code>&gt; date heading</code> to add a new one.</p>
	{{end}}
	<form method="POST">
		<textarea name="text" cols="72" rows="24">{{$text}}</textarea>
		<br/>
		<button type="submit">Update!</button>
	</form>
	{{if not (eq $text "")}}
	<br>
	<form method="GET" action="../goto">
		<input type="hidden" name="pfx" value="ntry/{{.Year}}/" />
		<label for="dest">View history for:</label>
		<select name="dest">
			{{range .Jrnl.ListYearEntries .Year}}
			<option value="{{printf "%02d/%02d" .Date.Month .Date.Day}}">
				{{printf "%02d-%02d" .Date.Month .Date.Day}}</option>
			{{end}}
		</select>
		&nbsp;
		<button type="submit">Go To</button>
	</form>
	{{end}}
{{template "foot" .}}


A template/ntries.html => template/ntries.html +15 -0
@@ 0,0 1,15 @@
{{template "head" .}}
	{{ $pfx := .Pfx }}
	<p>{{ .Blurb }}</p>
	{{if ne (len .Ntries) 0}}
	<ul>
		{{range $i, $e := .Ntries}}
		<li>
			<a href="{{ $pfx }}ntry/{{ $e.Entry.Date.URL }}">{{ $e.Entry.Date }}</a>
			v{{ $e.Id }}
		</li>
		{{end}}
	</ul>
	{{end}}
{{template "foot" .}}


A template/ntry.html => template/ntry.html +37 -0
@@ 0,0 1,37 @@
{{template "head" .}}
	{{ $latest := .Entry.GetVersion .Entry.GetLatest }}
	<p>
		{{if eq $latest.Id 0}}
		It hasn't been updated.
		{{else if eq $latest.Id 1}}
		It's been updated once.
		{{else}}
		It's been updated {{ $latest.Id }} times.
		{{end}}
		This version is from {{ $latest.ModTime.Format "2006-01-02 03:04:05" }}.
	</p>
	<form method="POST">
		<textarea name="text" cols="72" rows="24">{{ $latest.Body }}</textarea>
		<br/>
		<button type="submit">Update!</button>
	</form>
	<br>
	{{if ne $latest.Id 0}}
	<h3>History:</h3>
	<ul>
		{{range .Entry.GetVersions}}
		{{if ne .Id $latest.Id}}
		<li>
			<h4>{{ .ModTime.Format "2006-01-02 03:04:05" }}</h4>
			<pre>{{ .Body }}</pre>
			<form method="POST">
				<input type="hidden" name="text" value="{{ .Body }}" />
				<button type="submit">Revert</button>
			</form>
		</li>
		{{end}}
		{{end}}
	</ul>
	{{end}}
{{template "foot" .}}


A template/save.html => template/save.html +7 -0
@@ 0,0 1,7 @@
{{template "head" .}}
	<p>This is a full backup of your jrnl, including every version of every entry.
	   Modification dates are not preserved.</p>
	<textarea name="text" cols="72" rows="24">{{ .Jrnl.GetBackup }}</textarea>
	<form method="GET"><button name="download">Download!</button></form>
{{template "foot" .}}


A web.go => web.go +472 -0
@@ 0,0 1,472 @@
package main

import "embed"
import "fmt"
import "html/template"
import "net/http"
import "reflect"
import "strconv"
import "strings"
import "time"


const dashLinks =
	template.HTML(
		"<p><a href=\"ntry\">entries</a> • " +
		"<a href=\"init\">import</a> • " +
		"<a href=\"save\">export</a></p>" )

//go:embed template/*
var templateFS embed.FS
var Templates *template.Template
func init() {
	var err error

	Templates, err = template.ParseFS(templateFS, "template/*.html")
	if err != nil {
		panic(err)
	}
}


func Req2Date(r *http.Request) (date Date, ok bool) {
	year, err := strconv.ParseUint(r.PathValue("year"), 10, 32)
	if err != nil {
		return date, false
	}
	month, err := strconv.ParseUint(r.PathValue("month"), 10, 32)
	if err != nil {
		return date, false
	}
	day, err := strconv.ParseUint(r.PathValue("day"), 10, 32)
	if err != nil {
		return date, false
	}

	date = Time2Date(time.Date(int(year), time.Month(month), int(day), 0, 0, 0, 0, time.UTC))
	if date.Year != int(year) || date.Month != int(month) || date.Day != int(day) {
		return date, false
	}

	return date, true
}

func NotImplemented(w http.ResponseWriter, r *http.Request) {
	http.Error(w, "Not Implemented!", 400)
}


func Login(r *http.Request) *Jrnl {
	user, pass, ok := r.BasicAuth()
	if !ok {
		return nil
	}

	jrnl, ok := Jrnls[user]
	if !ok {
		return nil
	}

	if !jrnl.Passhash.Check(pass) {
		return nil
	} else {
		return jrnl
	}
}
func MustLogin(w http.ResponseWriter, r *http.Request) *Jrnl {
	jrnl := Login(r)
	if jrnl == nil {
		w.Header().Add("WWW-Authenticate", "Basic realm=\"jrnl\"")
		http.Error(w, "Unauthorized!", 401)
		return nil
	} else {
		return jrnl
	}
}

type Err500Page struct {
	Links template.HTML
	Title string
	Type reflect.Type
	Error error
}
func Err500(w http.ResponseWriter, r *http.Request, jrnl *Jrnl, err error) {
	w.WriteHeader(500)
	ReplyTemplate(
		w, r, jrnl, "500.html",
		Err500Page {
		Links: BuildLinks(r, jrnl),
		Title: "Jrnl Server Error",
		Type: reflect.TypeOf(err),
		Error: err } )
}

func NotFoundGet(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	NotFoundPage(w, r, jrnl)
}
type NotFoundPageData struct {
	Links template.HTML
	Title string
}
func NotFoundPage(w http.ResponseWriter, r *http.Request, jrnl *Jrnl) {
	w.WriteHeader(404)
	ReplyTemplate(
		w, r, jrnl, "404.html",
		NotFoundPageData {
		Links: BuildLinks(r, jrnl),
		Title: "Jrnl Page Not Found" } )
}

func ReplyTemplate(w http.ResponseWriter, r *http.Request, jrnl *Jrnl, tpl string, ctx interface{}) {
	var buf strings.Builder

	err := Templates.ExecuteTemplate(&buf, tpl, ctx)
	if err != nil {
		msg := fmt.Sprintf("error in template %s: %s", tpl, err)
		fmt.Println(msg)
		Err500(w, r, jrnl, err)
	} else {
		w.Write([]byte(buf.String()))
	}
}

func Redirect(loc string, code int) func(http.ResponseWriter, *http.Request) {
	return func(w http.ResponseWriter, r *http.Request) {
		jrnl := MustLogin(w, r)
		if jrnl == nil {
			return
		}

		http.Redirect(w, r, loc, code)
	}
}

func BuildLinks(r *http.Request, jrnl *Jrnl) template.HTML {
	if jrnl == nil {
		return template.HTML("")
	}

	pfx := strings.Repeat("../", strings.Count(r.URL.Path, "/"))
	links := fmt.Sprintf("<a href=\"%sdash\">dash</a>", pfx)

	years, _ := jrnl.ListYears()
	for i, year := range years {
		if i < 5 {
			links += " • "
			links += fmt.Sprintf(
				"<a href=\"%sjrnl/%04d\">jrnl/%04d</a>",
				pfx, year, year )
		} else {
			break
		}
	}

	return template.HTML("<p>" + links + "</p>")
}


func NtriesGet(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	ents, err := jrnl.ListEntries()
	if err != nil {
		Err500(w, r, jrnl, err)
		return
	}

	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)
		if err != nil {
			Err500(w, r, jrnl, err)
			return
		}

		vers[i] = v
	}

	NtriesPage(
		w, r, jrnl,
		"Jrnl Entry Index",
		"This is a log of every jrnl entry you've made.",
		vers )
}

type NtriesPageData struct {
	Links template.HTML
	Title, Blurb string
	Ntries []EntryVersion
	Pfx string
}
func NtriesPage(w http.ResponseWriter, r *http.Request, jrnl *Jrnl, Title, Blurb string, ntries []EntryVersion) {
	pfx := strings.Repeat("../", strings.Count(r.URL.Path, "/"))
	ReplyTemplate(
		w, r, jrnl, "ntries.html",
		NtriesPageData {
			Links: BuildLinks(r, jrnl),
			Title: Title, Blurb: Blurb,
			Ntries: ntries,
			Pfx: pfx } )
}

type DashPage struct {
	Links template.HTML
	Title string
	Jrnl *Jrnl
	Today, Yesterday Date
}
func Dash(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	today := time.Now().In((*time.Location)(jrnl.Timezone))
	yesterday := today.AddDate(0,0,-1)

	ReplyTemplate(
		w, r, jrnl, "dash.html",
		DashPage {
			Links: dashLinks,
			Title: "Jrnl Dash",
			Jrnl: jrnl,
			Today: Time2Date(today),
			Yesterday: Time2Date(yesterday), } )
}


func GotoRedirect(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	query := r.URL.Query()
	pfx, _ := query["pfx"]
	dest, _ := query["dest"]

	path := strings.Join(pfx, "") + strings.Join(dest, "")

	http.Redirect(w, r, path, 301)
}


func JrnlRedirect(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	years, err := jrnl.ListYears()
	if err != nil {
		http.Error(w, err.Error(), 500)
	} else if len(years) > 0 {
		http.Redirect(w, r, fmt.Sprintf("%04d", years[0]), 302)
	} else {
		http.Redirect(w, r, "../init", 302)
	}
}

func JrnlGet(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	year, err := strconv.ParseUint(r.PathValue("year"), 10, 32)
	if err != nil {
		NotFoundPage(w, r, jrnl)
		return
	}

	JrnlPage(w, r, jrnl, int(year))
}
func JrnlPost(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	vers, err := jrnl.PutText(r.FormValue("text"))
	if err != nil {
		Err500(w, r, jrnl, err)
		return
	}

	urlYear, err := strconv.ParseUint(r.PathValue("year"), 10, 32)
	if err != nil {
		NotFoundPage(w, r, jrnl)
		return
	}

	if len(vers) == 0 || (len(vers) == 1 && vers[0].Entry.Date.Year == 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)
	} else {
		NtriesPage(
			w, r, jrnl,
			"Jrnl Update",
			"The following entries were changed:",
			vers )
	}
}

type JrnlPageData struct {
	Links template.HTML
	Title string
	Jrnl *Jrnl
	Year int
}
func JrnlPage(w http.ResponseWriter, r *http.Request, jrnl *Jrnl, year int) {
	ReplyTemplate(
		w, r, jrnl, "jrnl.html",
		JrnlPageData {
			Links: BuildLinks(r, jrnl),
			Title: fmt.Sprintf("Jrnl for Year %04d", year),
			Jrnl: jrnl,
			Year: year} )
}


type InitPageData struct {
	Links template.HTML
	Title string
}
func InitGet(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	ReplyTemplate(
		w, r, jrnl, "init.html",
		InitPageData { BuildLinks(r, jrnl), "Jrnl Import" } )
}
func InitPost(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	vers, err := jrnl.PutText(r.FormValue("text"))
	if err != nil {
		Err500(w, r, jrnl, err)
		return
	}

	var blurb string
	if len(vers) < 1 {
		blurb = "No entries were imported."
	} else if len(vers) == 1 {
		blurb = "The following entry was imported:"
	} else {
		blurb = "The following entries were imported:"
	}

	NtriesPage(w, r, jrnl, "Jrnl Import", blurb, vers)
}

type SavePageData struct {
	Links template.HTML
	Title string
	Jrnl *Jrnl
}
func SaveGet(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	query := r.URL.Query()
	if _, ok := query["download"]; ok {
		backup, err := jrnl.GetBackup()
		if err != nil {
			Err500(w, r, jrnl, err)
			return
		}

		w.Header().Add("Content-Type", "text/plain; charset=UTF-8")
		w.Header().Add("Content-Disposition", "attachment; filename=\"backup.txt\"")
		w.Write([]byte(backup))
	} else {
		ReplyTemplate(
			w, r, jrnl, "save.html",
			SavePageData {
				Links: BuildLinks(r, jrnl),
				Title: "Jrnl Export",
				Jrnl: jrnl } )
	}
}

func NtryGet(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	date, ok := Req2Date(r)
	if !ok {
		NotFoundPage(w, r, jrnl)
		return
	}

	NtryPage(w, r, jrnl, date)
}
func NtryPost(w http.ResponseWriter, r *http.Request) {
	jrnl := MustLogin(w, r)
	if jrnl == nil {
		return
	}

	date, ok := Req2Date(r)
	if !ok {
		NotFoundPage(w, r, jrnl)
		return
	}

	_, _, err := jrnl.PutEntry(date, r.FormValue("text"))
	if err != nil {
		Err500(w, r, jrnl, err)
		return
	}

	NtryPage(w, r, jrnl, date)
}

type NtryPageData struct {
	Links template.HTML
	Title string
	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
	}

	ReplyTemplate(
		w, r, jrnl, "ntry.html",
		NtryPageData {
			Links: BuildLinks(r, jrnl),
			Title: fmt.Sprintf("Jrnl Entry for %s", date),
			Entry: &ent } )
}