Skip to content

Commit 34ef51b

Browse files
authored
Merge pull request #14 from Tanq16/featurenew
Config File and Settings from UI
2 parents 6f1a1df + 449fe8e commit 34ef51b

File tree

8 files changed

+490
-34
lines changed

8 files changed

+490
-34
lines changed

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ So, I created this project, which I use in my home lab to track my expenses. Thi
3636
- Multi-architecture Docker container with support for persistent storage
3737
- REST API for expense management
3838
- Single-user focused (mainly for a home lab deployment)
39-
- CSV export of all expense data from the UI
40-
- Custom categories via environment variable (`EXPENSE_CATEGORIES`) with sensible defaults
41-
- Custom currency symbol in the frontend via environment variable (`CURRENCY`)
39+
- CSV and JSON export of all expense data from the UI
40+
- Custom categories via app settings or environment variable (`EXPENSE_CATEGORIES`)
41+
- Custom currency symbol in the frontend via app settings environment variable (`CURRENCY`)
4242

4343
### Visualization
4444

@@ -50,6 +50,7 @@ So, I created this project, which I use in my home lab to track my expenses. Thi
5050
- This is where you can view individual expenses chronologically and delete them
5151
- You can use the browser's search to find a name if needed
5252
3. Month-by-month navigation
53+
4. Settings page for setting custom categories, currency, and export data as CSV or JSON
5354

5455
### Progressive Web App (PWA)
5556

@@ -180,26 +181,30 @@ curl http://localhost:8080/expenses
180181

181182
### Config Options
182183

184+
The primary config is stored in the data directory in the `config.json` file. A pre-defined configuration is automatically initialized. The currency in use and the categories can be customized from the `/settings` endpoint within the UI.
185+
183186
##### Currency Settings
184187

185-
ExpenseOwl supports multiple currencies through the CURRENCY environment variable. If not specified, it defaults to USD ($). For example, to run with Euro, use the following environment variable:
188+
ExpenseOwl supports multiple currencies through the CURRENCY environment variable. If not specified, it defaults to USD ($). All available options are shown in the UI settings page.
189+
190+
Alternatively, an environment variable can also be used to set the currency. This is useful for containerized deployments where non-sensitive configuration can remain as deployment templates. For example, to use Euro:
186191

187192
```bash
188193
CURRENCY=eur ./expenseowl
189194
```
190195

191-
Similarly, the environment variable can be set in a compose stack or using `-e` in the command line with a Docker command. The full list of supported currencies is in [this file](https://github.com/Tanq16/ExpenseOwl/blob/main/internal/config/config.go#L27).
196+
The environment variable can be set in a compose stack or using `-e` in the command line with a Docker command.
192197

193198
##### Category Settings
194199

195-
ExpenseOwl also supports custom categories, which can be specified through environment variables like so:
200+
ExpenseOwl also supports custom categories. A default set is pre-loaded in the config for ease of use and can be easily changed within the UI.
201+
202+
Alternatively, like currency, categories can also be specified in an environment variable like so:
196203

197204
```bash
198205
EXPENSE_CATEGORIES="Rent,Food,Transport,Fun,Bills" ./expenseowl
199206
```
200207

201-
Similarly, it can be specified in a Docker compose stack of a Docker CLI command with the `-e` flag. Refer to the examples shown above in the README.
202-
203208
# Contributing
204209

205210
Contributions are welcome; please ensure they align with the project's philosophy of simplicity. The project's core principle is maintaining user-facing simplicity by strictly using the current [tech stack](#technology-stack). It is intended for home lab use, i.e., a self-hosted first approach (containerized use). Consider the following:

cmd/expenseowl/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ func runServer(dataPath string) {
2626
http.HandleFunc("/expense", handler.AddExpense)
2727
http.HandleFunc("/expenses", handler.GetExpenses)
2828
http.HandleFunc("/table", handler.ServeTableView)
29+
http.HandleFunc("/settings", handler.ServeSettingsPage)
2930
http.HandleFunc("/expense/delete", handler.DeleteExpense)
30-
http.HandleFunc("/export", handler.ExportCSV)
31+
http.HandleFunc("/export/json", handler.ExportJSON)
32+
http.HandleFunc("/export/csv", handler.ExportCSV)
3133
http.HandleFunc("/manifest.json", handler.ServeManifest)
3234
http.HandleFunc("/sw.js", handler.ServeServiceWorker)
3335
http.HandleFunc("/pwa/", handler.ServePWAIcon)

internal/api/handlers.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func (h *Handler) EditCategories(w http.ResponseWriter, r *http.Request) {
6767
log.Printf("HTTP ERROR: Failed to decode request body: %v\n", err)
6868
return
6969
}
70-
h.config.Categories = categories
70+
h.config.UpdateCategories(categories)
7171
writeJSON(w, http.StatusOK, map[string]string{"status": "success"})
7272
log.Println("HTTP: Updated categories")
7373
}
@@ -84,7 +84,7 @@ func (h *Handler) EditCurrency(w http.ResponseWriter, r *http.Request) {
8484
log.Printf("HTTP ERROR: Failed to decode request body: %v\n", err)
8585
return
8686
}
87-
h.config.Currency = currency
87+
h.config.UpdateCurrency(currency)
8888
writeJSON(w, http.StatusOK, map[string]string{"status": "success"})
8989
log.Println("HTTP: Updated currency")
9090
}
@@ -152,6 +152,20 @@ func (h *Handler) ServeTableView(w http.ResponseWriter, r *http.Request) {
152152
}
153153
}
154154

155+
func (h *Handler) ServeSettingsPage(w http.ResponseWriter, r *http.Request) {
156+
if r.Method != http.MethodGet {
157+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
158+
log.Println("HTTP ERROR: Method not allowed")
159+
return
160+
}
161+
w.Header().Set("Content-Type", "text/html")
162+
if err := web.ServeTemplate(w, "settings.html"); err != nil {
163+
http.Error(w, "Failed to serve template", http.StatusInternalServerError)
164+
log.Printf("HTTP ERROR: Failed to serve template: %v\n", err)
165+
return
166+
}
167+
}
168+
155169
func (h *Handler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
156170
if r.Method != http.MethodDelete {
157171
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -254,6 +268,31 @@ func (h *Handler) ExportCSV(w http.ResponseWriter, r *http.Request) {
254268
log.Println("HTTP: Exported expenses to CSV")
255269
}
256270

271+
func (h *Handler) ExportJSON(w http.ResponseWriter, r *http.Request) {
272+
if r.Method != http.MethodGet {
273+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
274+
log.Println("HTTP ERROR: Method not allowed")
275+
return
276+
}
277+
expenses, err := h.storage.GetAllExpenses()
278+
if err != nil {
279+
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Failed to retrieve expenses"})
280+
log.Printf("HTTP ERROR: Failed to retrieve expenses: %v\n", err)
281+
return
282+
}
283+
w.Header().Set("Content-Type", "application/json")
284+
w.Header().Set("Content-Disposition", "attachment; filename=expenses.json")
285+
// Pretty print the JSON data for better readability
286+
jsonData, err := json.MarshalIndent(expenses, "", " ")
287+
if err != nil {
288+
http.Error(w, "Failed to marshal JSON data", http.StatusInternalServerError)
289+
log.Printf("HTTP ERROR: Failed to marshal JSON data: %v\n", err)
290+
return
291+
}
292+
w.Write(jsonData)
293+
log.Println("HTTP: Exported expenses to JSON")
294+
}
295+
257296
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
258297
w.Header().Set("Content-Type", "application/json")
259298
w.WriteHeader(status)

internal/config/config.go

Lines changed: 103 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package config
22

33
import (
4+
"encoding/json"
45
"errors"
56
"log"
67
"os"
78
"path/filepath"
89
"strings"
10+
"sync"
911
"time"
1012
)
1113

@@ -14,6 +16,12 @@ type Config struct {
1416
StoragePath string
1517
Categories []string
1618
Currency string
19+
mu sync.RWMutex
20+
}
21+
22+
type FileConfig struct {
23+
Categories []string `json:"categories"`
24+
Currency string `json:"currency"`
1725
}
1826

1927
var defaultCategories = []string{
@@ -81,32 +89,110 @@ func (e *Expense) Validate() error {
8189
}
8290

8391
func NewConfig(dataPath string) *Config {
84-
categories := defaultCategories
92+
finalPath := ""
93+
if dataPath == "data" {
94+
finalPath = filepath.Join(".", "data")
95+
} else {
96+
finalPath = filepath.Clean(dataPath)
97+
}
98+
if err := os.MkdirAll(finalPath, 0755); err != nil {
99+
log.Printf("Error creating data directory: %v", err)
100+
}
101+
log.Printf("Using data directory: %s\n", finalPath)
102+
cfg := &Config{
103+
ServerPort: "8080",
104+
StoragePath: finalPath,
105+
Categories: defaultCategories,
106+
Currency: "$", // Default to USD
107+
}
108+
configPath := filepath.Join(finalPath, "config.json")
109+
if fileConfig, err := loadConfigFile(configPath); err == nil {
110+
cfg.Categories = fileConfig.Categories
111+
cfg.Currency = fileConfig.Currency
112+
log.Println("Loaded configuration from file")
113+
}
85114
if envCategories := os.Getenv("EXPENSE_CATEGORIES"); envCategories != "" {
86-
categories = strings.Split(envCategories, ",")
115+
categories := strings.Split(envCategories, ",")
87116
for i := range categories {
88117
categories[i] = strings.TrimSpace(categories[i])
89118
}
119+
cfg.Categories = categories
120+
log.Println("Using custom categories from environment variables")
90121
}
91-
log.Println("Using custom categories from environment variables")
92-
currency := "$" // Default to USD
93122
if envCurrency := strings.ToLower(os.Getenv("CURRENCY")); envCurrency != "" {
94123
if symbol, exists := currencySymbols[envCurrency]; exists {
95-
currency = symbol
124+
cfg.Currency = symbol
96125
}
126+
log.Println("Using custom currency from environment variables")
97127
}
98-
log.Println("Using custom currency from environment variables")
99-
finalPath := ""
100-
if dataPath == "data" {
101-
finalPath = filepath.Join(".", "data")
102-
} else {
103-
finalPath = filepath.Clean(dataPath)
128+
cfg.SaveConfig()
129+
return cfg
130+
}
131+
132+
func loadConfigFile(filePath string) (*FileConfig, error) {
133+
data, err := os.ReadFile(filePath)
134+
if err != nil {
135+
return nil, err
104136
}
105-
log.Printf("Using data directory: %s\n", finalPath)
106-
return &Config{
107-
ServerPort: "8080",
108-
StoragePath: finalPath,
109-
Categories: categories,
110-
Currency: currency,
137+
var config FileConfig
138+
if err := json.Unmarshal(data, &config); err != nil {
139+
return nil, err
140+
}
141+
return &config, nil
142+
}
143+
144+
func (c *Config) SaveConfig() error {
145+
c.mu.Lock()
146+
defer c.mu.Unlock()
147+
filePath := filepath.Join(c.StoragePath, "config.json")
148+
fileConfig := FileConfig{
149+
Categories: c.Categories,
150+
Currency: c.Currency,
151+
}
152+
data, err := json.MarshalIndent(fileConfig, "", " ")
153+
if err != nil {
154+
return err
111155
}
156+
return os.WriteFile(filePath, data, 0644)
112157
}
158+
159+
func (c *Config) UpdateCategories(categories []string) error {
160+
c.mu.Lock()
161+
c.Categories = categories
162+
c.mu.Unlock()
163+
return c.SaveConfig()
164+
}
165+
166+
func (c *Config) UpdateCurrency(currencyCode string) error {
167+
c.mu.Lock()
168+
if symbol, exists := currencySymbols[strings.ToLower(currencyCode)]; exists {
169+
c.Currency = symbol
170+
} else {
171+
c.mu.Unlock()
172+
return errors.New("invalid currency code")
173+
}
174+
c.mu.Unlock()
175+
return c.SaveConfig()
176+
}
177+
178+
// func (c *Config) GetCategories() []string {
179+
// c.mu.RLock()
180+
// defer c.mu.RUnlock()
181+
// categories := make([]string, len(c.Categories))
182+
// copy(categories, c.Categories)
183+
// return categories
184+
// }
185+
186+
// func (c *Config) GetCurrency() string {
187+
// c.mu.RLock()
188+
// defer c.mu.RUnlock()
189+
// return c.Currency
190+
// }
191+
192+
// func GetCurrencySymbolMap() map[string]string {
193+
// symbolMap := make(map[string]string)
194+
// for k, v := range currencySymbols {
195+
// symbolMap[k] = v
196+
// }
197+
// return symbolMap
198+
// }

internal/web/templates/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ <h1 align="center">ExpenseOwl</h1>
3131
<a href="/table" class="view-button" data-tooltip="Table View">
3232
<i class="fa-solid fa-table"></i>
3333
</a>
34-
<a href="/export" class="view-button" download="expenses.csv" data-tooltip="All expenses to CSV">
35-
<i class="fa-solid fa-file-csv"></i>
34+
<a href="/settings" class="view-button" data-tooltip="Settings">
35+
<i class="fa-solid fa-gear"></i>
3636
</a>
3737
</div>
3838
</header>
@@ -114,7 +114,7 @@ <h1 align="center">ExpenseOwl</h1>
114114
if (postfixCurrencies.has(currencySymbol)) {
115115
return `${formattedAmount} ${currencySymbol}`;
116116
}
117-
return `${currencySymbol}${formattedAmount}`;
117+
return `${currencySymbol} ${formattedAmount}`;
118118
}
119119

120120
function calculateCategoryBreakdown(expenses) {

0 commit comments

Comments
 (0)