├── .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("")
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(""..tagname..">")
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("" + tagname + ">")
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("
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 |
--------------------------------------------------------------------------------