Compare commits

...

17 Commits

Author SHA1 Message Date
Wancat
46da56d9f8 Add default ledger & queries to archetypes 2022-11-17 18:00:26 +08:00
Wancat
16722eb9bf Add .ledgerrc in archetypes 2022-11-17 17:58:48 +08:00
Wancat
0cdc6bec24 Error handling / rename / ordered queries
1. error handling for all routes & models function
2. rename scripts -> queries
3. use [][2]string instead of map[string]string to provide ordered
   queries list
2022-11-17 17:50:55 +08:00
Wancat
a6510b34a4 Copy archetype on signing up 2022-11-17 17:27:01 +08:00
Wancat
c6b79938ea Add archetypes files 2022-11-17 17:16:17 +08:00
Wancat
fc4c269ddc Define tx templates in user dir 2022-11-17 17:03:40 +08:00
Wancat
3ef4dfaa48 Define favorite scripts in user dir 2022-11-17 16:54:30 +08:00
Wancat
3aab4827fe Move htpasswd to data dir 2022-11-16 02:34:26 +08:00
Wancat
64b7fa4f50 Change to query based uri, add new file, success page 2022-11-16 02:33:19 +08:00
Wancat
f5ace34852 Edit any files in user dir 2022-11-16 01:55:36 +08:00
Wancat
22229fc5bc 加入來源帳戶選項 2022-11-05 00:21:44 +08:00
Wancat
ae86204c84 Add query function 2022-11-05 00:13:21 +08:00
Wancat
4ae49b5db6 Add download 2022-11-04 23:45:00 +08:00
Wancat
b1d0bc826f Add edit function 2022-11-04 23:36:23 +08:00
Wancat
7c63223aa0 l10n, create dir on creating file 2022-11-04 23:17:17 +08:00
Wancat
9f99aad680 Add user dir & file 2022-11-04 23:06:32 +08:00
Wancat
bca1735393 Add Template layouts, load session hashkey 2022-11-04 22:45:57 +08:00
28 changed files with 520 additions and 207 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,4 @@
.env .env
test.txt
ledger-quicknote ledger-quicknote
*.txt
.htpasswd .htpasswd
data

1
archetypes/.ledgerrc Normal file
View File

@@ -0,0 +1 @@
--file ledger.txt

4
archetypes/default.tpl Normal file
View File

@@ -0,0 +1,4 @@
{{ .Date }} {{ with .Name }}{{ . }}{{ else }}{{ .Destination }}{{ end }}
{{ .Destination }} ${{ .Amount }}
{{ with .Source }}{{ . }}{{ else }}cash{{ end }}

13
archetypes/ledger.txt Normal file
View File

@@ -0,0 +1,13 @@
account assets
account liabilities
account income
account expenses
account equity
account assets:savings
alias savings
account assets:checking
alias checking
account expenses:cash
alias cash
account expenses:food
alias food

5
archetypes/queries.txt Normal file
View File

@@ -0,0 +1,5 @@
資產餘額:b assets
本月開支:b -b "this month"
上月開支:b -b "last month" -e "this month"
記錄列表:r
過去七天:r -b "last 7 days"

View File

@@ -13,6 +13,7 @@ import (
) )
type AuthStore interface { type AuthStore interface {
Get(user string) bool
Register(user, pass string) error Register(user, pass string) error
Login(user, pass string) (token string, err error) Login(user, pass string) (token string, err error)
Verify(token string) (session Session, err error) Verify(token string) (session Session, err error)
@@ -39,6 +40,11 @@ func New(path string, hashKey []byte) (AuthStore, error) {
return s, err return s, err
} }
func (s Htpasswd) Get(user string) bool {
_, ok := s.accounts[user]
return ok
}
func (s Htpasswd) Register(user, pass string) (err error) { func (s Htpasswd) Register(user, pass string) (err error) {
if _, ok := s.accounts[user]; ok { if _, ok := s.accounts[user]; ok {
return errors.New("user already exists") return errors.New("user already exists")

View File

@@ -1,7 +1,12 @@
package main package main
var SCRIPTS = map[string][]string{ var SCRIPTS = map[string]string{
"balance assets": {"b", "assets", "-X", "$"}, "balance assets": "b assets -X $",
"register": {"r", "--tail", "10"}, "register": "r --tail 10",
"balance this month": {"b", "-b", "this month"}, "balance this month": "b -b \"this month\"",
} }
const QUERIES_FILE = "queries.txt"
const HTPASSWD_FILE = ".htpasswd"
const DEFAULT_JOURNAL = "ledger.txt"
const ARCHETYPES_DIR = "archetypes"

1
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-isatty v0.0.14 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/otiai10/copy v1.9.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.1 // indirect github.com/pelletier/go-toml/v2 v2.0.1 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect github.com/ugorji/go/codec v1.2.7 // indirect
golang.org/x/crypto v0.1.0 // indirect golang.org/x/crypto v0.1.0 // indirect

7
go.sum
View File

@@ -37,6 +37,12 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/otiai10/copy v1.9.0 h1:7KFNiCgZ91Ru4qW4CWPf/7jqtxLagGRmIxWldPP9VY4=
github.com/otiai10/copy v1.9.0/go.mod h1:hsfX19wcn0UWIHUQ3/4fHuehhk2UyArQ9dVFAn3FczI=
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.4.0/go.mod h1:gifjb2MYOoULtKLqUAEILUG/9KONW6f7YsJ6vQLTlFI=
github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU= github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -63,6 +69,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

124
main.go
View File

@@ -1,136 +1,50 @@
package main package main
import ( import (
"encoding/base64"
"flag" "flag"
"log" "log"
"net/http" "path"
"text/template" "text/template"
"github.com/gin-gonic/gin"
"github.com/gorilla/securecookie" "github.com/gorilla/securecookie"
"github.com/lancatlin/ledger-quicknote/auth" "github.com/lancatlin/ledger-quicknote/auth"
) )
var ledgerTpl *template.Template
var htmlTpl *template.Template var htmlTpl *template.Template
var LEDGER_FILE string var DATA_DIR string
var LEDGER_INIT string
var WORKING_DIR string
var HOST string var HOST string
var store auth.AuthStore var store auth.AuthStore
const HTPASSWD_FILE = ".htpasswd"
type UserLogin struct {
Email string `form:"email" binding:"required"`
Password string `form:"password" binding:"required"`
}
func init() { func init() {
ledgerTpl = template.Must(template.ParseGlob("tx/*")) flag.StringVar(&DATA_DIR, "d", "data", "data folder")
flag.StringVar(&LEDGER_FILE, "f", "example.txt", "ledger journal file to write")
flag.StringVar(&LEDGER_INIT, "i", "", "ledger initiation file")
flag.StringVar(&WORKING_DIR, "w", "", "ledger working directory")
flag.StringVar(&HOST, "b", "127.0.0.1:8000", "binding address") flag.StringVar(&HOST, "b", "127.0.0.1:8000", "binding address")
var hashKey string var hashKeyString string
flag.StringVar(&hashKey, "s", "", "session secret") flag.StringVar(&hashKeyString, "s", "", "session secret")
flag.Parse() flag.Parse()
if hashKey == "" { var hashKey []byte
hashKey = string(securecookie.GenerateRandomKey(32))
log.Printf("Generate random session key: %s", hashKey)
}
var err error var err error
store, err = auth.New(HTPASSWD_FILE, []byte(hashKey))
if hashKeyString == "" {
hashKey = securecookie.GenerateRandomKey(32)
log.Printf("Generate random session key: %s", base64.StdEncoding.EncodeToString(hashKey))
} else {
hashKey, err = base64.StdEncoding.DecodeString(hashKeyString)
if err != nil {
panic(err)
}
}
store, err = auth.New(path.Join(DATA_DIR, HTPASSWD_FILE), hashKey)
if err != nil { if err != nil {
panic(err) panic(err)
} }
} }
func main() { func main() {
r := gin.Default() r := router()
r.HTMLRender = loadTemplates("templates")
r.GET("/signup", func(c *gin.Context) {
c.HTML(200, "signup.html", nil)
})
r.GET("/signin", func(c *gin.Context) {
c.HTML(200, "signin.html", nil)
})
r.POST("/signup", signup)
r.POST("/signin", signin)
authZone := r.Group("", basicAuth)
authZone.GET("/dashboard", func(c *gin.Context) {
c.HTML(200, "index.html", struct {
Templates []*template.Template
Scripts map[string][]string
}{
ledgerTpl.Templates(),
SCRIPTS,
})
})
authZone.POST("/new", func(c *gin.Context) {
var data TxData
if err := c.ShouldBind(&data); err != nil {
c.AbortWithError(400, err)
return
}
tx, err := newTx(data)
if err != nil {
c.AbortWithError(400, err)
log.Println(err, c.Request.Form)
return
}
c.HTML(200, "new.html", struct {
Tx string
}{tx})
})
authZone.POST("/submit", func(c *gin.Context) {
tx := c.PostForm("tx")
if err := appendToFile(tx); err != nil {
c.AbortWithError(500, err)
return
}
c.HTML(200, "success.html", struct {
Tx string
}{tx})
})
authZone.GET("/exec", func(c *gin.Context) {
name, _ := c.GetQuery("name")
if err := executeScript(c.Writer, name); err != nil {
c.AbortWithError(500, err)
log.Println(err)
return
}
})
log.Fatal(r.Run(HOST)) log.Fatal(r.Run(HOST))
} }
func basicAuth(c *gin.Context) {
cookie, err := c.Cookie("session")
if err == http.ErrNoCookie {
c.Redirect(303, "/signin")
return
}
session, err := store.Verify(cookie)
if err != nil {
c.Redirect(303, "/signin")
return
}
c.Set("user", UserLogin{
Email: session.User,
})
c.Next()
}

166
models.go Normal file
View File

@@ -0,0 +1,166 @@
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"path"
"strings"
"text/template"
"time"
cp "github.com/otiai10/copy"
)
type User struct {
IsLogin bool
Email string `form:"email" binding:"required"`
Password string `form:"password" binding:"required"`
}
func (u *User) Dir() string {
dir := path.Join(DATA_DIR, u.Email)
return dir
}
func (u *User) Mkdir() error {
return cp.Copy(ARCHETYPES_DIR, u.Dir())
}
func (u *User) FilePath(name string) string {
return path.Join(u.Dir(), name)
}
func (u *User) File(name string, mode int) (*os.File, error) {
return os.OpenFile(u.FilePath(name), mode, 0644)
}
func (u *User) AppendFile(name string) (*os.File, error) {
return u.File(name, os.O_WRONLY|os.O_CREATE|os.O_APPEND)
}
func (u *User) ReadFile(name string) (*os.File, error) {
return u.File(name, os.O_RDONLY|os.O_CREATE)
}
func (u *User) WriteFile(name string) (*os.File, error) {
return u.File(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
}
func (u *User) List() ([]string, error) {
files, err := os.ReadDir(u.Dir())
if err != nil {
return []string{}, fmt.Errorf("Failed to open directory: %w", err)
}
result := make([]string, len(files))
for i, v := range files {
result[i] = v.Name()
}
return result, nil
}
func (u *User) readAllFile(name string) (data []byte, err error) {
f, err := u.ReadFile(name)
if err != nil {
return
}
defer f.Close()
data, err = ioutil.ReadAll(f)
return
}
func (u *User) appendToFile(tx string) (err error) {
f, err := u.AppendFile(DEFAULT_JOURNAL)
if err != nil {
return err
}
defer f.Close()
buf := strings.NewReader(strings.ReplaceAll(tx, "\r", "")) // Remove CR generated from browser
_, err = io.Copy(f, buf)
return err
}
func (u *User) overwriteFile(filename string, tx string) (err error) {
f, err := u.WriteFile(filename)
if err != nil {
return err
}
defer f.Close()
buf := strings.NewReader(strings.ReplaceAll(tx, "\r", "")) // Remove CR generated from browser
_, err = io.Copy(f, buf)
return err
}
func (u *User) query(query string) (result string, err error) {
var buf bytes.Buffer
cmd := exec.Command("ledger")
cmd.Dir = u.Dir()
cmd.Stdin = strings.NewReader(query)
cmd.Stdout = &buf
cmd.Stderr = &buf
err = cmd.Run()
return buf.String(), err
}
func (u *User) queries() (queries [][2]string, err error) {
f, err := u.ReadFile(QUERIES_FILE)
if err != nil {
err = fmt.Errorf("Failed to read queries file: %w", err)
return
}
defer f.Close()
fileScanner := bufio.NewScanner(f)
fileScanner.Split(bufio.ScanLines)
queries = make([][2]string, 0)
for fileScanner.Scan() {
arr := strings.SplitN(fileScanner.Text(), ":", 2)
if len(arr) < 2 {
continue
}
queries = append(queries, [2]string{arr[0], arr[1]})
}
return
}
func (u *User) templates() (templates []string, err error) {
files, err := u.List()
if err != nil {
return
}
for _, v := range files {
if strings.HasSuffix(v, ".tpl") {
templates = append(templates, v)
}
}
return
}
type TxData struct {
Action string `form:"action" binding:"required"`
Name string `form:"name"`
Date string
Amount string `form:"amount" binding:"required"`
Destination string `form:"dest"`
Source string `form:"src"`
}
func (u *User) newTx(data TxData) (result string, err error) {
data.Date = time.Now().Format("2006/01/02")
var buf bytes.Buffer
tpl, err := template.ParseFiles(u.FilePath(data.Action))
if err != nil {
return
}
err = tpl.ExecuteTemplate(&buf, data.Action, data)
result = buf.String()
return
}

148
route.go Normal file
View File

@@ -0,0 +1,148 @@
package main
import (
"log"
"github.com/gin-gonic/gin"
)
func router() *gin.Engine {
r := gin.Default()
r.HTMLRender = loadTemplates("templates")
r.GET("/", func(c *gin.Context) {
c.Redirect(303, "/dashboard")
})
r.GET("/signup", func(c *gin.Context) {
HTML(c, 200, "signup.html", nil)
})
r.GET("/signin", func(c *gin.Context) {
HTML(c, 200, "signin.html", nil)
})
r.POST("/signup", signup)
r.POST("/signin", signin)
authZone := r.Group("", authenticate)
authZone.GET("/dashboard", func(c *gin.Context) {
user := getUser(c)
queries, err := user.queries()
if err != nil {
c.AbortWithError(500, err)
return
}
templates, err := user.templates()
if err != nil {
c.AbortWithError(500, err)
return
}
HTML(c, 200, "dashboard.html", gin.H{
"Queries": queries,
"Templates": templates,
})
})
authZone.POST("/new", func(c *gin.Context) {
var data TxData
if err := c.ShouldBind(&data); err != nil {
c.AbortWithError(400, err)
return
}
user := getUser(c)
tx, err := user.newTx(data)
if err != nil {
c.AbortWithError(400, err)
log.Println(err, c.Request.Form)
return
}
HTML(c, 200, "new.html", gin.H{
"Tx": tx,
})
})
authZone.POST("/submit", func(c *gin.Context) {
user := getUser(c)
tx := c.PostForm("tx")
if err := user.appendToFile(tx); err != nil {
c.AbortWithError(500, err)
return
}
c.Redirect(303, "/dashboard")
})
authZone.GET("/edit", func(c *gin.Context) {
user := getUser(c)
filename := c.Query("filename")
list, err := user.List()
if err != nil {
c.AbortWithError(500, err)
return
}
exists := contain(list, filename)
var data []byte
if exists {
data, err = user.readAllFile(filename)
if err != nil {
c.AbortWithError(500, err)
}
}
HTML(c, 200, "edit.html", gin.H{
"Data": string(data),
"FileName": filename,
"FileList": list,
"Exists": exists,
})
})
authZone.POST("/edit", func(c *gin.Context) {
user := getUser(c)
filename := c.PostForm("filename")
data := c.PostForm("data")
err := user.overwriteFile(filename, data)
if err != nil {
c.AbortWithError(500, err)
return
}
HTML(c, 200, "success.html", gin.H{
"FileName": filename,
"Tx": data,
})
})
authZone.GET("/download", func(c *gin.Context) {
user := getUser(c)
c.FileAttachment(user.FilePath(DEFAULT_JOURNAL), DEFAULT_JOURNAL)
})
authZone.GET("/query", func(c *gin.Context) {
user := getUser(c)
response := struct {
Query string
Result string
Queries [][2]string
}{}
var ok bool
var err error
response.Queries, err = user.queries()
if err != nil {
c.AbortWithError(500, err)
return
}
response.Query, ok = c.GetQuery("query")
if ok && response.Query != "" {
response.Result, err = user.query(response.Query)
if err != nil {
c.AbortWithError(500, err)
return
}
}
HTML(c, 200, "query.html", response)
})
return r
}

View File

@@ -1,29 +1,57 @@
package main package main
import "github.com/gin-gonic/gin" import (
"net/http"
"github.com/gin-gonic/gin"
)
func authenticate(c *gin.Context) {
cookie, err := c.Cookie("session")
if err == http.ErrNoCookie {
c.Redirect(303, "/signin")
return
}
session, err := store.Verify(cookie)
if err != nil {
c.Redirect(303, "/signin")
return
}
c.Set("user", User{
Email: session.User,
})
c.Next()
}
func getUser(c *gin.Context) User {
return c.MustGet("user").(User)
}
func signup(c *gin.Context) { func signup(c *gin.Context) {
var user UserLogin var user User
if err := c.ShouldBind(&user); err != nil { if err := c.ShouldBind(&user); err != nil {
c.HTML(400, "signup.html", err) HTML(c, 400, "signup.html", err)
return return
} }
if err := store.Register(user.Email, user.Password); err != nil { if err := store.Register(user.Email, user.Password); err != nil {
c.HTML(400, "signup.html", err) HTML(c, 400, "signup.html", err)
return
}
if err := user.Mkdir(); err != nil {
return return
} }
signin(c) signin(c)
} }
func signin(c *gin.Context) { func signin(c *gin.Context) {
var user UserLogin var user User
if err := c.ShouldBind(&user); err != nil { if err := c.ShouldBind(&user); err != nil {
c.HTML(400, "signin.html", err) HTML(c, 400, "signin.html", err)
return return
} }
token, err := store.Login(user.Email, user.Password) token, err := store.Login(user.Email, user.Password)
if err != nil { if err != nil {
c.HTML(401, "signin.html", err) HTML(c, 401, "signin.html", err)
return return
} }
c.SetCookie("session", token, 60*60*24*7, "", "", false, false) c.SetCookie("session", token, 60*60*24*7, "", "", false, false)

View File

@@ -5,6 +5,7 @@ import (
"path/filepath" "path/filepath"
"github.com/gin-contrib/multitemplate" "github.com/gin-contrib/multitemplate"
"github.com/gin-gonic/gin"
) )
func loadTemplates(templatesDir string) multitemplate.Renderer { func loadTemplates(templatesDir string) multitemplate.Renderer {
@@ -25,3 +26,21 @@ func loadTemplates(templatesDir string) multitemplate.Renderer {
} }
return r return r
} }
type Data interface{}
type Page struct {
Data
User User
}
func HTML(c *gin.Context, status int, name string, data interface{}) {
output := Page{
Data: data,
}
_, ok := c.Get("user")
if ok {
output.User = c.MustGet("user").(User)
}
c.HTML(status, name, output)
}

16
templates/dashboard.html Normal file
View File

@@ -0,0 +1,16 @@
{{ define "main" }}
<h1>Ledger 速記</h1>
<form action="/new" method="POST">
<label>選擇模板:
{{ range .Templates }}
<input type="radio" name="action" value="{{ . }}">{{ . }}</option>
{{ end }}
</label><br>
<label>金額:<input name="amount" type="number"></label><br>
<label>目的帳戶:<input name="dest" type="text"></label></br>
<label>來源帳戶:<input name="src" type="text"></label></br>
<label>名稱:<input name="name" type="text"></label></br>
<input type="submit" value="新增記錄">
</form>
{{ template "queries" .Queries }}
{{ end }}

18
templates/edit.html Normal file
View File

@@ -0,0 +1,18 @@
{{ define "title" }}編輯{{ end }}
{{ define "main" }}
<h1>編輯檔案</h1>
<aside>
<ul>
{{ range .FileList }}
<li><a href="/edit?filename={{ . }}">{{ . }}</a></li>
{{ end }}
</ul>
<p><a href="/edit">新檔案</a></p>
</aside>
<form action="/edit" method="POST">
<label>Filename: <input type="text" name="filename" value="{{ .FileName }}"></label><br>
<textarea name="data" rows="15" cols="40">{{ .Data }}</textarea><br>
<input type="submit" value="{{ if .Exists }}儲存{{ else }}新增{{ end }}">
</form>
{{ end }}

View File

@@ -1,20 +1,3 @@
{{ define "main" }} {{ define "main" }}
<h1>Ledger Quick Note</h1> <h1>Ledger 速記</h1>
<form action="/new" method="POST">
<label>Action:
{{ range .Templates }}
<input type="radio" name="action" value="{{ .Name }}">{{ .Name }}</option>
{{ end }}
</label><br>
<label>Amount: <input name="amount" type="number"></label><br>
<label>Account: <input name="account" type="text"></label></br>
<label>Tx Name: <input name="name" type="text"></label></br>
<input type="submit">
</form>
<h2>Scripts</h2>
<ul>
{{ range $k, $v := .Scripts }}
<li><a href="/exec?name={{ $k }}">{{ $k }}</a></li>
{{ end }}
</ul>
{{ end }} {{ end }}

View File

@@ -1,11 +1,21 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>{{ block "title" . }}Ledger Quick Note{{ end }}</title> <title>{{ block "title" . }}Ledger 速記{{ end }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head> </head>
<body> <body>
{{ block "main" . }} <nav>
<ul>
{{ with .User }}
{{ .Email }}
<li><a href="/dashboard">儀表板</a></li>
<li><a href="/edit">編輯</a></li>
<li><a href="/download">下載</a></li>
<li><a href="/query">查詢</a></li>
{{ end }}
</nav>
{{ block "main" .Data }}
{{ end }} {{ end }}
</body> </body>

View File

@@ -0,0 +1,8 @@
{{ define "queries" }}
<h3>快速查詢</h3>
<ul>
{{ range . }}
<li><a href="/query?query={{ index . 1 | urlquery }}">{{ index . 0 }}</a></li>
{{ end }}
</ul>
{{ end }}

12
templates/query.html Normal file
View File

@@ -0,0 +1,12 @@
{{ define "title" }}查詢{{ end }}
{{ define "main" }}
<h1>查詢</h1>
{{ with .Result }}
<pre><code>{{ . }}</code></pre>
{{ end }}
<form action="/query" method="GET">
<input type="text" name="query" value="{{ .Query }}" autofocus>
<input type="submit" value="查詢">
</form>
{{ template "queries" .Queries }}
{{ end }}

View File

@@ -1,10 +1,11 @@
{{ define "title" }}Sign Up{{ end }} {{ define "title" }}登入{{ end }}
{{ define "main" }} {{ define "main" }}
<h1>Sign In</h1> <h1>登入</h1>
<form action="/signin" method="POST"> <form action="/signin" method="POST">
<label>Email: <input type="text" name="email"></label><br> <label>Email: <input type="text" name="email"></label><br>
<label>Password: <input type="password" name="password"></label><br> <label>Password: <input type="password" name="password"></label><br>
{{ with .Error }}<p class="error">{{ . }}</p>{{ end }} {{ with .Error }}<p class="error">{{ . }}</p>{{ end }}
<input type="submit" value="Sign Up"> <input type="submit" value="登入">
</form> </form>
<a href="signup">沒有帳號嗎?註冊</a>
{{ end }} {{ end }}

View File

@@ -1,10 +1,11 @@
{{ define "title" }}Sign Up{{ end }} {{ define "title" }}註冊{{ end }}
{{ define "main" }} {{ define "main" }}
<h1>Sign Up</h1> <h1>註冊</h1>
<form action="/signup" method="POST"> <form action="/signup" method="POST">
<label>Email: <input type="text" name="email"></label><br> <label>Email: <input type="text" name="email"></label><br>
<label>Password: <input type="password" name="password"></label><br> <label>Password: <input type="password" name="password"></label><br>
{{ with .Error }}<p class="error">{{ . }}</p>{{ end }} {{ with .Error }}<p class="error">{{ . }}</p>{{ end }}
<input type="submit" value="Sign Up"> <input type="submit" value="註冊">
</form> </form>
<a href="signin">已有帳號嗎?登入</a>
{{ end }} {{ end }}

View File

@@ -1,6 +1,6 @@
{{ define "title" }}Success{{ end }} {{ define "title" }}Success{{ end }}
{{ define "main" }} {{ define "main" }}
<p><strong>Success</strong></p> <p><strong>Success</strong></p>
{{ with .FileName }}<p><a href="/edit?filename={{ . }}">繼續編輯</a></p>{{ end }}
<pre><code>{{ .Tx }}</code></pre> <pre><code>{{ .Tx }}</code></pre>
<p><a href="/">Back to home</a></p>
{{ end }} {{ end }}

50
tx.go
View File

@@ -1,50 +0,0 @@
package main
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
)
type TxData struct {
Action string `form:"action" binding:"required"`
Name string `form:"name"`
Date string
Amount string `form:"amount" binding:"required"`
Account string `form:"account"`
}
func newTx(data TxData) (result string, err error) {
data.Date = time.Now().Format("2006/01/02")
var buf bytes.Buffer
err = ledgerTpl.ExecuteTemplate(&buf, data.Action, data)
return buf.String(), nil
}
func appendToFile(tx string) (err error) {
f, err := os.OpenFile(LEDGER_FILE, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
return err
}
defer f.Close()
buf := strings.NewReader(strings.ReplaceAll(tx, "\r", "")) // Remove CR generated from browser
_, err = io.Copy(f, buf)
return err
}
func executeScript(w io.Writer, name string) (err error) {
script, ok := SCRIPTS[name]
if !ok {
return fmt.Errorf("%s script not found", name)
}
cmd := exec.Command("ledger", append([]string{"--init-file", LEDGER_INIT, "--file", LEDGER_FILE}, script...)...)
cmd.Dir = WORKING_DIR
cmd.Stdout = w
cmd.Stderr = w
return cmd.Run()
}

View File

@@ -1,4 +0,0 @@
{{ .Date }} * cash
cash ${{ .Amount }}
assets:cash

View File

@@ -1,4 +0,0 @@
{{ .Date }} {{ with .Name }}{{ . }}{{ else }}{{ .Account }}{{ end }}
{{ .Account }} ${{ .Amount }}
cash

View File

@@ -1,4 +0,0 @@
{{ .Date }} * withdraw
assets:cash ${{ .Amount }}
assets:checking

10
utils.go Normal file
View File

@@ -0,0 +1,10 @@
package main
func contain[T comparable](arr []T, elem T) bool {
for _, v := range arr {
if v == elem {
return true
}
}
return false
}