~aleteoryx/webjrnl

627b98e1f76461e95a92f958a4592446f16470cf — Aleteoryx 23 days ago
db done
6 files changed, 448 insertions(+), 0 deletions(-)

A .gitignore
A db.go
A go.mod
A jrnl.go
A parse.go
A plan.txt
A  => .gitignore +2 -0
@@ 1,2 @@
/db
jrnl

A  => db.go +327 -0
@@ 1,327 @@
package main

import "bytes"
import "crypto/sha1"
import "encoding/hex"
import "encoding/json"
import "errors"
import "fmt"
import "io"
import "os"
import "path/filepath"
import "regexp"
import "sort"
import "strconv"
import "time"


var stripname *regexp.Regexp
var NotFound error

func init() {
	stripname, _ = regexp.Compile("[^a-zA-Z0-9_]")
	NotFound = errors.New("object not found")
}


var Mtpt string
var Jrnls map[string]*Jrnl


type Jrnl struct {
	Id string
	Passhash Passhash
	Timezone *Location
}
type Passhash struct {
	Hash [20]byte
}
type Location time.Location
type Entry struct {
	jrnl *Jrnl
	Year, Month, Day int
}
type EntryVersion struct {
	entry *Entry
	Id int
	Body string
	ModTime time.Time
}


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

	dec := json.NewDecoder(bytes.NewBuffer(b))
	if err := dec.Decode(&s); err != nil {
		return errors.New("sha1 password hash is not a string")
	}

	if len(s) != 40 {
		return errors.New("sha1 password hash is not 40 chars")
	}

	if _, err := hex.Decode(p.Hash[:], []byte(s)); err != nil {
		return errors.New("sha1 password hash is not hexadecimal")
	}

	return nil
}
func (p *Passhash) Check(pw string) bool {
	return sha1.Sum([]byte(pw)) == p.Hash
}

func (l *Location) UnmarshalJSON(b []byte) error {
	var s string

	dec := json.NewDecoder(bytes.NewBuffer(b))
	if err := dec.Decode(&s); err != nil {
		return errors.New("timezone is not a string")
	}

	loc, err := time.LoadLocation(s)
	if err != nil {
		return err
	}

	*l = Location(*loc)

	return nil
}

func MountDB() error {
	var rawJrnls []*Jrnl

	fp, err := os.Open(filepath.Join(Mtpt, "users.json"))
	if err != nil {
		return err
	}

	dec := json.NewDecoder(fp)
	if err = dec.Decode(&rawJrnls); err == io.EOF {
		return errors.New("must have users in users.json!")
	} else if err != nil {
		return err
	}

	Jrnls = make(map [string]*Jrnl)
	for _, jrnl := range rawJrnls {
		jrnl.Id = stripname.ReplaceAllString(jrnl.Id, "")
		if _, ok := Jrnls[jrnl.Id]; ok {
			return errors.New("overlapping usernames")
		}

		Jrnls[jrnl.Id] = jrnl
		if err := os.MkdirAll(jrnl.Path(), 0750); err != nil {
			return err
		}
	}

	return nil
}

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

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

	for _, e := range ents {
		n, err := e.GetLatest()
		if err != nil {
			return "", err
		}

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

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

	return s, nil
}

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

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

	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 {
			years = append(years, int(n))
		}
	}

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

	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
				}
			}
		}
	}

	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
	return
}
func (j *Jrnl) GetEntry(year, month, day int) (e Entry, err error) {
	e = j.newEntry(year, month, day)

	if fi, err := os.Stat(e.Path()); fi != nil && fi.IsDir() {
		return e, nil
	} else if err.(*os.PathError).Err.Error() == "no such file or directory" {
		return e, NotFound
	} else {
		return e, err
	}
}
func (j *Jrnl) PutEntry(year, month, day int, text string) (v EntryVersion, err error) {
	e := j.newEntry(year, month, day)
	path := e.Path()

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

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

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

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

	for {
		n++

		latestpath := filepath.Join(path, "latest")
		err = os.WriteFile(latestpath, []byte(fmt.Sprint(n)), 0640)
		if err != nil {
			return
		}

		verpath := filepath.Join(path, fmt.Sprint(n))
		fp, err := os.OpenFile(verpath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0640)
		if err != nil && err.(*os.PathError).Err.Error() == "file exists" {
			continue
		} else if err != nil {
			return v, err
		}

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

func (e *Entry) Path() string {
	return filepath.Join(
		e.jrnl.Path(),
		fmt.Sprintf("%04d/%02d/%02d", e.Year, e.Month, e.Day) )
}
func (e *Entry) GetLatest() (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
	} else if err != nil {
		return n, err
	}

	parsed, err := strconv.ParseUint(string(data), 10, 32)
	if err != nil {
		return -1, nil
	} else {
		return int(parsed), nil
	}
}
func (e *Entry) GetVersion(n int) (v EntryVersion, err error) {
	v.entry = e
	v.Id = n

	path := v.Path()

	fi, err := os.Stat(path)
	if err != nil && err.(*os.PathError).Err.Error() == "no such file or directory" {
		return v, NotFound
	} else if err != nil {
		return v, err
	}
	v.ModTime = fi.ModTime()

	body, err := os.ReadFile(path)
	if err != nil {
		return v, err
	}
	v.Body = string(body)

	return v, nil
}
func (e *Entry) GetVersions() (vers []EntryVersion, err error) {
	n, err := e.GetLatest()
	if err != nil {
		return vers, err
	}

	vers = make([]EntryVersion, n + 1)
	for i := 0; i <= n; i++ {
		v, err := e.GetVersion(i)
		if err != nil {
			return vers, err
		}
		vers[n - i] = v
	}

	return vers, nil
}

func (v *EntryVersion) Path() string {
	return filepath.Join(v.entry.Path(), fmt.Sprint(v.Id))
}

A  => go.mod +3 -0
@@ 1,3 @@
module git.amehut.dev/~aleteoryx/jrnl

go 1.24.5

A  => jrnl.go +36 -0
@@ 1,36 @@
package main

import "fmt"
import "os"

func fatal(err error) {
	fmt.Fprintln(os.Stderr, "fatal:", err)
	os.Exit(2)
}

func usage() {
	fmt.Fprintln(os.Stderr, "usage: jrnl addr [mtpt]")
	os.Exit(1)
}

func main() {
	if 2 > len(os.Args) || len(os.Args) > 3 {
		usage()
	} 

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

	if err := MountDB(); err != nil {
		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())
}

A  => parse.go +43 -0
@@ 1,43 @@
package main

import "strings"
import "time"

const HeadFormat = "> 2006-01-02"

func NextLine(s string) (line, rest string) {
	line, rest, _ = strings.Cut(s, "\n")
	return
}

func NextEntry(s *string) (ts time.Time, body string, ok bool) {
	var err error

	for {
		if *s == "" {
			return ts, body, false
		}

		line, rest := NextLine(*s)
			*s = rest
		if ts, err = time.Parse(HeadFormat, line); err == nil {
			break
		}
	}

	for {
		if *s == "" {
			break
		}

		line, rest := NextLine(*s)
		if _, err = time.Parse(HeadFormat, line); err != nil {
			body += line + "\n"
			*s = rest
		} else {
			break
		}
	}

	return ts, strings.TrimSpace(body), true
}

A  => plan.txt +37 -0
@@ 1,37 @@
endpoints:
	/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
	/edit - GET
		log of edits. each edit has an ID, so polling this will
		allow js code to edit the <textarea> in realtime

	HTTP basic auth

	serve a js API and an HTML page from the same set of endpoints,
	use Accept:

	DB is just JSON files on disk.

frontend:
	static mode and js mode. js mode does clever things to allow
	multiple client


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