├── .gitignore ├── Note.go ├── README.md ├── app.go ├── bags.go ├── go.mod ├── go.sum ├── homeView.go ├── main.go ├── middleware.go ├── noteDetailsView.go ├── repository.go ├── scriptRepository.go ├── scripting.go ├── scriptingApp.go ├── scriptingViews.go ├── state.go ├── templates ├── attributes.go ├── tags.go └── templates.go ├── todo.txt ├── uploadView.go ├── uploads.go └── views.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | *.db 3 | -------------------------------------------------------------------------------- /Note.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | type Note struct { 9 | Id int64 10 | Content string 11 | Created time.Time 12 | Updated time.Time 13 | Deleted time.Time 14 | } 15 | 16 | type Script struct { 17 | Id int64 18 | Name string 19 | Content string 20 | Created time.Time 21 | Updated time.Time 22 | Deleted time.Time 23 | } 24 | 25 | func (s Script) IsPage() bool { 26 | return strings.HasSuffix(s.Name, ".page") 27 | } 28 | 29 | func (s Script) IsLibrary() bool { 30 | return strings.HasSuffix(s.Name, ".lib") 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gills: The Tagged, Search-based journaling system 2 | 3 | ## Status 4 | 5 | This project is currently no longer under development by me, @yumaikas. 6 | 7 | If you're interested in how to build something that ties Lua, Go and scripting into a note-taking system, this might be of interest, but I don't have any future plans for it. 8 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/go-chi/chi" 12 | ) 13 | 14 | func Route(r chi.Router) { 15 | // So, the homepage can either be a search page, or a lua-based page 16 | // if a home.page is defined 17 | r.Get("/", Home) 18 | r.Get("/admin/search", Search) 19 | r.Get("/admin/search/", Search) 20 | r.Get("/admin", Desktop) 21 | r.Get("/admin/", Desktop) 22 | r.Post("/admin/search", DoSaveState) 23 | r.Post("/admin/create", CreateNote) 24 | 25 | // Have the GET and POST forms so that we can keep the URLs clean when we're just linking 26 | // vs coming here from the main admin page (which wants to save some state on the way) 27 | r.Get("/admin/note/{noteID}", ShowNote) 28 | r.Post("/admin/note/{noteID}/show", SaveStateAndRedirToNote) 29 | r.Post("/admin/note/{noteID}", DoSaveNote) 30 | r.Post("/admin/note/{noteID}/delete", DoDeleteNote) 31 | r.Get("/admin/upload", ShowUploadForm) 32 | r.Get("/admin/upload/{filename}", ShowUploadedFile) 33 | r.Post("/admin/upload", ProcessUpload) 34 | r.Get("/admin/upload/list", ShowUploadNotes) 35 | 36 | // Scripting stuffs 37 | r.Get("/admin/scripting", ListLuaScripts) 38 | r.Get("/admin/scripting/", ListLuaScripts) 39 | 40 | r.Get("/admin/scripts/new", NewLuaScriptForm) 41 | r.Get("/admin/scripts/new/", NewLuaScriptForm) 42 | 43 | r.Post("/admin/scripts/new/run", RunDraftLuaScript) 44 | 45 | r.Post("/admin/scripts/new/", CreateLuaScript) 46 | r.Post("/admin/scripts/new", CreateLuaScript) 47 | 48 | // Edit scripts 49 | r.Get("/admin/scripts/edit/{script-name}", EditLuaScript) 50 | r.Post("/admin/scripts/save-and-run/{script-name}", SaveAndRunLuaScript) 51 | r.Post("/admin/scripts/edit/{script-name}", SaveLuaScript) 52 | 53 | // Run scripts that have been saved 54 | r.Get("/admin/s/{script-name}*", RunLuaScript) 55 | r.Post("/admin/s/{script-name}*", RunLuaPostScript) 56 | 57 | r.Get("/admin/wild-test*", TestWildCard) 58 | } 59 | 60 | func TestWildCard(w http.ResponseWriter, r *http.Request) { 61 | param := chi.URLParam(r, "*") 62 | fmt.Fprint(w, "The URL param was:") 63 | fmt.Fprint(w, param) 64 | } 65 | 66 | func Home(w http.ResponseWriter, r *http.Request) { 67 | script, err := GetScriptByName("home.page") 68 | if err == sql.ErrNoRows { 69 | Search(w, r) 70 | return 71 | } 72 | die(err) 73 | state, err := LoadState() 74 | die(err) 75 | die(LuaExecutionOnlyView(w, state, doLuaScript(script.Content, r))) 76 | } 77 | 78 | func Desktop(w http.ResponseWriter, r *http.Request) { 79 | appState, err := LoadState() 80 | die(err) 81 | searchTerms := appState.GetOr(recentSearchKey, "") 82 | notes, err := SearchRecentNotes(searchTerms) 83 | die(err) 84 | die(HomeView(w, appState, notes)) 85 | } 86 | 87 | func ShowUploadForm(w http.ResponseWriter, r *http.Request) { 88 | appState, err := LoadState() 89 | searchTerms := r.URL.Query().Get("q") 90 | die(err) 91 | notes, err := SearchUploadNotes(searchTerms) 92 | die(err) 93 | die(UploadView(w, appState, searchTerms, notes)) 94 | } 95 | 96 | func ShowUploadNotes(w http.ResponseWriter, r *http.Request) { 97 | state, err := LoadState() 98 | die(err) 99 | searchTerms := r.URL.Query().Get("q") 100 | notes, err := SearchUploadNotes(searchTerms) 101 | die(err) 102 | die(SearchView(w, state.AppName(), searchTerms, notes)) 103 | } 104 | 105 | func ShowUploadedFile(w http.ResponseWriter, r *http.Request) { 106 | fileName := chi.URLParam(r, "filename") 107 | http.ServeFile(w, r, PathForName(fileName)) 108 | } 109 | 110 | func ProcessUpload(w http.ResponseWriter, r *http.Request) { 111 | err := r.ParseMultipartForm(1000000) 112 | forms := r.MultipartForm 113 | files := forms.File["upload"] 114 | fileNames := make([]string, len(files)) 115 | for i, header := range files { 116 | file, err := files[i].Open() 117 | defer file.Close() 118 | die(err) 119 | parts := strings.Split(header.Filename, ".") 120 | ext := strings.ToLower(parts[len(parts)-1]) 121 | name, err := SaveUploadedFile(file, ext) 122 | die(err) 123 | fileNames[i] = name 124 | } 125 | tag := r.FormValue("tag") 126 | notes := r.FormValue("notes") 127 | returnPageName := r.FormValue("retpage") 128 | 129 | _, err = SaveNote(NoteForFileNames(fileNames, tag, notes)) 130 | die(err) 131 | if strings.HasPrefix(returnPageName, "/admin/pages/s/") { 132 | http.Redirect(w, r, returnPageName, 301) 133 | } else { 134 | http.Redirect(w, r, "/admin/upload", 301) 135 | } 136 | } 137 | 138 | func Search(w http.ResponseWriter, r *http.Request) { 139 | state, err := LoadState() 140 | searchTerms := r.URL.Query().Get("q") 141 | notes, err := SearchNotes(searchTerms) 142 | die(err) 143 | die(SearchView(w, state.AppName(), searchTerms, notes)) 144 | } 145 | 146 | func DoSaveState(w http.ResponseWriter, r *http.Request) { 147 | die(saveMainPageState(r)) 148 | http.Redirect(w, r, "/admin/", 301) 149 | } 150 | 151 | func SaveStateAndRedirToNote(w http.ResponseWriter, r *http.Request) { 152 | die(saveMainPageState(r)) 153 | strId := chi.URLParam(r, "noteID") 154 | http.Redirect(w, r, fmt.Sprint("/admin/note/", strId), 301) 155 | } 156 | 157 | func ShowNote(w http.ResponseWriter, r *http.Request) { 158 | state, loadErr := LoadState() 159 | die(loadErr) 160 | strId := chi.URLParam(r, "noteID") 161 | id, convErr := strconv.ParseInt(strId, 10, 64) 162 | die(convErr) 163 | note, repoErr := GetNoteBy(id) 164 | die(repoErr) 165 | NoteDetailsView(w, state, note) 166 | } 167 | 168 | func CreateNote(w http.ResponseWriter, r *http.Request) { 169 | r.ParseForm() 170 | content := r.PostFormValue(draftnoteKey) 171 | tagline := r.PostFormValue(taglineKey) 172 | var note = Note{ 173 | Id: 0, // used to signal that this note does *not* have a correspoding database row 174 | Content: content + "\n" + tagline, 175 | Created: time.Now(), 176 | } 177 | var err error 178 | note.Id, err = SaveNote(note) 179 | die(err) 180 | // Remove the draftnote so that it gets cleared out on savestate 181 | r.Form.Set("draftnote", "") 182 | die(saveMainPageState(r)) 183 | http.Redirect(w, r, "/admin/", 301) 184 | } 185 | 186 | func DoSaveNote(w http.ResponseWriter, r *http.Request) { 187 | r.ParseForm() 188 | content := r.PostFormValue("note-content") 189 | strId := chi.URLParam(r, "noteID") 190 | appState, err := LoadState() 191 | die(err) 192 | id, err := strconv.ParseInt(strId, 10, 64) 193 | if err != nil { 194 | w.WriteHeader(400) 195 | InvalidIdView(w, 196 | appState.AppName(), 197 | "Cannot save note with invalid id: ", 198 | strId) 199 | return 200 | } 201 | 202 | var note = Note{ 203 | Id: id, 204 | Content: content, 205 | Created: time.Now(), 206 | } 207 | note.Id, err = SaveNote(note) 208 | die(err) 209 | http.Redirect(w, r, fmt.Sprint("/admin/note/", note.Id), 301) 210 | } 211 | 212 | func DoDeleteNote(w http.ResponseWriter, r *http.Request) { 213 | r.ParseForm() 214 | strId := chi.URLParam(r, "noteID") 215 | appState, err := LoadState() 216 | die(err) 217 | 218 | id, err := strconv.ParseInt(strId, 10, 64) 219 | if err != nil { 220 | w.WriteHeader(400) 221 | InvalidIdView(w, 222 | appState.AppName(), 223 | "Cannot delete note from invalid id:", 224 | strId) 225 | return 226 | } 227 | die(DeleteNote(id)) 228 | http.Redirect(w, r, "/admin/upload", 301) 229 | } 230 | 231 | func stateEntry(key string, cb ChainBag) KV { 232 | return KV{key, cb.GetOr(key, "")} 233 | } 234 | 235 | func saveMainPageState(r *http.Request) error { 236 | fallback, err := LoadState() 237 | die(err) 238 | die(r.ParseForm()) 239 | state := MultiBag(r.Form).BackedBy(fallback) 240 | 241 | toSave := []KV{ 242 | stateEntry(scratchpadKey, state), 243 | stateEntry(draftnoteKey, state), 244 | stateEntry(taglineKey, state), 245 | stateEntry(recentSearchKey, state), 246 | stateEntry(appNameKey, state), 247 | } 248 | return SaveState(toSave) 249 | } 250 | 251 | func die(e error) { 252 | if e != nil { 253 | panic(e) 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /bags.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type SingleBag map[string]string 4 | type MultiBag map[string][]string 5 | 6 | type ChainBag []Bag 7 | 8 | type Bag interface { 9 | GetOr(key, value string) string 10 | get(key string) (string, bool) 11 | } 12 | 13 | func (mb MultiBag) GetOr(key, fallback string) string { 14 | if value, found := mb[key]; found && len(value) > 0 { 15 | return value[0] 16 | } 17 | return fallback 18 | } 19 | 20 | func (b SingleBag) GetOr(key, fallback string) string { 21 | if value, ok := b[key]; ok { 22 | return value 23 | } 24 | return fallback 25 | } 26 | 27 | func (cb ChainBag) GetOr(key, fallback string) string { 28 | for _, innerBag := range cb { 29 | val, found := innerBag.get(key) 30 | if found { 31 | return val 32 | } 33 | } 34 | return fallback 35 | } 36 | 37 | func (mb MultiBag) get(key string) (string, bool) { 38 | if value, found := mb[key]; found && len(value) > 0 { 39 | return value[0], true 40 | } 41 | return "", false 42 | } 43 | 44 | func (b SingleBag) get(key string) (string, bool) { 45 | if value, ok := b[key]; ok { 46 | return value, ok 47 | } 48 | return "", false 49 | } 50 | 51 | func (cb ChainBag) get(key string) (string, bool) { 52 | for _, innerBag := range cb { 53 | val, found := innerBag.get(key) 54 | if found { 55 | return val, true 56 | } 57 | } 58 | return "", false 59 | } 60 | 61 | func (cb ChainBag) BackedBy(inner Bag) ChainBag { 62 | return append(cb, inner) 63 | } 64 | 65 | func (b MultiBag) BackedBy(inner Bag) ChainBag { 66 | return []Bag{b, inner} 67 | } 68 | 69 | func (b SingleBag) BackedBy(inner Bag) ChainBag { 70 | return []Bag{b, inner} 71 | } 72 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gills 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/Shopify/go-lua v0.0.0-20190517144221-e389fc5bb7e7 7 | github.com/Shopify/goluago v0.0.0-20181106184041-88ed7d28bef6 // indirect 8 | github.com/go-chi/chi v4.0.2+incompatible // indirect 9 | github.com/jmoiron/sqlx v1.2.0 // indirect 10 | github.com/mattn/go-sqlite3 v1.11.0 // indirect 11 | github.com/pborman/uuid v1.2.0 // indirect 12 | github.com/russross/blackfriday/v2 v2.0.1 13 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 14 | gopkg.in/myesui/uuid.v1 v1.0.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Shopify/go-lua v0.0.0-20190517144221-e389fc5bb7e7 h1:EETuYA92vIj+waOJCSvu1ygcnyU2ArrsetGByxvEYko= 2 | github.com/Shopify/go-lua v0.0.0-20190517144221-e389fc5bb7e7/go.mod h1:lvS2IGWEGk+KQkRrCXuWlcsHO5BitT0HyhnP51rh3gA= 3 | github.com/Shopify/goluago v0.0.0-20181106184041-88ed7d28bef6 h1:xDYshN9WvWUOSjunwffe/a6R7gFYyOe3fEktCaDuScQ= 4 | github.com/Shopify/goluago v0.0.0-20181106184041-88ed7d28bef6/go.mod h1:dlQ4bI+E/sdfDGosWq4bqozm8djBiKQGVw9wq4s37KE= 5 | github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= 6 | github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= 7 | github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 8 | github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA= 9 | github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 10 | github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= 11 | github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= 12 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 13 | github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 14 | github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q= 15 | github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 16 | github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g= 17 | github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= 18 | github.com/russross/blackfriday v2.0.0+incompatible h1:cBXrhZNUf9C+La9/YpS+UHpUT8YD6Td9ZMSU9APFcsk= 19 | github.com/russross/blackfriday v2.0.0+incompatible/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= 20 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 21 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 22 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 23 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 24 | gopkg.in/myesui/uuid.v1 v1.0.0 h1:9y1k4tTQvYr0GliMtqWdBxMEX3X86gow12Uj6GIyBiE= 25 | gopkg.in/myesui/uuid.v1 v1.0.0/go.mod h1:OHnLC+jZGuFVkJVF3gLeL7pqB+S6rx0NlvgLqclsWc4= 26 | -------------------------------------------------------------------------------- /homeView.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | 6 | . "gills/templates" 7 | ) 8 | 9 | var homeLayout = ` 10 | @media (min-aspect-ratio: 4/3) { 11 | .side-by-side { 12 | display: grid; 13 | grid-template-columns: 30px auto 10px auto 10px auto 30px; 14 | grid-template-areas: ". left . center . right ."; 15 | } 16 | .col-left { 17 | grid-area: left; 18 | } 19 | 20 | .col-center { 21 | grid-area: center; 22 | } 23 | 24 | .col-right { 25 | grid-area: right; 26 | } 27 | 28 | textarea, .note-card { 29 | font-size: 9pt; 30 | } 31 | .app-name { 32 | margin-left: 30px; 33 | } 34 | } 35 | 36 | @media (max-aspect-ratio: 4/3) { 37 | body { 38 | font-size: 26px; 39 | width: 100%; 40 | } 41 | 42 | input[type=submit] { 43 | margin-left: 20px; 44 | padding: 5px 10px; 45 | font-size: 24px; 46 | } 47 | input[type=text] { 48 | font-size: 24px; 49 | } 50 | 51 | input[type=submit].inline-form { 52 | margin-left: 5px; 53 | padding: 5px 10px; 54 | } 55 | 56 | input { 57 | font-size: 22px; 58 | margin-top: 20px; 59 | margin-bottom: 20px; 60 | } 61 | textarea { 62 | font-size: 22px; 63 | width: 95%; 64 | } 65 | } 66 | ` 67 | 68 | func HomeView(w io.Writer, state AppState, recentNotes []Note) error { 69 | var template = BasePage(state.AppName(), 70 | Style(homeLayout), 71 | H2(Atr.Class("app-name"), A(Atr.Href("/"), Str(state.AppName()))), 72 | // TODO: Find a better place to put this, probably make a better header-section 73 | // A(Atr.Href("http://jessicaabel.com/ja/growing-gills/"), Str("Growing Gills"))), 74 | Form(Atr.Class("side-by-side").Action("/admin/search"), 75 | Div(Atr.Class("col-left"), 76 | Label(Atr.For(scratchpadKey), 77 | Div(Atr, Str("Scratchpad:")), 78 | TextArea(Atr.Name(scratchpadKey).Id("scratchpad").Cols("48").Rows("20"), state.ScratchPad()), 79 | ), 80 | ), 81 | Div(Atr.Class("col-center"), 82 | Label(Atr.For(draftnoteKey), 83 | Div(Atr, Str("What is on your mind?")), 84 | TextArea(Atr.Name(draftnoteKey).Id("draftnote").Cols("48").Rows("15"), state.DraftNote()), 85 | ), 86 | Label(Atr.For(taglineKey), 87 | Div(Atr, 88 | Str("Tagline:"), 89 | Input(Atr.Name(taglineKey).Type("textbox").Id("tagline").Size("45").Value(state.Tagline())), 90 | Input(Atr.Type("submit"). 91 | Class("inline-form"). 92 | Value("Save Note"). 93 | FormMethod("POST").FormAction("/admin/create")), 94 | ), 95 | ), 96 | ), 97 | Div(Atr.Class("col-right"), 98 | Label(Atr.For(recentSearchKey), 99 | Input(Atr.Type("submit").FormAction("/admin/search").Class("inline-form").FormMethod("POST").Value("Search Recent")), 100 | Input(Atr.Name(recentSearchKey).Id("recentSearch").Size("45").Value(state.RecentSearchTerms())), 101 | ), 102 | RecentNotes(recentNotes, 5), 103 | ), 104 | ), 105 | ) 106 | return RenderWithTargetAndTheme(w, "AQUA", template) 107 | } 108 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi" 8 | "github.com/go-chi/chi/middleware" 9 | ) 10 | 11 | func main() { 12 | err := InitDB("journal.sqlite") 13 | if err != nil { 14 | fmt.Println("Could not set up database") 15 | fmt.Println(err) 16 | return 17 | } 18 | err = prepUploadFolder() 19 | if err != nil { 20 | fmt.Println("Could not allocate upload folder!") 21 | fmt.Println(err) 22 | return 23 | } 24 | r := chi.NewRouter() 25 | r.Use(middleware.RequestID) 26 | r.Use(middleware.RealIP) 27 | r.Use(middleware.Logger) 28 | // TODO: Customize the Recoverer to show a custom 500 page that has the same style as the rest of the app. 29 | r.Use(Recoverer) 30 | Route(r) 31 | err = http.ListenAndServe(":3000", r) 32 | fmt.Println(err) 33 | } 34 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // The original work was derived from Goji's middleware, source: 4 | // https://github.com/zenazn/goji/tree/master/web/middleware 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "runtime/debug" 11 | 12 | "github.com/go-chi/chi/middleware" 13 | ) 14 | 15 | // Recoverer is a middleware that recovers from panics, logs the panic (and a 16 | // backtrace), and returns a HTTP 500 (Internal Server Error) status if 17 | // possible. Recoverer prints a request ID if one is provided. 18 | // 19 | // Alternatively, look at https://github.com/pressly/lg middleware pkgs. 20 | func Recoverer(next http.Handler) http.Handler { 21 | fn := func(w http.ResponseWriter, r *http.Request) { 22 | defer func() { 23 | if rvr := recover(); rvr != nil { 24 | 25 | logEntry := middleware.GetLogEntry(r) 26 | if logEntry != nil { 27 | logEntry.Panic(rvr, debug.Stack()) 28 | } else { 29 | fmt.Fprintf(os.Stderr, "Panic: %+v\n", rvr) 30 | debug.PrintStack() 31 | } 32 | 33 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 34 | } 35 | }() 36 | 37 | next.ServeHTTP(w, r) 38 | } 39 | 40 | return http.HandlerFunc(fn) 41 | } 42 | -------------------------------------------------------------------------------- /noteDetailsView.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | . "gills/templates" 8 | ) 9 | 10 | var NoteDetailsStyle = ` 11 | @media (min-aspect-ratio: 2/1) { 12 | textarea { 13 | max-width: 800px; 14 | } 15 | } 16 | @media (max-aspect-ratio: 2/1) { 17 | textarea { 18 | width: 95%; 19 | } 20 | } 21 | ` 22 | 23 | func NoteDetailsView(w io.Writer, state AppState, note Note) error { 24 | noteURL := fmt.Sprint("/admin/note/", note.Id) 25 | var template = BasePage(state.AppName(), 26 | Form(Atr.Action(noteURL).Method("POST").Class("note-container"), 27 | H2(Atr, 28 | A(Atr.Href("/admin/"), Str(state.AppName()+" Home"))), 29 | Div(Atr, Str("On "+tfmt(note.Created)+" you said:")), 30 | TextArea(DimensionsOf(note).Name("note-content"), note.Content), 31 | Div(Atr, 32 | Input(Atr.Type("Submit").Value("Save Changes")), 33 | Button(Atr.Type("Submit").FormAction(noteURL+"/delete").FormMethod("POST"), 34 | Str("Delete Note"), 35 | ), 36 | ), 37 | ), 38 | Div(Atr, Str("Preview: ")), 39 | Markdown(note.Content), 40 | ) 41 | return RenderWithTargetAndTheme(w, "AQUA", template) 42 | } 43 | -------------------------------------------------------------------------------- /repository.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/jmoiron/sqlx" 8 | _ "github.com/mattn/go-sqlite3" 9 | ) 10 | 11 | type noteDB struct { 12 | Id int64 `db:"Id"` 13 | Content string `db:"Content"` 14 | Created sql.NullInt64 `db:"Created"` 15 | Updated sql.NullInt64 `db:"Updated"` 16 | Deleted sql.NullInt64 `db:"Deleted"` 17 | } 18 | 19 | type noteHistoryDB struct { 20 | Id int64 `db:"Id"` 21 | NoteId int64 `db:"NoteId"` 22 | Content string `db:"Content"` 23 | Created int64 `db:"Created"` 24 | Updated int64 `db:"Updated"` 25 | Deleted int64 `db:"Deleted"` 26 | } 27 | 28 | var db *sqlx.DB 29 | 30 | // Use this to mark upload display notes in a way that won't get accidentally set by a human 31 | const UploadUUID = "8afee0a6-9ec4-46c1-a530-89287f579300" 32 | 33 | func InitDB(path string) error { 34 | var err error 35 | db, err = sqlx.Open("sqlite3", path) 36 | if err != nil { 37 | return err 38 | } 39 | return buildSchema(db) 40 | } 41 | 42 | func queryNotes(query, searchTerms string) ([]Note, error) { 43 | notes := &[]noteDB{} 44 | err := sqlx.Select(db, notes, query, searchTerms) 45 | if err != nil { 46 | return nil, err 47 | } 48 | retVals := make([]Note, len(*notes)) 49 | for idx, note := range *notes { 50 | retVals[idx] = Note{ 51 | Id: note.Id, 52 | Content: note.Content, 53 | // I don't care if these are null for now. 54 | Created: time.Unix(note.Created.Int64, 0), 55 | Updated: time.Unix(note.Updated.Int64, 0), 56 | } 57 | } 58 | return retVals, nil 59 | } 60 | 61 | func SearchUploadNotes(searchTerms string) ([]Note, error) { 62 | return queryNotes(` 63 | Select NoteId as Id, Content, Created, Updated 64 | from Notes 65 | where Content like '%' || ? || '%' 66 | AND Content like '%@upload-8afee0a6-9ec4-46c1-a530-89287f579300%' 67 | AND Content not like '%--@script%' 68 | order by Created DESC 69 | `, searchTerms) 70 | 71 | } 72 | 73 | func SearchRecentNotes(searchTerms string) ([]Note, error) { 74 | return queryNotes(` 75 | Select NoteId as Id, Content, Created, Updated 76 | from Notes 77 | where Content like '%' || ? || '%' 78 | AND Content not like '%@archive%' 79 | order by Created DESC 80 | `, searchTerms) 81 | } 82 | 83 | func SearchNotes(searchTerms string) ([]Note, error) { 84 | return queryNotes(` 85 | Select NoteId as Id, Content, Created, Updated 86 | from Notes 87 | where 88 | Content like '%' || ? || '%' 89 | AND Content not like '%@archive%' 90 | order by Created DESC 91 | `, searchTerms) 92 | } 93 | 94 | type KV struct { 95 | Key string `db:"Key"` 96 | Content string `db:"Content"` 97 | } 98 | 99 | func LoadState() (AppState, error) { 100 | results := []KV{} 101 | err := db.Select(&results, `Select Key, Content from StateKV;`) 102 | if err != nil { 103 | return AppState{}, err 104 | } 105 | var retVal = make(map[string]string) 106 | for _, kv := range results { 107 | retVal[kv.Key] = kv.Content 108 | } 109 | return AppState{SingleBag(retVal)}, nil 110 | } 111 | 112 | // This is more of a "Save Settings" now. I think I'm going to remove the delete. 113 | func SaveState(state []KV) error { 114 | tx := db.MustBegin() 115 | defer tx.Rollback() 116 | tx.MustExec("Delete from StateKV;") 117 | for _, kv := range state { 118 | tx.MustExec(` 119 | Insert into StateKV (Key, Content, Created) 120 | values (?, ?, strftime('%s', 'now'))`, kv.Key, kv.Content) 121 | } 122 | return tx.Commit() 123 | } 124 | 125 | func GetNoteBy(id int64) (Note, error) { 126 | var n = ¬eDB{} 127 | err := db.Get(n, `Select NoteId as Id, Content, Created, Updated, Deleted from Notes where NoteId = ?`, id) 128 | if err != nil { 129 | return Note{}, err 130 | } 131 | var note = Note{ 132 | Id: n.Id, 133 | Content: n.Content, 134 | Created: time.Unix(n.Created.Int64, 0), 135 | } 136 | 137 | if n.Updated.Valid { 138 | note.Updated = time.Unix(n.Updated.Int64, 0) 139 | } 140 | 141 | return note, err 142 | } 143 | 144 | func DeleteNote(id int64) error { 145 | tx := db.MustBegin() 146 | defer tx.Rollback() 147 | tx.MustExec(` 148 | Insert into NoteHistory (NoteID, Content, Created, Deleted) 149 | Select ?, Content, Created, strftime('%s', 'now') 150 | from Notes where NoteID = ? 151 | `, id, id) 152 | tx.MustExec(`Delete from Notes where NoteId = ?`, id) 153 | return tx.Commit() 154 | } 155 | 156 | func SaveNote(note Note) (int64, error) { 157 | // 0 ID means that this note isn't in the database 158 | // https://www.sqlite.org/autoinc.html 159 | // > If the table is initially empty, then a ROWID of 1 is used 160 | if note.Id != 0 { 161 | db.MustExec(` 162 | Insert into NoteHistory 163 | (NoteId, Content, Created, Updated) 164 | Select ?, Content, Created, strftime('%s', 'now') 165 | from Notes where NoteID = ?; 166 | 167 | Update Notes 168 | Set 169 | Content = ?, 170 | Updated = strftime('%s', 'now') 171 | where NoteID = ? 172 | `, note.Id, note.Id, note.Content, note.Id) 173 | return note.Id, nil 174 | } else { 175 | var retVal int64 176 | tx := db.MustBegin() 177 | defer tx.Rollback() 178 | tx.MustExec("Insert into Notes (Content, Created) values (?, strftime('%s', 'now'));", note.Content) 179 | err := tx.Get(&retVal, "Select last_insert_rowid()") 180 | if err != nil { 181 | return 0, err 182 | } 183 | return retVal, tx.Commit() 184 | } 185 | } 186 | 187 | func buildSchema(database *sqlx.DB) error { 188 | schema := ` 189 | Create Table If Not Exists Notes ( 190 | NoteId INTEGER PRIMARY KEY, 191 | Content text, 192 | Created int, -- unix timestamp 193 | Updated int, -- unix timestamp 194 | Deleted int -- unix timestamp 195 | ); 196 | 197 | Create Table If Not Exists Scripts ( 198 | ScriptID INTEGER PRIMARY KEY, 199 | Name text UNIQUE, -- The name of the script 200 | Content text, 201 | Created int, -- unix timestamp 202 | Updated int, -- unix timestamp 203 | Deleted int -- unix timestamp 204 | ); 205 | 206 | Create Table If Not Exists ScriptHistory ( 207 | Id INTEGER PRIMARY KEY, 208 | ScriptId int, 209 | Name text, 210 | Content text, 211 | Created int, -- unix timestamp 212 | Updated int, -- unix timestamp 213 | Deleted int -- unix timestamp 214 | ); 215 | 216 | Create Table If Not Exists NoteHistory ( 217 | Id INTEGER PRIMARY KEY, 218 | NoteId int, 219 | Content text, 220 | Created int, -- unix timestamp 221 | Updated int, -- unix timestamp 222 | Deleted int -- unix timestamp 223 | ); 224 | 225 | Create Table If Not Exists StateKV ( 226 | KvId INTEGER PRIMARY KEY, 227 | Key text, 228 | Content text, 229 | Created int, -- unix timestamp 230 | Updated int, -- unix timestamp 231 | Deleted int -- unix timestamp 232 | ); 233 | 234 | Create Unique Index If Not Exists UniqueKV ON StateKV(Key); 235 | Create Index If Not Exists KVidx ON StateKV(Key, Content);` 236 | 237 | _, err := database.Exec(schema) 238 | return err 239 | } 240 | -------------------------------------------------------------------------------- /scriptRepository.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/jmoiron/sqlx" 8 | _ "github.com/mattn/go-sqlite3" 9 | ) 10 | 11 | type scriptDB struct { 12 | Id int64 `db:"Id"` 13 | Name string `db:"Name"` 14 | Content string `db:"Content"` 15 | Created sql.NullInt64 `db:"Created"` 16 | Updated sql.NullInt64 `db:"Updated"` 17 | Deleted sql.NullInt64 `db:"Deleted"` 18 | IsLibrary string `db:"IsPage"` 19 | } 20 | 21 | func GetScriptByName(name string) (Script, error) { 22 | var s = &scriptDB{} 23 | err := db.Get(s, ` 24 | Select 25 | ScriptID as Id, 26 | Name, 27 | Content, 28 | Created, 29 | Updated, 30 | Deleted 31 | from Scripts 32 | where Name = ? 33 | `, name) 34 | if err != nil { 35 | return Script{}, err 36 | } 37 | 38 | var script = Script{ 39 | Id: s.Id, 40 | Name: s.Name, 41 | Content: s.Content, 42 | Created: time.Unix(s.Created.Int64, 0), 43 | } 44 | if s.Updated.Valid { 45 | script.Updated = time.Unix(s.Updated.Int64, 0) 46 | } 47 | return script, err 48 | } 49 | 50 | func ListScripts() ([]Script, error) { 51 | var scripts = &[]scriptDB{} 52 | err := sqlx.Select(db, scripts, 53 | `Select 54 | ScriptID as Id, 55 | Name, 56 | Content, 57 | Created, 58 | Updated, 59 | Deleted 60 | from Scripts`) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | var retScripts = make([]Script, len(*scripts)) 66 | for i, s := range *scripts { 67 | retScripts[i] = Script{ 68 | Id: s.Id, 69 | Name: s.Name, 70 | Content: s.Content, 71 | Created: time.Unix(s.Created.Int64, 0), 72 | } 73 | if s.Updated.Valid { 74 | retScripts[i].Updated = time.Unix(s.Updated.Int64, 0) 75 | } 76 | } 77 | 78 | return retScripts, nil 79 | } 80 | 81 | func CreateScript(name, code string) error { 82 | _, err := db.Exec(` 83 | INSERT OR FAIL 84 | into Scripts(Name, Content, Created) 85 | values (?, ?, strftime('%s', 'now'));`, name, code) 86 | return err 87 | } 88 | 89 | func RenameScript(currentName, newName string) error { 90 | db.MustExec(` 91 | Insert Into ScriptHistory (ScriptId, Name, Content, Created, Updated) 92 | Select ScriptId, Name, Content, Created, strftime('%s', 'now') from Scripts where Name = ?; 93 | Update Scripts Set Name = ?, Updated where Name = ?; 94 | `, currentName, newName, currentName) 95 | return nil 96 | } 97 | 98 | func SaveScript(name, code string) error { 99 | _, err := db.Exec(` 100 | INSERT OR IGNORE 101 | into Scripts(Name, Content, Updated) 102 | values (?, ?, strftime('%s', 'now')); 103 | 104 | Insert Into ScriptHistory (ScriptId, Name, Content, Created, Updated) 105 | Select ScriptId, Name, Content, Created, strftime('%s', 'now') from Scripts where Name = ?; 106 | 107 | Update Scripts 108 | Set 109 | Content = ?, 110 | Updated = strftime('%s', 'now') 111 | where Name = ?; 112 | `, name, code, name, code, name) 113 | return err 114 | } 115 | -------------------------------------------------------------------------------- /scripting.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "time" 9 | 10 | lua "github.com/Shopify/go-lua" 11 | "github.com/Shopify/goluago" 12 | "github.com/Shopify/goluago/util" 13 | 14 | "gills/templates" 15 | ) 16 | 17 | func doLuaScript(code string, r *http.Request) func(ctx templates.Context) { 18 | l := lua.NewState() 19 | lua.OpenLibraries(l) 20 | goluago.Open(l) 21 | die(CleanOSFunctions(l)) 22 | RegisterDbFunctions(l) 23 | RegisterRequestArgFunctions(l, r) 24 | 25 | return func(ctx templates.Context) { 26 | buf := &bytes.Buffer{} 27 | // Will panic if things fail to load 28 | templates.GetLuaRenderingContext(ctx)(l) 29 | l.Register("echo", LuaDoPrint(buf)) 30 | l.Register("echoln", LuaDoPrintln(buf)) 31 | l.Register("clear_buffer", LuaClear(buf)) 32 | l.Register("flush_markdown", LuaFlush(buf, ctx, templates.Markdown)) 33 | l.Register("flush_plain", LuaFlush(buf, ctx, templates.StrBr)) 34 | l.Register("flush_raw", LuaFlush(buf, ctx, templates.RawStr)) 35 | l.Register("script", LuaEmitJsScript(ctx)) 36 | l.Register("style", LuaEmitCSS(ctx)) 37 | //l.Register("require_note", LuaRequireNote) 38 | // Emit a div with an ID as a JS mount-point for things like Vue.JS 39 | l.Register("app_div", LuaEmitDiv(ctx)) 40 | // l.Register("write_tag", LuaWriteTag(ctx)) 41 | // l.Register("write_void_tag", LuaWriteTag(ctx)) 42 | // TODO: Build in 43 | 44 | // This really just pushes our custom require searcher onto the 45 | // package.searchers which are used when calling require. 46 | l.Global("package") 47 | l.Field(-1, "searchers") 48 | l.Length(-1) 49 | searcherLength, _ := l.ToInteger(-1) 50 | searcherLength++ 51 | l.Pop(1) 52 | l.PushInteger(searcherLength) 53 | l.PushGoFunction(LuaRequireNote) 54 | l.SetTable(-3) 55 | l.Pop(2) 56 | 57 | err := lua.LoadString(l, code) 58 | msg, _ := l.ToString(l.Top()) 59 | 60 | //fmt.Printf("%+v\n", l) 61 | if err != nil { 62 | templates.StrBr("Partial output: " + msg)(ctx) 63 | templates.StrBr(buf.String())(ctx) 64 | templates.StrBr("Error:")(ctx) 65 | templates.StrBr(err.Error())(ctx) 66 | return 67 | } 68 | err = l.ProtectedCall(0, lua.MultipleReturns, 0) 69 | if err != nil { 70 | templates.StrBr("Partial output: " + msg)(ctx) 71 | templates.StrBr(buf.String())(ctx) 72 | templates.StrBr("Error:")(ctx) 73 | templates.StrBr(err.Error())(ctx) 74 | return 75 | } 76 | // String up whatever leftover stuff wasn't flushed 77 | templates.StrBr(buf.String())(ctx) 78 | } 79 | } 80 | 81 | func LuaRequireNote(l *lua.State) int { 82 | if l.Top() < 1 { 83 | l.PushString("require_note called without script name") 84 | return 1 85 | } 86 | scriptName, ok := l.ToString(1) 87 | if !ok { 88 | l.PushString("require_note called with non-string script name") 89 | return 1 90 | } 91 | 92 | note, err := GetScriptByName(scriptName) 93 | if err != nil { 94 | l.PushString(err.Error()) 95 | return 1 96 | } 97 | 98 | err = lua.LoadString(l, note.Content) 99 | if err != nil { 100 | msg, _ := l.ToString(l.Top()) 101 | l.PushString(msg) 102 | return 1 103 | } 104 | 105 | l.PushString(fmt.Sprintf("Note %q", scriptName)) 106 | return 2 107 | } 108 | 109 | func CleanOSFunctions(l *lua.State) error { 110 | return lua.DoString(l, ` 111 | io = nil 112 | os.execute = nil 113 | os.getenv = nil 114 | os.remove = nil 115 | os.rename = nil 116 | os.tmpname = nil 117 | os.exit = nil 118 | `) 119 | } 120 | 121 | func RegisterDbFunctions(l *lua.State) { 122 | l.Register("search_notes", LuaDoSearch) 123 | l.Register("note_for_id", LuaNoteForId) 124 | } 125 | 126 | func RegisterRequestArgFunctions(l *lua.State, r *http.Request) { 127 | l.Register("url_query", LuaUrlQuery(r)) 128 | l.Register("form_value", LuaFormValue(r)) 129 | 130 | } 131 | func LuaFormValue(r *http.Request) func(*lua.State) int { 132 | return func(l *lua.State) int { 133 | argName, ok := l.ToString(1) 134 | if !ok { 135 | l.PushString("Expected a string for the query name argument!") 136 | l.Error() 137 | } 138 | l.PushString(r.FormValue(argName)) 139 | return 1 140 | } 141 | } 142 | 143 | func LuaUrlQuery(r *http.Request) func(*lua.State) int { 144 | return func(l *lua.State) int { 145 | argName, ok := l.ToString(1) 146 | if !ok { 147 | l.PushString("Expected a string for the query name argument!") 148 | l.Error() 149 | } 150 | l.PushString(r.URL.Query().Get(argName)) 151 | return 1 152 | } 153 | } 154 | 155 | func LuaClear(buf *bytes.Buffer) func(*lua.State) int { 156 | return func(l *lua.State) int { 157 | buf.Reset() 158 | return 0 159 | } 160 | } 161 | 162 | func LuaFlush(buf *bytes.Buffer, ctx templates.Context, flushUsing func(string) func(templates.Context)) func(*lua.State) int { 163 | return func(l *lua.State) int { 164 | flushUsing(buf.String())(ctx) 165 | buf.Reset() 166 | return 0 167 | } 168 | } 169 | 170 | func LuaEmitDiv(ctx templates.Context) func(*lua.State) int { 171 | return func(l *lua.State) int { 172 | if l.Top() != 2 { 173 | l.PushString("\"app_div\" requires two arguments, id and loading message") 174 | l.Error() 175 | } 176 | id, ok1 := l.ToString(1) 177 | loadingText, ok2 := l.ToString(2) 178 | if !(ok1 && ok2) { 179 | l.PushString("Either id or loadingText isn't a string!") 180 | l.Error() 181 | } 182 | templates.Div(templates.Atr.Id(id), templates.Str(loadingText))(ctx) 183 | return 0 184 | } 185 | 186 | } 187 | 188 | func LuaEmitCSS(ctx templates.Context) func(*lua.State) int { 189 | return func(l *lua.State) int { 190 | if l.Top() != 2 { 191 | l.PushString("\"style\" requires two arguments") 192 | l.Error() 193 | } 194 | mode, _ := l.ToString(1) 195 | script, ok := l.ToString(2) 196 | if mode == "text" && ok { 197 | templates.Style(script)(ctx) 198 | } else if mode == "link" && ok { 199 | templates.StyleLink(script)(ctx) 200 | } else { 201 | l.PushString("First argument for \"style\" must be \"text\" or \"link\", and the second argument must be a string") 202 | l.Error() 203 | } 204 | return 0 205 | } 206 | } 207 | 208 | func LuaEmitJsScript(ctx templates.Context) func(*lua.State) int { 209 | return func(l *lua.State) int { 210 | if l.Top() != 2 { 211 | l.PushString("script requires two arguments") 212 | l.Error() 213 | } 214 | 215 | mode, _ := l.ToString(1) 216 | script, ok := l.ToString(2) 217 | if mode == "text" && ok { 218 | templates.JS(script)(ctx) 219 | } else if mode == "link" && ok { 220 | templates.JSLink(script)(ctx) 221 | } else { 222 | l.PushString("First argument for scipt must be \"text\" or \"link\", and the second argument must be a string") 223 | l.Error() 224 | } 225 | return 0 226 | } 227 | } 228 | 229 | func LuaDoPrintln(w io.Writer) func(*lua.State) int { 230 | return func(l *lua.State) int { 231 | numArgs := l.Top() 232 | for i := 1; i <= numArgs; i++ { 233 | str, ok := l.ToString(i) 234 | if !ok { 235 | l.PushString(fmt.Sprint("Cannot convert argument at position", i, "to a lua string for printing!")) 236 | l.Error() 237 | } 238 | w.Write([]byte(str)) 239 | } 240 | w.Write([]byte("\n")) 241 | return 0 242 | } 243 | } 244 | func LuaDoPrint(w io.Writer) func(*lua.State) int { 245 | return func(l *lua.State) int { 246 | numArgs := l.Top() 247 | for i := 1; i <= numArgs; i++ { 248 | str, ok := l.ToString(i) 249 | if !ok { 250 | l.PushString(fmt.Sprint("Cannot convert argument at position", i, "to a lua string for printing!")) 251 | l.Error() 252 | } 253 | w.Write([]byte(str)) 254 | } 255 | return 0 256 | } 257 | } 258 | 259 | func LuaDoSearch(l *lua.State) int { 260 | numArgs := l.Top() 261 | fmt.Println("Number of arguments:", numArgs) 262 | searchTerms, ok := l.ToString(1) 263 | if !ok { 264 | l.PushString("Cannot search on a non-string term!") 265 | l.Error() 266 | } 267 | notes, err := SearchNotes(searchTerms) 268 | if err != nil { 269 | l.PushString(err.Error()) 270 | l.Error() 271 | } 272 | util.DeepPush(l, MapFromNotes(notes)) 273 | return 1 274 | } 275 | 276 | func LuaNoteForId(l *lua.State) int { 277 | id := lua.CheckInteger(l, 1) 278 | note, err := GetNoteBy(int64(id)) 279 | if err != nil { 280 | l.PushString("An error happened fetching a note:" + err.Error()) 281 | l.Error() 282 | } 283 | util.DeepPush(l, MapFromNote(note)) 284 | return 1 285 | 286 | } 287 | 288 | func MapFromNote(n Note) map[string]interface{} { 289 | return map[string]interface{}{ 290 | "id": n.Id, 291 | "content": n.Content, 292 | "created": MapFromGoTime(n.Created.Local()), 293 | "updated": MapFromGoTime(n.Updated.Local()), 294 | } 295 | } 296 | 297 | func MapFromNotes(notes []Note) []map[string]interface{} { 298 | var mappedNotes = make([]map[string]interface{}, len(notes)) 299 | for idx, n := range notes { 300 | mappedNotes[idx] = MapFromNote(n) 301 | } 302 | 303 | return mappedNotes 304 | } 305 | 306 | func MapFromGoTime(t time.Time) map[string]interface{} { 307 | return map[string]interface{}{ 308 | "year": t.Year(), 309 | "month": int(t.Month()), 310 | "day": t.Day(), 311 | "hour": t.Hour(), 312 | "minute": t.Minute(), 313 | "second": t.Second(), 314 | "nanosecond": t.Nanosecond(), 315 | "weekday": int(t.Weekday()) + 1, 316 | "unix": t.Unix(), 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /scriptingApp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/go-chi/chi" 8 | ) 9 | 10 | // List the lua scripts 11 | func ListLuaScripts(w http.ResponseWriter, r *http.Request) { 12 | var state, err = LoadState() 13 | die(err) 14 | scripts, err := ListScripts() 15 | die(err) 16 | die(ScriptListView(w, scripts, state)) 17 | } 18 | 19 | func NewLuaScriptForm(w http.ResponseWriter, r *http.Request) { 20 | var state, err = LoadState() 21 | die(err) 22 | die(LuaScriptEditView(w, state, "", "")) 23 | } 24 | 25 | func RunDraftLuaScript(w http.ResponseWriter, r *http.Request) { 26 | r.ParseForm() 27 | code := r.PostFormValue("code") 28 | state, err := LoadState() 29 | die(err) 30 | die(LuaExecutionResultsView(w, state, doLuaScript(code, r), code, "")) 31 | } 32 | 33 | func CreateLuaScript(w http.ResponseWriter, r *http.Request) { 34 | r.ParseForm() 35 | code := r.PostFormValue("code") 36 | name := r.PostFormValue("script-name") 37 | state, err := LoadState() 38 | die(err) 39 | if len(name) <= 0 { 40 | InvalidScriptNameView(w, state.AppName(), name, "400: Script name cannot be empty") 41 | return 42 | } 43 | if strings.ContainsAny(name, "{}/?") { 44 | InvalidScriptNameView(w, state.AppName(), name, "400: script name cannot have any of {, }, / or ?") 45 | return 46 | } 47 | die(CreateScript(name, code)) 48 | http.Redirect(w, r, "/admin/scripts/edit/"+name, 301) 49 | } 50 | 51 | func EditLuaScript(w http.ResponseWriter, r *http.Request) { 52 | r.ParseForm() 53 | name := chi.URLParam(r, "script-name") 54 | state, err := LoadState() 55 | die(err) 56 | script, err := GetScriptByName(name) 57 | die(err) 58 | die(LuaScriptEditView(w, state, script.Content, script.Name)) 59 | } 60 | 61 | func SaveLuaScript(w http.ResponseWriter, r *http.Request) { 62 | r.ParseForm() 63 | code := r.PostFormValue("code") 64 | name := r.PostFormValue("script-name") 65 | oldName := r.PostFormValue("old-script-name") 66 | 67 | if name != oldName { 68 | die(RenameScript(oldName, name)) 69 | } 70 | die(SaveScript(name, code)) 71 | http.Redirect(w, r, "/admin/scripts/edit/"+name, 301) 72 | } 73 | 74 | func SaveAndRunLuaScript(w http.ResponseWriter, r *http.Request) { 75 | r.ParseForm() 76 | code := r.PostFormValue("code") 77 | name := r.PostFormValue("script-name") 78 | oldName := r.PostFormValue("old-script-name") 79 | 80 | if name != oldName { 81 | die(RenameScript(oldName, name)) 82 | } 83 | die(SaveScript(name, code)) 84 | // Ensure that the script is a page-script 85 | state, err := LoadState() 86 | die(err) 87 | die(err) 88 | die(LuaExecutionOnlyView(w, state, doLuaScript(code, r))) 89 | } 90 | 91 | func RunLuaScript(w http.ResponseWriter, r *http.Request) { 92 | // Ensure that the script is a page-script 93 | name := chi.URLParam(r, "script-name") 94 | script, err := GetScriptByName(name) 95 | die(err) 96 | state, err := LoadState() 97 | die(err) 98 | die(LuaExecutionOnlyView(w, state, doLuaScript(script.Content, r))) 99 | } 100 | 101 | func RunLuaPostScript(w http.ResponseWriter, r *http.Request) { 102 | // Ensure that the script is a page-script 103 | die(r.ParseForm()) 104 | name := chi.URLParam(r, "script-name") 105 | script, err := GetScriptByName(name) 106 | state, err := LoadState() 107 | die(err) 108 | die(err) 109 | die(LuaExecutionOnlyView(w, state, doLuaScript(script.Content, r))) 110 | } 111 | -------------------------------------------------------------------------------- /scriptingViews.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | . "gills/templates" 8 | ) 9 | 10 | func ScriptListView(w io.Writer, scripts []Script, state AppState) error { 11 | if len(scripts) == 0 { 12 | var template = BasePage(state.AppName(), 13 | Div(Atr, Str("Looks like you don't have any scripts yet."), 14 | Str("Why don't you "), A(Atr.Href("/admin/scripts/new/"), Str("make one?")), 15 | ), 16 | ) 17 | return RenderWithTargetAndTheme(w, "AQUA", template) 18 | } 19 | 20 | var template = BasePage(state.AppName(), 21 | Style(`div.script-info { padding: 30px; }`), 22 | Div(Atr, 23 | H2(Atr, A(Atr.Href("/admin/scripts/new"), Str("Create new script"))), 24 | ), 25 | Div(Atr, 26 | H2(Atr, Str("Pages")), 27 | PageScripts(scripts), 28 | Hr(), 29 | ), 30 | Div(Atr, 31 | H2(Atr, Str("Script library")), 32 | LibScripts(scripts), 33 | Hr(), 34 | ), 35 | Div(Atr, 36 | H2(Atr, Str("Other scripts")), 37 | OtherScripts(scripts), 38 | Hr(), 39 | ), 40 | ) 41 | 42 | return RenderWithTargetAndTheme(w, "AQUA", template) 43 | 44 | } 45 | func renderScriptLinks(s Script, ctx Context) { 46 | Div(Atr.Class("script-info"), 47 | Div(Atr, H3(Atr, Str(s.Name))), 48 | Span(Atr, PageLink(s), Str(" "), EditLink(s)), 49 | )(ctx) 50 | } 51 | 52 | func PageLink(s Script) func(Context) { 53 | if s.IsPage() { 54 | return A(Atr.Href("/admin/s/"+s.Name), Str("View Page")) 55 | } 56 | return func(ctx Context) {} 57 | } 58 | 59 | func EditLink(s Script) func(Context) { 60 | return A(Atr.Href("/admin/scripts/edit/"+s.Name), Str("Edit Script")) 61 | } 62 | 63 | func LibScripts(scripts []Script) func(Context) { 64 | return func(ctx Context) { 65 | for _, s := range scripts { 66 | if s.IsLibrary() { 67 | renderScriptLinks(s, ctx) 68 | } 69 | } 70 | } 71 | } 72 | 73 | func PageScripts(scripts []Script) func(Context) { 74 | return func(ctx Context) { 75 | for _, s := range scripts { 76 | if s.IsPage() { 77 | renderScriptLinks(s, ctx) 78 | } 79 | } 80 | } 81 | } 82 | 83 | func OtherScripts(scripts []Script) func(Context) { 84 | return func(ctx Context) { 85 | for _, s := range scripts { 86 | if !s.IsPage() && !s.IsLibrary() { 87 | renderScriptLinks(s, ctx) 88 | } 89 | } 90 | } 91 | 92 | } 93 | 94 | func LuaScriptEditView(w io.Writer, state AppState, code, name string) error { 95 | var template = BasePage(state.AppName(), 96 | Form(ExecuteActionForScriptName(name), 97 | Div(Atr, 98 | Label(Atr.For("script-name"), 99 | Str("Script Name"), 100 | Input(Atr.Type("text").Name("script-name").Id("script-name").Value(name)), 101 | ), 102 | SubmitForScriptName(name), 103 | Input(Atr.Type("submit").Value("Execute Code!")), 104 | Input(Atr.Id("old-script-name").Name("old-script-name").Type("hidden").Value(name)), 105 | ), 106 | TextArea(Atr.Name("code").Cols("80").Rows("40"), code), 107 | ), 108 | ) 109 | return RenderWithTargetAndTheme(w, "AQUA", template) 110 | } 111 | 112 | func ExecuteActionForScriptName(name string) AttributeChain { 113 | if len(name) > 0 { 114 | return Atr.Action("/admin/scripts/save-and-run/" + name).Method("POST") 115 | } 116 | return Atr.Action("/admin/scripts/new/run").Method("POST") 117 | } 118 | 119 | func SubmitForScriptName(name string) func(Context) { 120 | if len(name) > 0 { 121 | return Input(Atr.Type("submit"). 122 | Value("Save Script"). 123 | FormMethod("POST"). 124 | FormAction(fmt.Sprint("/admin/scripts/edit/", name))) 125 | } 126 | return Input(Atr.Type("submit"). 127 | Value("Create Script"). 128 | FormMethod("POST"). 129 | FormAction("/admin/scripts/new/")) 130 | } 131 | 132 | func LuaExecutionOnlyView(w io.Writer, state AppState, doScript func(Context)) error { 133 | return RenderWithTargetAndTheme(w, "AQUA", BasePage(state.AppName(), doScript)) 134 | } 135 | 136 | func LuaExecutionResultsView(w io.Writer, state AppState, doScript func(Context), code, name string) error { 137 | var template = BasePage(state.AppName(), 138 | Form(Atr.Action("/admin/scripting/test").Method("POST"), 139 | Label(Atr.For("script-name"), 140 | Str("Script Name"), 141 | Input(Atr.Type("text").Name("script-name").Id("script-name").Value(name)), 142 | ), 143 | Input(Atr.Type("submit").Value("Execute Code!")), 144 | TextArea(Atr.Name("code").Cols("80").Rows("40"), code), 145 | ), 146 | Div(Atr.Id("code-results"), 147 | doScript, 148 | ), 149 | ) 150 | return RenderWithTargetAndTheme(w, "AQUA", template) 151 | } 152 | -------------------------------------------------------------------------------- /state.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type AppState struct{ Bag } 4 | 5 | const scratchpadKey = "scratchpad" 6 | const draftnoteKey = "draftnote" 7 | const taglineKey = "tagline" 8 | const recentSearchKey = "recentSearchTerms" 9 | const appNameKey = "appName" 10 | 11 | func (b AppState) ScratchPad() string { 12 | return b.GetOr(scratchpadKey, "") 13 | } 14 | 15 | func (b AppState) DraftNote() string { 16 | return b.GetOr(draftnoteKey, "") 17 | } 18 | 19 | func (b AppState) Tagline() string { 20 | return b.GetOr(taglineKey, "") 21 | } 22 | 23 | func (b AppState) RecentSearchTerms() string { 24 | return b.GetOr(recentSearchKey, "") 25 | } 26 | 27 | func (b AppState) AppName() string { 28 | return b.GetOr(appNameKey, "Gills") 29 | } 30 | -------------------------------------------------------------------------------- /templates/attributes.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | var Atr = AttributeChain(make([]Attribute, 0)) 4 | 5 | type AttributeChain []Attribute 6 | 7 | func (attrs AttributeChain) Add(key, value string) AttributeChain { 8 | return append(attrs, Attribute{Key: key, Value: value, Trusted: false}) 9 | } 10 | 11 | func (attrs AttributeChain) AddVoid(key string) AttributeChain { 12 | return append(attrs, Attribute{Key: key, Trusted: false, Void: true}) 13 | } 14 | func (attrs AttributeChain) AddUnsafe(key, value string) AttributeChain { 15 | return append(attrs, Attribute{Key: key, Value: value, Trusted: true}) 16 | } 17 | 18 | func (attrs AttributeChain) Id(id string) AttributeChain { 19 | return attrs.Add("id", id) 20 | } 21 | 22 | func (attrs AttributeChain) Value(value string) AttributeChain { 23 | return attrs.Add("value", value) 24 | } 25 | 26 | func (attrs AttributeChain) Type(value string) AttributeChain { 27 | return attrs.Add("type", value) 28 | } 29 | 30 | func (attrs AttributeChain) Class(class string) AttributeChain { 31 | return attrs.Add("class", class) 32 | } 33 | 34 | func (attrs AttributeChain) For(_for string) AttributeChain { 35 | return attrs.Add("for", _for) 36 | } 37 | 38 | func (attrs AttributeChain) Name(name string) AttributeChain { 39 | return attrs.Add("name", name) 40 | } 41 | 42 | func (attrs AttributeChain) Cols(name string) AttributeChain { 43 | return attrs.Add("cols", name) 44 | } 45 | 46 | func (attrs AttributeChain) Rows(name string) AttributeChain { 47 | return attrs.Add("rows", name) 48 | } 49 | 50 | func (attrs AttributeChain) Size(name string) AttributeChain { 51 | return attrs.Add("size", name) 52 | } 53 | 54 | func (attrs AttributeChain) FormMethod(name string) AttributeChain { 55 | return attrs.Add("formmethod", name) 56 | } 57 | 58 | func (attrs AttributeChain) FormAction(name string) AttributeChain { 59 | return attrs.Add("formaction", name) 60 | } 61 | 62 | func (attrs AttributeChain) Form(name string) AttributeChain { 63 | return attrs.Add("form", name) 64 | } 65 | 66 | func (attrs AttributeChain) Method(name string) AttributeChain { 67 | return attrs.Add("method", name) 68 | } 69 | 70 | func (attrs AttributeChain) Action(name string) AttributeChain { 71 | return attrs.Add("action", name) 72 | } 73 | 74 | func (attrs AttributeChain) Accept(name string) AttributeChain { 75 | return attrs.Add("accept", name) 76 | } 77 | 78 | func (attrs AttributeChain) Multiple() AttributeChain { 79 | return attrs.AddVoid("multiple") 80 | } 81 | 82 | func (attrs AttributeChain) EncType(enctype string) AttributeChain { 83 | return attrs.Add("enctype", enctype) 84 | } 85 | 86 | func (attrs AttributeChain) Href(href string) AttributeChain { 87 | return attrs.Add("href", href) 88 | } 89 | 90 | func (attrs AttributeChain) Rel(rel string) AttributeChain { 91 | return attrs.Add("rel", rel) 92 | } 93 | 94 | func (attrs AttributeChain) Src(src string) AttributeChain { 95 | return attrs.Add("src", src) 96 | } 97 | 98 | func (attrs AttributeChain) UnsafeHref(href string) AttributeChain { 99 | return attrs.AddUnsafe("href", href) 100 | } 101 | -------------------------------------------------------------------------------- /templates/tags.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "html/template" 5 | "strings" 6 | ) 7 | 8 | func Br() func(Context) { 9 | return WriteVoidTag("br", Atr) 10 | } 11 | func Hr() func(Context) { 12 | return WriteVoidTag("hr", Atr) 13 | } 14 | 15 | func Div(attributes AttributeChain, inner ...func(Context)) func(Context) { 16 | return WriteTag("div", attributes, inner...) 17 | } 18 | 19 | func Span(attributes AttributeChain, inner ...func(Context)) func(Context) { 20 | return WriteTag("span", attributes, inner...) 21 | } 22 | 23 | func Style(inner string) func(Context) { 24 | return WriteTag("style", Atr, func(ctx Context) { 25 | ctx.indentMultiline(inner) 26 | }) 27 | } 28 | 29 | func StyleLink(href string) func(Context) { 30 | return WriteVoidTag("link", Atr.Rel("stylesheet").Type("text/css").Href(href)) 31 | } 32 | 33 | func H1(attributes AttributeChain, inner ...func(Context)) func(Context) { 34 | return WriteTag("h2", attributes, inner...) 35 | } 36 | 37 | func H2(attributes AttributeChain, inner ...func(Context)) func(Context) { 38 | return WriteTag("h2", attributes, inner...) 39 | } 40 | 41 | func H3(attributes AttributeChain, inner ...func(Context)) func(Context) { 42 | return WriteTag("h3", attributes, inner...) 43 | } 44 | 45 | func Title(attributes AttributeChain, inner ...func(Context)) func(Context) { 46 | return WriteTag("title", attributes, inner...) 47 | } 48 | 49 | func A(attributes AttributeChain, inner ...func(Context)) func(Context) { 50 | return WriteTag("a", attributes, inner...) 51 | } 52 | func P(attributes AttributeChain, inner ...func(Context)) func(Context) { 53 | return WriteTag("p", attributes, inner...) 54 | } 55 | 56 | func Table(attributes AttributeChain, inner ...func(Context)) func(Context) { 57 | return WriteTag("Table", attributes, inner...) 58 | } 59 | 60 | func Td(attributes AttributeChain, inner ...func(Context)) func(Context) { 61 | return WriteTag("td", attributes, inner...) 62 | } 63 | 64 | func Tr(attributes AttributeChain, inner ...func(Context)) func(Context) { 65 | return WriteTag("tr", attributes, inner...) 66 | } 67 | 68 | func Form(attributes AttributeChain, inner ...func(Context)) func(Context) { 69 | return WriteTag("form", attributes, inner...) 70 | } 71 | 72 | func Label(attributes AttributeChain, inner ...func(Context)) func(Context) { 73 | return WriteTag("label", attributes, inner...) 74 | } 75 | 76 | func Button(attributes AttributeChain, inner ...func(Context)) func(Context) { 77 | return WriteTag("button", attributes, inner...) 78 | } 79 | 80 | func Input(attributes AttributeChain) func(Context) { 81 | return WriteVoidTag("input", attributes) 82 | } 83 | 84 | func JS(script string) func(Context) { 85 | return WriteTag("script", Atr, func(ctx Context) { 86 | ctx.write(script) 87 | }) 88 | } 89 | 90 | func JSLink(src string) func(Context) { 91 | return WriteTag("script", Atr.Src(src), func(ctx Context) {}) 92 | } 93 | 94 | func TextArea(attributes AttributeChain, inner string) func(Context) { 95 | return func(ctx Context) { 96 | ctx.startLine() 97 | ctx.write("") 100 | template.HTMLEscape(ctx.w, []byte(strings.Trim(inner, " \t\r\n"))) 101 | ctx.write("") 102 | ctx.endLine() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | "io" 7 | "strings" 8 | "sync" 9 | 10 | lua "github.com/Shopify/go-lua" 11 | blackfriday "github.com/russross/blackfriday/v2" 12 | ) 13 | 14 | // TODO: Come back to this if I find it works better "gopkg.in/russross/blackfriday.v2" 15 | // TODO: integrate "github.com/microcosm-cc/bluemonday" into this code if I start trusting more than one user. 16 | 17 | // This is a content string 18 | type Content struct { 19 | Raw string 20 | // If false, escape the string, if true, emit it raw 21 | Trusted bool 22 | } 23 | 24 | type Attribute struct { 25 | Key string 26 | Value string 27 | // If false, escape the string, if true, emit it raw 28 | Trusted bool 29 | // If true, then this is an attribute without a value 30 | Void bool 31 | } 32 | 33 | type Context struct { 34 | indentCount int 35 | w io.Writer 36 | themeName string 37 | } 38 | 39 | var styleLoaderMux sync.Mutex 40 | 41 | var loaders = make([]func(string) (string, bool), 0) 42 | 43 | func AddStyleLoader(fn func(string) (string, bool)) { 44 | styleLoaderMux.Lock() 45 | loaders = append(loaders, fn) 46 | styleLoaderMux.Unlock() 47 | } 48 | 49 | func GetStyle(name string) (string, bool) { 50 | styleLoaderMux.Lock() 51 | defer styleLoaderMux.Unlock() 52 | for _, fn := range loaders { 53 | if str, ok := fn(name); ok { 54 | return str, ok 55 | } 56 | } 57 | return "", false 58 | } 59 | 60 | func GetLuaRenderingContext(derivedFrom Context) func(l *lua.State) int { 61 | return func(l *lua.State) int { 62 | err := lua.LoadString(l, ` 63 | local strings = require("goluago/strings") 64 | local indentCount, writeFunc, escapeHTML, mdownFunc, themeName = ... 65 | 66 | local ctx = {} 67 | 68 | function ctx.start_line() 69 | for i=1,indentCount,1 do 70 | ctx.write("\t") 71 | end 72 | end 73 | 74 | function ctx.end_line() 75 | writeFunc("\n") 76 | end 77 | 78 | function ctx.write(str) 79 | writeFunc(str) 80 | end 81 | 82 | function ctx.write_line(str) 83 | writeFunc(str) 84 | end 85 | 86 | function ctx.write_tags(inner) 87 | indentCount = indentCount + 1 88 | for i=1,#inner do 89 | inner[i]() 90 | end 91 | indentCount = indentCount - 1 92 | end 93 | 94 | function ctx.write_attributes(attributes) 95 | for i=1,#attributes do 96 | local attr = attributes[i] 97 | ctx.write(" "..attr.Key..'="') 98 | if attr.Trusted then 99 | ctx.write(attr.Value) 100 | else 101 | ctx.write(escapeHTML(attr.Value)) 102 | end 103 | ctx.write('"') 104 | end 105 | end 106 | 107 | function write_void_tag(tagname, attr) 108 | return function() 109 | ctx.start_line() 110 | ctx.write("<"..tagname) 111 | ctx.write_attributes(attr) 112 | ctx.write(">") 113 | ctx.end_line() 114 | end 115 | end 116 | 117 | function write_tag(tagname, attr, ...) 118 | local inner = {...} 119 | return function() 120 | ctx.start_line() 121 | ctx.write("<"..tagname) 122 | ctx.write_attributes(attr) 123 | ctx.write(">") 124 | ctx.end_line() 125 | ctx.write_tags(inner) 126 | ctx.write_line("") 127 | end 128 | end 129 | 130 | local AttributeMT = { 131 | __index = function(t, k) 132 | return function(value) 133 | table.insert(t, {Key = string.lower(k), Value = value, Trusted = false}) 134 | return t 135 | end 136 | end 137 | } 138 | 139 | function Atr() 140 | local atrChain = {} 141 | atrChain.AddUnsafe = function(key, value) 142 | table.insert(atrChain, {Key = string.lower(key), Value = value, Trusted = true}) 143 | end 144 | setmetatable(atrChain, AttributeMT) 145 | return atrChain 146 | end 147 | 148 | function InlineStr(content) 149 | return function() 150 | ctx.write(escapeHTML(content)) 151 | end 152 | end 153 | 154 | function Str(content) 155 | return function() 156 | ctx.start_line() 157 | ctx.write(escapeHTML(content)) 158 | ctx.end_line() 159 | end 160 | end 161 | 162 | function StrBr(content) 163 | return function() 164 | local lines = strings.split(strings.trim(content, " \r\n"), "\n") 165 | for i=1,#lines do 166 | ctx.write(escapeHTML(lines[i].." ")) 167 | end 168 | end 169 | end 170 | 171 | function Markdown(content) 172 | return function() 173 | ctx.write(mdownFunc(content)) 174 | end 175 | end 176 | 177 | 178 | local void_tags = {"br", "hr", "input"} 179 | 180 | local normal_tags = { 181 | "div", "span", "h1", "h2", "h3", "h4", 182 | "title", "a", "table", "td", "form", "label", 183 | "button", 184 | } 185 | 186 | -- Add the above tags as functions to _ENV. 187 | -- This is mostly intended for pages that need to use 188 | -- the HTML renderer 189 | for i=1,#void_tags do 190 | local t = void_tags[i] 191 | local fn_name = string.upper(string.sub(t, 1,1))..string.sub(t, 2) 192 | _ENV[fn_name] = function(attributes, ...) 193 | return write_void_tag(t, attributes, ...) 194 | end 195 | end 196 | for i=1,#normal_tags do 197 | local t = normal_tags[i] 198 | local fn_name = string.upper(string.sub(t, 1,1))..string.sub(t, 2) 199 | _ENV[fn_name] = function(attributes, ...) 200 | return write_tag(t, attributes, ...) 201 | end 202 | end 203 | `) 204 | if err != nil { 205 | str, _ := l.ToString(l.Top()) 206 | panic(str) 207 | } 208 | l.PushInteger(derivedFrom.indentCount) 209 | l.PushGoFunction(func(l *lua.State) int { 210 | str, ok := l.ToString(1) 211 | if !ok { 212 | l.PushString("Cannot convert argument for ctx.write to string!") 213 | l.Error() 214 | } 215 | _, err := derivedFrom.w.Write([]byte(str)) 216 | if err != nil { 217 | l.PushString("Error while writing output: " + err.Error()) 218 | l.Error() 219 | } 220 | return 0 221 | }) 222 | l.PushGoFunction(func(l *lua.State) int { 223 | str, ok := l.ToString(1) 224 | if !ok { 225 | l.PushString("Cannot convert argument for escapeHTML to string!") 226 | l.Error() 227 | } 228 | buf := &bytes.Buffer{} 229 | template.HTMLEscape(buf, []byte(str)) 230 | l.PushString(buf.String()) 231 | return 1 232 | }) 233 | l.PushGoFunction(LuaMarkdown) 234 | l.PushString(derivedFrom.themeName) 235 | err = l.ProtectedCall(5, 0, 0) 236 | if err != nil { 237 | l.PushString(err.Error()) 238 | l.Error() 239 | } 240 | return 0 241 | } 242 | } 243 | 244 | func RenderWithTargetAndTheme(w io.Writer, themeName string, template func(Context)) (err error) { 245 | template(Context{0, w, themeName}) 246 | return nil 247 | } 248 | 249 | func (ctx Context) startLine() { 250 | for i := 0; i < ctx.indentCount; i++ { 251 | _, err := ctx.w.Write([]byte("\t")) 252 | if err != nil { 253 | panic(err) 254 | } 255 | } 256 | } 257 | func (ctx Context) endLine() { 258 | _, err := ctx.w.Write([]byte("\n")) 259 | if err != nil { 260 | panic(err) 261 | } 262 | } 263 | func (ctx Context) write(content string) { 264 | _, err := ctx.w.Write([]byte(content)) 265 | if err != nil { 266 | panic(err) 267 | } 268 | } 269 | 270 | func (ctx Context) writeLine(content string) { 271 | ctx.startLine() 272 | ctx.write(content) 273 | ctx.endLine() 274 | } 275 | 276 | func BasePage(title string, inner ...func(Context)) func(Context) { 277 | return Html(Title(Atr, Str(title)), Body(inner...)) 278 | } 279 | 280 | func Html(inner ...func(Context)) func(Context) { 281 | return func(ctx Context) { 282 | ctx.writeLine("") 283 | ctx.writeLine("") 284 | ctx.writeLine("") 285 | ctx.writeLine(``) 286 | ctx.WriteTags(inner...) 287 | ctx.writeLine("") 288 | } 289 | } 290 | 291 | func RawStr(content string) func(Context) { 292 | return func(ctx Context) { 293 | ctx.write(content) 294 | } 295 | } 296 | 297 | func StrBr(content string) func(Context) { 298 | return func(ctx Context) { 299 | var buf bytes.Buffer 300 | template.HTMLEscape(&buf, []byte(content)) 301 | ctx.startLine() 302 | ctx.write(strings.Replace(buf.String(), "\n", "
", -1)) 303 | ctx.endLine() 304 | } 305 | } 306 | 307 | var mdown_flags = 0 | 308 | blackfriday.Smartypants | 309 | blackfriday.SmartypantsFractions | 310 | blackfriday.FootnoteReturnLinks 311 | 312 | var mdown_extensions = 0 | 313 | blackfriday.Tables | 314 | blackfriday.Footnotes | 315 | blackfriday.FencedCode | 316 | blackfriday.Autolink | 317 | blackfriday.Strikethrough | 318 | blackfriday.HardLineBreak 319 | 320 | var mdown_renderer = blackfriday.NewHTMLRenderer( 321 | blackfriday.HTMLRendererParameters{Flags: mdown_flags}) 322 | 323 | func LuaMarkdown(l *lua.State) int { 324 | content, ok := l.ToString(1) 325 | if !ok { 326 | l.PushString("Cannot render markdown from non-string argument") 327 | l.Error() 328 | } 329 | output := blackfriday.Run( 330 | []byte(content), 331 | blackfriday.WithExtensions(mdown_extensions), 332 | blackfriday.WithRenderer(mdown_renderer), 333 | ) 334 | l.PushString(string(output)) 335 | return 1 336 | } 337 | 338 | func Markdown(content string) func(Context) { 339 | return func(ctx Context) { 340 | output := blackfriday.Run( 341 | []byte(content), 342 | blackfriday.WithExtensions(mdown_extensions), 343 | blackfriday.WithRenderer(mdown_renderer), 344 | ) 345 | ctx.write(string(output)) 346 | } 347 | } 348 | 349 | func Str(content string) func(Context) { 350 | return func(ctx Context) { 351 | ctx.startLine() 352 | template.HTMLEscape(ctx.w, []byte(content)) 353 | ctx.endLine() 354 | } 355 | } 356 | 357 | func Body(inner ...func(ctx Context)) func(ctx Context) { 358 | return WriteTag("body", Atr, append([]func(Context){baseStyle}, inner...)...) 359 | } 360 | 361 | func (ctx Context) indentMultiline(str string) { 362 | var lines = strings.Split(strings.Trim(str, " \r\n"), "\n") 363 | for _, line := range lines { 364 | ctx.writeLine(strings.Trim(line, " \r\n")) 365 | } 366 | } 367 | 368 | var baseStyle = WriteTag("style", Atr, func(ctx Context) { 369 | if ctx.themeName == "AQUA" { 370 | ctx.indentMultiline(` 371 | body { 372 | max-width: 1200px; 373 | width: 80%; 374 | } 375 | body,input,textarea,button { 376 | font-family: Iosevka, monospace; 377 | background: #191e2a; 378 | color: #21EF9F; 379 | } 380 | textarea[name=note-content] { width: 100%; } 381 | a { color: aqua; } 382 | a:visited { color: #1ad6d6; } 383 | .note-card { 384 | border-top: solid 1px #21EF9F; 385 | margin-top: 5px; 386 | padding-top: 5px; 387 | } 388 | img {max-width: 85%;} 389 | `) 390 | } 391 | GetStyle(ctx.themeName) 392 | }) 393 | 394 | func (ctx Context) writeAttributes(attributes AttributeChain) { 395 | for _, attr := range attributes { 396 | ctx.write(" " + attr.Key) 397 | if attr.Void { 398 | continue 399 | } 400 | ctx.write("=\"") 401 | if attr.Trusted { 402 | ctx.write(attr.Value) 403 | } else { 404 | template.HTMLEscape(ctx.w, []byte(attr.Value)) 405 | } 406 | ctx.write("\"") 407 | } 408 | } 409 | 410 | func WriteVoidTag(tagname string, attributes AttributeChain) func(Context) { 411 | return func(ctx Context) { 412 | ctx.startLine() 413 | ctx.write("<" + tagname) 414 | ctx.writeAttributes(attributes) 415 | ctx.write(">") 416 | ctx.endLine() 417 | } 418 | } 419 | 420 | func (ctx Context) WriteTags(inner ...func(ctx Context)) { 421 | innerCtx := Context{ctx.indentCount + 1, ctx.w, ctx.themeName} 422 | for _, fn := range inner { 423 | fn(innerCtx) 424 | } 425 | } 426 | 427 | func WriteTag(tagname string, attributes AttributeChain, inner ...func(ctx Context)) func(ctx Context) { 428 | return func(ctx Context) { 429 | ctx.startLine() 430 | ctx.write("<") 431 | ctx.write(tagname) 432 | ctx.writeAttributes(attributes) 433 | ctx.write(">") 434 | ctx.endLine() 435 | ctx.WriteTags(inner...) 436 | ctx.writeLine("") 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | Things to do: 2 | 3 | - Basic code-based html templating library. (done enough) 4 | - SQLite repository (done enough) 5 | 6 | - Routes 7 | - - Search Notes 8 | - - Create Note 9 | - - Save Note 10 | - - File upload 11 | - - Delete note 12 | 13 | 14 | 15 | I need to finish putting out the recent notes section under the draft section -------------------------------------------------------------------------------- /uploadView.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | 6 | . "gills/templates" 7 | ) 8 | 9 | var uploadViewLayout = ` 10 | input[type="file"] { 11 | display: none; 12 | } 13 | 14 | #submit-upload { 15 | display: none; 16 | } 17 | 18 | #q, #search-button, .custom-file-upload { 19 | font-size: 24px; 20 | } 21 | 22 | #q { 23 | width: 60%; 24 | } 25 | #search-button { 26 | width: 35%; 27 | } 28 | .custom-file-upload { 29 | display: block; 30 | width: 95%; 31 | padding: 6px 12px; 32 | font-size: bigger; 33 | border: 2px solid grey; 34 | margin-bottom: 20px; 35 | text-align: center; 36 | } 37 | ` 38 | 39 | var uploadScript = ` 40 | function byId(sel) { return document.getElementById(sel) ; }; 41 | byId("take-photo-text").innerHTML = "Take Photo"; 42 | byId("upload").onchange = function() { 43 | byId("submit-upload").click(); 44 | }; 45 | ` 46 | 47 | func UploadView(w io.Writer, state AppState, searchTerms string, recentUploadNotes []Note) error { 48 | var template = BasePage(state.AppName(), 49 | Style(uploadViewLayout), 50 | H2(Atr, Str("Upload images")), 51 | Form(Atr.Action("/admin/upload").Method("GET").EncType("multipart/form-data"), 52 | Label(Atr.For("upload").Class("custom-file-upload"), 53 | Span(Atr.Id("take-photo-text"), Str("Loading...")), 54 | Input(Atr.Id("upload").Type("file").Name("upload").Accept("image/*").Multiple()), 55 | ), 56 | Button(Atr.Id("submit-upload").FormAction("/admin/upload").FormMethod("POST"), Str("Upload Image")), 57 | Div(Atr, 58 | Label(Atr.For("q"), 59 | Input(Atr.Type("text").Id("q").Name("q").Value(searchTerms)), 60 | ), 61 | Input(Atr.Type("Submit").Id("search-button").Value("Search Notes")), 62 | ), 63 | RecentNotes(recentUploadNotes, 5), 64 | ), 65 | JS(uploadScript), 66 | ) 67 | return RenderWithTargetAndTheme(w, "AQUA", template) 68 | } 69 | -------------------------------------------------------------------------------- /uploads.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "os" 8 | "path" 9 | 10 | "gopkg.in/myesui/uuid.v1" 11 | ) 12 | 13 | var pathDir string 14 | 15 | func PathForName(name string) string { 16 | return path.Join(pathDir, name) 17 | } 18 | 19 | func SaveUploadedFile(formFile io.Reader, extension string) (string, error) { 20 | randName := uuid.BulkV4(1)[0].String() + "." + extension 21 | ostream, err := os.OpenFile(path.Join(pathDir, randName), os.O_CREATE|os.O_WRONLY, 0750) 22 | defer ostream.Close() 23 | if err != nil { 24 | return "", err 25 | } 26 | if _, err := io.Copy(ostream, formFile); err != nil { 27 | return "", err 28 | } 29 | return randName, nil 30 | } 31 | 32 | func NoteForFileNames(names []string, tag, notes string) Note { 33 | var buf bytes.Buffer 34 | if len(notes) > 0 { 35 | buf.WriteString(notes) 36 | buf.WriteString("\n") 37 | } 38 | 39 | for _, name := range names { 40 | buf.WriteString("![Uploaded file](/admin/upload/") 41 | buf.WriteString(name) 42 | buf.WriteString(")\n\n") 43 | } 44 | // If we have a category for this page, use that, otherwise, give it the default category 45 | if len(tag) > 0 { 46 | buf.WriteString(tag) 47 | } else { 48 | buf.WriteString("@archive @upload-") 49 | buf.WriteString(UploadUUID) 50 | } 51 | 52 | return Note{Content: buf.String()} 53 | } 54 | 55 | var ErrNotDir = errors.New("$PWD/uploads is not a directory!") 56 | 57 | func prepUploadFolder() error { 58 | uuid.SwitchFormat(uuid.FormatHex) 59 | var wd, err = os.Getwd() 60 | if err != nil { 61 | return err 62 | } 63 | pathDir = path.Join(wd, "uploads") 64 | if info, err := os.Stat(path.Join(wd, "uploads")); os.IsNotExist(err) { 65 | err := os.Mkdir(pathDir, 0750) 66 | if err != nil { 67 | return err 68 | } 69 | } else { 70 | if !info.IsDir() { 71 | return ErrNotDir 72 | } 73 | } 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /views.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | . "gills/templates" 11 | ) 12 | 13 | func SearchView(w io.Writer, appName, searchTerms string, searchedNotes []Note) error { 14 | var template = BasePage(appName, 15 | H2(Atr, Str(appName+" Search")), 16 | Div(Atr, 17 | A(Atr.Href("/admin/upload"), Str("Upload Photos")), 18 | ), 19 | Div(Atr, 20 | A(Atr.Href("/admin/"), Str("View Other Notes")), 21 | ), 22 | Div(Atr, 23 | A(Atr.Href("/admin/scripting"), Str("Scripting")), 24 | ), 25 | Form(Atr.Action("/admin/search").Method("GET"), 26 | Label(Atr.For("q"), 27 | Input(Atr.Type("text").Name("q").Value(searchTerms)), 28 | ), 29 | Input(Atr.Type("Submit").Value("Search Notes")), 30 | RecentNotes(searchedNotes, min(25, len(searchedNotes))), 31 | ), 32 | ) 33 | return RenderWithTargetAndTheme(w, "AQUA", template) 34 | } 35 | 36 | func InvalidScriptNameView(w io.Writer, appName, scriptName, message string) error { 37 | var template = BasePage(appName, 38 | H2(Atr, Str(message)), 39 | Str(scriptName)) 40 | 41 | return RenderWithTargetAndTheme(w, "AQUA", template) 42 | } 43 | func InvalidIdView(w io.Writer, appName, message, invalidID string) error { 44 | var template = BasePage(appName, 45 | H2(Atr, Str("400: You sent me a goofy request")), 46 | Str(fmt.Sprint(message, invalidID))) 47 | 48 | return RenderWithTargetAndTheme(w, "AQUA", template) 49 | } 50 | 51 | func RecentNotes(notes []Note, count int) func(Context) { 52 | return func(ctx Context) { 53 | numNotes := min(len(notes), count) 54 | for i := 0; i < numNotes; i++ { 55 | renderNote(notes[i], ctx) 56 | } 57 | } 58 | } 59 | 60 | func DimensionsOf(n Note) AttributeChain { 61 | lines := strings.Split(n.Content, "\n") 62 | numLines := strconv.Itoa(len(lines)) 63 | maxLineLen := 0 64 | for _, l := range lines { 65 | maxLineLen = max(maxLineLen, len(strings.Trim(l, "\t \r\n"))) 66 | } 67 | return Atr.Cols(strconv.Itoa(maxLineLen)).Rows(numLines) 68 | } 69 | 70 | func renderNote(n Note, ctx Context) { 71 | noteShowURL := fmt.Sprint("/admin/note/", n.Id, "/show") 72 | Div(Atr.Add("data-note-id", fmt.Sprint(n.Id)).Class("note-card"), 73 | Div(Atr, 74 | Str(tfmt(n.Created)), 75 | Input(Atr.Type("submit").Value("Edit").FormAction(noteShowURL).FormMethod("POST")), 76 | ), 77 | Markdown(n.Content), 78 | )(ctx) 79 | } 80 | 81 | func tfmt(t time.Time) string { 82 | return fmt.Sprintf("%d-%02d-%02d %02d:%02d", 83 | t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute()) 84 | } 85 | 86 | func max(x, y int) int { 87 | if x < y { 88 | return y 89 | } 90 | return x 91 | } 92 | 93 | func min(x, y int) int { 94 | if x > y { 95 | return y 96 | } 97 | return x 98 | } 99 | --------------------------------------------------------------------------------