package handlers import ( "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "docman/internal/db" "docman/internal/processor" "github.com/labstack/echo/v4" ) type Handlers struct { db *db.DB processor *processor.Processor storeDir string recordsDir string } func New(database *db.DB, proc *processor.Processor, storeDir, recordsDir string) *Handlers { return &Handlers{ db: database, processor: proc, storeDir: storeDir, recordsDir: recordsDir, } } // Page handlers func (h *Handlers) Dashboard(c echo.Context) error { stats, _ := h.db.GetStats() recent, _ := h.db.RecentDocuments(12) return c.Render(http.StatusOK, "dashboard", map[string]interface{}{ "Stats": stats, "Recent": recent, "Active": "dashboard", }) } func (h *Handlers) Browse(c echo.Context) error { category := c.QueryParam("category") page, _ := strconv.Atoi(c.QueryParam("page")) if page < 1 { page = 1 } limit := 24 offset := (page - 1) * limit docs, _ := h.db.ListDocuments(category, limit, offset) stats, _ := h.db.GetStats() categories := []string{"taxes", "expenses", "bills", "medical", "contacts", "legal", "insurance", "banking", "receipts", "correspondence", "uncategorized"} return c.Render(http.StatusOK, "browse", map[string]interface{}{ "Documents": docs, "Category": category, "Categories": categories, "Page": page, "Stats": stats, "Active": "browse", }) } func (h *Handlers) Document(c echo.Context) error { id := c.Param("id") doc, err := h.db.GetDocument(id) if err != nil { return c.Render(http.StatusNotFound, "error", map[string]interface{}{ "Error": "Document not found", }) } // Read markdown content if available var markdownContent string if doc.MarkdownPath != "" { if data, err := os.ReadFile(doc.MarkdownPath); err == nil { markdownContent = string(data) } } return c.Render(http.StatusOK, "document", map[string]interface{}{ "Document": doc, "Markdown": markdownContent, "Active": "browse", }) } func (h *Handlers) Search(c echo.Context) error { query := c.QueryParam("q") var results []*db.SearchResult if query != "" { // Try FTS first if ftsResults, err := h.db.SearchFTS(query, 50); err == nil && len(ftsResults) > 0 { results = ftsResults } else { // Fallback to markdown search mdResults, _ := processor.SearchMarkdown(h.recordsDir, query, 50) results = mdResults } } return c.Render(http.StatusOK, "search", map[string]interface{}{ "Query": query, "Results": results, "Active": "search", }) } func (h *Handlers) Expenses(c echo.Context) error { year, _ := strconv.Atoi(c.QueryParam("year")) month, _ := strconv.Atoi(c.QueryParam("month")) if year == 0 { year = time.Now().Year() } docs, _ := h.db.GetExpenses(year, month) // Calculate totals var total float64 var deductible float64 for _, doc := range docs { if doc.Amount != nil { total += *doc.Amount if doc.TaxDeductible { deductible += *doc.Amount } } } years := []int{} for y := time.Now().Year(); y >= time.Now().Year()-5; y-- { years = append(years, y) } return c.Render(http.StatusOK, "expenses", map[string]interface{}{ "Documents": docs, "Year": year, "Month": month, "Total": total, "Deductible": deductible, "Years": years, "Active": "expenses", }) } func (h *Handlers) Upload(c echo.Context) error { return c.Render(http.StatusOK, "upload", map[string]interface{}{ "Active": "upload", }) } // API handlers func (h *Handlers) APIUpload(c echo.Context) error { file, err := c.FormFile("file") if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "No file uploaded"}) } src, err := file.Open() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Cannot open file"}) } defer src.Close() data, err := io.ReadAll(src) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Cannot read file"}) } doc, err := h.processor.ProcessSingle(c.Request().Context(), data, file.Filename) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // For htmx, return the document card if c.Request().Header.Get("HX-Request") == "true" { return c.Render(http.StatusOK, "document-card", doc) } return c.JSON(http.StatusOK, doc) } func (h *Handlers) APIDocument(c echo.Context) error { id := c.Param("id") doc, err := h.db.GetDocument(id) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "Not found"}) } return c.JSON(http.StatusOK, doc) } func (h *Handlers) APIUpdateDocument(c echo.Context) error { id := c.Param("id") doc, err := h.db.GetDocument(id) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "Not found"}) } // Parse update fields var update struct { Category string `json:"category"` Subcategory string `json:"subcategory"` Title string `json:"title"` Date string `json:"date"` Vendor string `json:"vendor"` Amount *float64 `json:"amount"` TaxDeductible bool `json:"tax_deductible"` } if err := c.Bind(&update); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if update.Category != "" { doc.Category = update.Category } doc.Subcategory = update.Subcategory if update.Title != "" { doc.Title = update.Title } if update.Date != "" { if t, err := time.Parse("2006-01-02", update.Date); err == nil { doc.Date = &t } } doc.Vendor = update.Vendor doc.Amount = update.Amount doc.TaxDeductible = update.TaxDeductible if err := h.db.UpdateDocument(doc); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, doc) } func (h *Handlers) APIDeleteDocument(c echo.Context) error { id := c.Param("id") doc, err := h.db.GetDocument(id) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "Not found"}) } // Remove files if doc.StoragePath != "" { os.Remove(doc.StoragePath) } if doc.MarkdownPath != "" { os.Remove(doc.MarkdownPath) } if err := h.db.DeleteDocument(id); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusOK) } func (h *Handlers) APISearch(c echo.Context) error { query := c.QueryParam("q") if query == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Query required"}) } results, err := h.db.SearchFTS(query, 50) if err != nil { // Fallback to markdown search results, _ = processor.SearchMarkdown(h.recordsDir, query, 50) } return c.JSON(http.StatusOK, results) } func (h *Handlers) APIExportExpenses(c echo.Context) error { year, _ := strconv.Atoi(c.QueryParam("year")) if year == 0 { year = time.Now().Year() } docs, _ := h.db.GetExpenses(year, 0) c.Response().Header().Set("Content-Type", "text/csv") c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=expenses-%d.csv", year)) w := c.Response().Writer w.Write([]byte("Date,Vendor,Title,Amount,Currency,Tax Deductible,Category\n")) for _, doc := range docs { date := "" if doc.Date != nil { date = doc.Date.Format("2006-01-02") } amount := "" if doc.Amount != nil { amount = fmt.Sprintf("%.2f", *doc.Amount) } taxDed := "No" if doc.TaxDeductible { taxDed = "Yes" } line := fmt.Sprintf("%s,%s,%s,%s,%s,%s,%s\n", csvEscape(date), csvEscape(doc.Vendor), csvEscape(doc.Title), amount, doc.Currency, taxDed, doc.Category, ) w.Write([]byte(line)) } return nil } func (h *Handlers) APIStats(c echo.Context) error { stats, err := h.db.GetStats() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, stats) } // ServePDF serves PDF files for viewing func (h *Handlers) ServePDF(c echo.Context) error { filename := c.Param("filename") // Sanitize filename to prevent path traversal filename = filepath.Base(filename) path := filepath.Join(h.storeDir, filename) if _, err := os.Stat(path); os.IsNotExist(err) { return c.String(http.StatusNotFound, "File not found") } return c.File(path) } // ServeMarkdown serves markdown record files func (h *Handlers) ServeMarkdown(c echo.Context) error { // Get path from query path := c.QueryParam("path") if path == "" { return c.String(http.StatusBadRequest, "Path required") } // Ensure path is within records directory absPath, _ := filepath.Abs(path) absRecords, _ := filepath.Abs(h.recordsDir) if !strings.HasPrefix(absPath, absRecords) { return c.String(http.StatusForbidden, "Access denied") } data, err := os.ReadFile(path) if err != nil { return c.String(http.StatusNotFound, "File not found") } return c.Blob(http.StatusOK, "text/markdown", data) } func csvEscape(s string) string { if strings.ContainsAny(s, ",\"\n") { return "\"" + strings.ReplaceAll(s, "\"", "\"\"") + "\"" } return s } // Template renderer type TemplateRenderer struct { templates map[string]string } func NewTemplateRenderer() *TemplateRenderer { return &TemplateRenderer{ templates: make(map[string]string), } } func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error { // We'll use html/template inline for now, or integrate with templ later // For simplicity, serve static HTML with htmx return renderTemplate(w, name, data) } func renderTemplate(w io.Writer, name string, data interface{}) error { dataMap, ok := data.(map[string]interface{}) if !ok { dataMap = map[string]interface{}{"Data": data} } html := generateHTML(name, dataMap) _, err := w.Write([]byte(html)) return err } func generateHTML(name string, data map[string]interface{}) string { switch name { case "dashboard": return dashboardHTML(data) case "browse": return browseHTML(data) case "document": return documentHTML(data) case "search": return searchHTML(data) case "expenses": return expensesHTML(data) case "upload": return uploadHTML(data) case "document-card": return documentCardHTML(data) case "error": return errorHTML(data) default: return "Unknown template: " + name + "" } } func baseHTML(title, content string, active string) string { return fmt.Sprintf(` %s - DocMan
%s
`, title, content, activeClass(active, "dashboard"), activeClass(active, "browse"), activeClass(active, "search"), activeClass(active, "expenses"), activeClass(active, "upload"), ) } func activeClass(active, name string) string { if active == name { return "active" } return "" } func dashboardHTML(data map[string]interface{}) string { stats := data["Stats"].(map[string]interface{}) recent := data["Recent"].([]*db.Document) totalSize := int64(0) if v, ok := stats["total_size"].(int64); ok { totalSize = v } content := fmt.Sprintf(`

Dashboard

%d
Total Documents
%d
This Month
%s
Storage Used

Recent Documents

%s
`, stats["total"], stats["this_month"], formatBytes(totalSize), renderDocCards(recent)) return baseHTML("Dashboard", content, data["Active"].(string)) } func browseHTML(data map[string]interface{}) string { docs := data["Documents"].([]*db.Document) categories := data["Categories"].([]string) currentCat := data["Category"].(string) catOptions := `` for _, cat := range categories { selected := "" if cat == currentCat { selected = "selected" } catOptions += fmt.Sprintf(``, cat, selected, strings.Title(cat)) } content := fmt.Sprintf(`

Browse Documents

+ Upload
%s
`, catOptions, renderDocCards(docs)) return baseHTML("Browse", content, data["Active"].(string)) } func documentHTML(data map[string]interface{}) string { doc := data["Document"].(*db.Document) dateStr := "โ€”" if doc.Date != nil { dateStr = doc.Date.Format("January 2, 2006") } amountStr := "โ€”" if doc.Amount != nil { amountStr = fmt.Sprintf("%s %.2f", doc.Currency, *doc.Amount) } taxDed := "No" if doc.TaxDeductible { taxDed = "Yes" } content := fmt.Sprintf(`

%s

โ† Back

Preview

Details

Category%s
Date%s
Vendor%s
Amount%s
Tax Deductible%s
Pages%d
File Size%s
Processed%s

OCR Text

%s
`, doc.Title, doc.ID, doc.Filename, doc.Category, doc.Category, dateStr, doc.Vendor, amountStr, taxDed, doc.PageCount, formatBytes(doc.FileSize), doc.ProcessedAt.Format("Jan 2, 2006 3:04 PM"), escapeHTML(truncateText(doc.OCRText, 5000))) return baseHTML(doc.Title, content, data["Active"].(string)) } func searchHTML(data map[string]interface{}) string { query := data["Query"].(string) results, _ := data["Results"].([]*db.SearchResult) resultsHTML := "" if query == "" { resultsHTML = `

Enter a search query to find documents

` } else if len(results) == 0 { resultsHTML = `

No results found

` } else { for _, r := range results { resultsHTML += fmt.Sprintf(`

%s

%s

%s

`, r.ID, r.Title, r.Category, r.Category, r.Snippet) } } content := fmt.Sprintf(`

Search Documents

%s
`, escapeHTML(query), resultsHTML) return baseHTML("Search", content, data["Active"].(string)) } func expensesHTML(data map[string]interface{}) string { docs := data["Documents"].([]*db.Document) year := data["Year"].(int) month := data["Month"].(int) total := data["Total"].(float64) deductible := data["Deductible"].(float64) years := data["Years"].([]int) yearOptions := "" for _, y := range years { selected := "" if y == year { selected = "selected" } yearOptions += fmt.Sprintf(``, y, selected, y) } monthOptions := `` months := []string{"", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"} for i := 1; i <= 12; i++ { selected := "" if i == month { selected = "selected" } monthOptions += fmt.Sprintf(``, i, selected, months[i]) } rows := "" for _, doc := range docs { dateStr := "โ€”" if doc.Date != nil { dateStr = doc.Date.Format("2006-01-02") } amountStr := "โ€”" if doc.Amount != nil { amountStr = fmt.Sprintf("%.2f", *doc.Amount) } taxDed := "" if doc.TaxDeductible { taxDed = "โœ“" } rows += fmt.Sprintf(` %s %s %s %s %s %s `, dateStr, doc.Vendor, doc.ID, doc.Title, doc.Currency, amountStr, taxDed) } content := fmt.Sprintf(`

Expenses

Export CSV
$%.2f
Total Expenses
$%.2f
Tax Deductible
%s
Date Vendor Description Amount Tax Ded.
`, year, total, deductible, yearOptions, monthOptions, rows) return baseHTML("Expenses", content, data["Active"].(string)) } func uploadHTML(data map[string]interface{}) string { content := `

Upload Documents

` return baseHTML("Upload", content, data["Active"].(string)) } func documentCardHTML(data map[string]interface{}) string { doc := data["Data"].(*db.Document) return renderDocCard(doc) } func renderDocCards(docs []*db.Document) string { html := "" for _, doc := range docs { html += renderDocCard(doc) } return html } func renderDocCard(doc *db.Document) string { dateStr := "" if doc.Date != nil { dateStr = doc.Date.Format("Jan 2, 2006") } icon := "๐Ÿ“„" switch doc.Category { case "taxes": icon = "๐Ÿงพ" case "expenses": icon = "๐Ÿ›’" case "bills": icon = "๐Ÿ“ฌ" case "medical": icon = "๐Ÿฅ" case "legal": icon = "โš–๏ธ" case "insurance": icon = "๐Ÿ›ก๏ธ" case "banking": icon = "๐Ÿฆ" case "receipts": icon = "๐Ÿงพ" } return fmt.Sprintf(`
%s
%s
%s %s
`, doc.ID, icon, escapeHTML(doc.Title), doc.Category, doc.Category, dateStr) } func errorHTML(data map[string]interface{}) string { errMsg := data["Error"].(string) content := fmt.Sprintf(`

Error

%s

Go Home
`, escapeHTML(errMsg)) return baseHTML("Error", content, "") } func formatBytes(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) } func escapeHTML(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, "\"", """) return s } func truncateText(s string, max int) string { if len(s) <= max { return s } return s[:max] + "..." }