main.go (6909B)
1 package main 2 3 import ( 4 "bufio" 5 "flag" 6 "fmt" 7 "log" 8 "net/http" 9 "os" 10 "os/exec" 11 "regexp" 12 "strings" 13 "time" 14 ) 15 16 var filePath string 17 18 func main() { 19 port := flag.String("p", "8080", "port to listen on") 20 file := flag.String("f", "", "path to markdown file (required)") 21 flag.Parse() 22 23 if *file == "" { 24 fmt.Fprintln(os.Stderr, "Usage: weight-tracker -f <file.md> [-p <port>]") 25 flag.PrintDefaults() 26 os.Exit(1) 27 } 28 filePath = *file 29 30 if _, err := os.Stat(filePath); err != nil { 31 fmt.Fprintf(os.Stderr, "Cannot open %s: %v\n", filePath, err) 32 os.Exit(1) 33 } 34 35 http.HandleFunc("/", handleIndex) 36 http.HandleFunc("/submit", handleSubmit) 37 38 addr := ":" + *port 39 fmt.Printf("Listening on %s\n", addr) 40 log.Fatal(http.ListenAndServe(addr, nil)) 41 } 42 43 func readFile() (string, error) { 44 b, err := os.ReadFile(filePath) 45 if err != nil { 46 return "", err 47 } 48 return string(b), nil 49 } 50 51 func extractSVG(content string) string { 52 re := regexp.MustCompile(`(?s)<svg[\s\S]*?</svg>`) 53 return re.FindString(content) 54 } 55 56 func extractEntries(content string) []string { 57 re := regexp.MustCompile(`(?s)<pre>([\s\S]*?)</pre>`) 58 m := re.FindStringSubmatch(content) 59 if m == nil { 60 return nil 61 } 62 var entries []string 63 scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(m[1]))) 64 for scanner.Scan() { 65 line := strings.TrimSpace(scanner.Text()) 66 if line != "" { 67 entries = append(entries, line) 68 } 69 } 70 return entries 71 } 72 73 func todayStr() string { 74 return time.Now().Format("2006-01-02") 75 } 76 77 func entryExistsForToday(entries []string) bool { 78 today := todayStr() 79 for _, e := range entries { 80 if strings.HasPrefix(e, today) { 81 return true 82 } 83 } 84 return false 85 } 86 87 func updatePreBlock(content, weight string) string { 88 today := todayStr() 89 newLine := today + "," + weight 90 91 re := regexp.MustCompile(`(?s)(<pre>)([\s\S]*?)(</pre>)`) 92 return re.ReplaceAllStringFunc(content, func(block string) string { 93 parts := re.FindStringSubmatch(block) 94 if parts == nil { 95 return block 96 } 97 raw := parts[2] 98 lines := strings.Split(raw, "\n") 99 replaced := false 100 for i, line := range lines { 101 if strings.HasPrefix(strings.TrimSpace(line), today) { 102 lines[i] = newLine 103 replaced = true 104 break 105 } 106 } 107 if !replaced { 108 trimmed := strings.TrimRight(raw, "\n ") 109 raw = trimmed + "\n" + newLine + "\n" 110 } else { 111 raw = strings.Join(lines, "\n") 112 } 113 return parts[1] + raw + parts[3] 114 }) 115 } 116 117 func handleSubmit(w http.ResponseWriter, r *http.Request) { 118 if r.Method != http.MethodPost { 119 http.Redirect(w, r, "/", http.StatusSeeOther) 120 return 121 } 122 weight := strings.TrimSpace(r.FormValue("weight")) 123 if weight == "" { 124 http.Redirect(w, r, "/", http.StatusSeeOther) 125 return 126 } 127 if !strings.Contains(weight, ".") { 128 weight += ".0" 129 } 130 131 content, err := readFile() 132 if err != nil { 133 http.Error(w, "Could not read file: "+err.Error(), http.StatusInternalServerError) 134 return 135 } 136 137 updated := updatePreBlock(content, weight) 138 139 if err := os.WriteFile(filePath, []byte(updated), 0644); err != nil { 140 http.Error(w, "Could not write file: "+err.Error(), http.StatusInternalServerError) 141 return 142 } 143 144 // Run blog-weight to regenerate graph / markdown. 145 cmd := exec.Command("blog-weight") 146 cmd.Stdout = os.Stdout 147 cmd.Stderr = os.Stderr 148 if err := cmd.Run(); err != nil { 149 log.Printf("blog-weight exited with error: %v", err) 150 } 151 152 http.Redirect(w, r, "/", http.StatusSeeOther) 153 } 154 155 func handleIndex(w http.ResponseWriter, r *http.Request) { 156 content, err := readFile() 157 if err != nil { 158 http.Error(w, "Could not read file: "+err.Error(), http.StatusInternalServerError) 159 return 160 } 161 162 svg := extractSVG(content) 163 entries := extractEntries(content) 164 today := todayStr() 165 alreadyLogged := entryExistsForToday(entries) 166 167 lastEntry := "" 168 if len(entries) > 0 { 169 lastEntry = entries[len(entries)-1] 170 } 171 172 start := len(entries) - 14 173 if start < 0 { 174 start = 0 175 } 176 recent := entries[start:] 177 178 w.Header().Set("Content-Type", "text/html; charset=utf-8") 179 180 autofocusAttr := "autofocus" 181 182 alreadyLoggedHTML := "" 183 if alreadyLogged { 184 alreadyLoggedHTML = `<p class="logged">Already logged today: ` + lastEntry + `</p>` 185 } 186 187 recentRows := "" 188 for _, e := range recent { 189 parts := strings.SplitN(e, ",", 2) 190 date, val := parts[0], "" 191 if len(parts) == 2 { 192 val = parts[1] 193 } 194 recentRows += fmt.Sprintf("<tr><td>%s</td><td>%s kg</td></tr>\n", date, val) 195 } 196 197 recentTable := "" 198 if len(recent) > 0 { 199 recentTable = `<details class="card"><summary><h2>Recent entries</h2></summary><table>` + recentRows + `</table></details>` 200 } 201 202 svgBlock := "" 203 if svg != "" { 204 svgBlock = `<div class="card"><h2>Chart</h2>` + svg + `</div>` 205 } 206 207 html := `<!doctype html> 208 <html lang="en"> 209 <head> 210 <meta charset="UTF-8"> 211 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> 212 <title>Weight Logger</title> 213 <style> 214 *{box-sizing:border-box;margin:0;padding:0} 215 .card h2,body,button,button:hover{color:#d4d4d4} 216 .card h2,button{font-size:1rem;color:#d4d4d4} 217 .card h2{margin-bottom:12px} 218 .card,body{padding:16px} 219 .card{background:#222;border-radius:12px;margin-bottom:16px;border:1px solid #333;box-shadow:none} 220 .date-label{font-size:.85rem;color:#777;margin-bottom:8px} 221 .logged,table{font-size:.9rem} 222 .input-row{display:flex;gap:8px;align-items:center} 223 .logged{color:#7cbf7c;font-weight:600;margin-top:8px} 224 body{font-family:monospace;background:#1a1a1a;max-width:600px;margin:0 auto} 225 button:active{opacity:.8} 226 button:hover{background:#2e2e2e} 227 button{padding:10px 20px;background:#2a2a2a;border:1px solid #333;border-radius:8px;cursor:pointer;white-space:nowrap} 228 input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none} 229 input[type=number]{flex:1;font-size:1.4rem;padding:10px 14px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#d4d4d4;-moz-appearance:textfield} 230 svg,table{width:100%} 231 svg{height:auto;display:block} 232 svg text{fill:#d4d4d4} 233 table{border-collapse:collapse} 234 td:last-child{text-align:right;font-variant-numeric:tabular-nums} 235 td{padding:6px 4px;border-bottom:1px solid #333} 236 tr:last-child td{border-bottom:none;font-weight:600} 237 details summary{cursor:pointer;list-style:none} 238 details summary::-webkit-details-marker{display:none} 239 details summary h2{display:inline} 240 details summary:hover{color:#5b9bd5} 241 details summary::after{content:" ▸";color:#777} 242 details[open] summary::after{content:" ▾"} 243 </style> 244 </head> 245 <body> 246 <div class="card"> 247 <h2>Log today's weight</h2> 248 <p class="date-label">` + today + `</p> 249 <form method="POST" action="/submit"> 250 <div class="input-row"> 251 <input type="number" name="weight" step="0.1" min="30" max="300" 252 placeholder="kg" inputmode="decimal" ` + autofocusAttr + `> 253 <button type="submit">Save</button> 254 </div> 255 </form> 256 ` + alreadyLoggedHTML + ` 257 </div> 258 ` + recentTable + ` 259 ` + svgBlock + ` 260 </body></html>` 261 262 fmt.Fprint(w, html) 263 }