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(`| Category | %s |
| Date | %s |
| Vendor | %s |
| Amount | %s |
| Tax Deductible | %s |
| Pages | %d |
| File Size | %s |
| Processed | %s |
Enter a search query to find documents
` } else if len(results) == 0 { resultsHTML = `No results found
` } else { for _, r := range results { resultsHTML += fmt.Sprintf(` `, r.ID, r.Title, r.Category, r.Category, r.Snippet) } } content := fmt.Sprintf(`| Date | Vendor | Description | Amount | Tax Ded. |
|---|