Skip to content

Commit c618e1c

Browse files
cpcloudclaude
andcommitted
feat(data): add Document model with BLOB storage and top-level tab
Cherry-pick of 1850fd0 from PR #236, resolved against this branch. Takes 236's simpler store API (CreateDocument/UpdateDocument take a single Document arg; GetDocument loads all fields including Data), its inline editing support, and its tab labeling ("Docs"). Keeps this branch's additive features: FileName and ChecksumSHA256 tracking, extract-to-cache system (doccache.go), MaxDocumentSize limit, and the full set of entity kinds. Ports the BLOB preservation fix from the previous commit to 236's API: UpdateDocument omits file columns when Data is empty, preventing metadata-only edits from erasing the file. Part of #230 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 467f6a8 commit c618e1c

File tree

10 files changed

+625
-551
lines changed

10 files changed

+625
-551
lines changed

internal/app/detail_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,3 +652,32 @@ func TestNavigateToLinkClosesDetailStack(t *testing.T) {
652652
assert.False(t, m.inDetail(), "detail stack should be closed after navigateToLink")
653653
assert.Equal(t, tabIndex(tabProjects), m.active)
654654
}
655+
656+
// ---------------------------------------------------------------------------
657+
// Document handler tests
658+
// ---------------------------------------------------------------------------
659+
660+
func TestDocumentHandlerFormKind(t *testing.T) {
661+
h := documentHandler{}
662+
assert.Equal(t, formDocument, h.FormKind())
663+
}
664+
665+
func TestEntityDocumentColumnSpecsNoEntity(t *testing.T) {
666+
specs := entityDocumentColumnSpecs()
667+
for _, s := range specs {
668+
assert.NotEqual(t, "Entity", s.Title,
669+
"entity document specs should not include Entity column")
670+
}
671+
}
672+
673+
func TestDocumentColumnSpecsIncludeEntity(t *testing.T) {
674+
specs := documentColumnSpecs()
675+
var found bool
676+
for _, s := range specs {
677+
if s.Title == "Entity" {
678+
found = true
679+
break
680+
}
681+
}
682+
assert.True(t, found, "top-level document specs should include Entity column")
683+
}

internal/app/forms.go

Lines changed: 169 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ package app
66
import (
77
"errors"
88
"fmt"
9+
"net/http"
10+
"os"
11+
"path/filepath"
912
"strconv"
1013
"strings"
1114
"time"
@@ -102,6 +105,13 @@ type vendorFormData struct {
102105
Notes string
103106
}
104107

108+
type documentFormData struct {
109+
Title string
110+
FilePath string // local file path; read on submit for new documents
111+
EntityKind string // set by scoped handlers
112+
Notes string
113+
}
114+
105115
type applianceFormData struct {
106116
Name string
107117
Brand string
@@ -114,12 +124,6 @@ type applianceFormData struct {
114124
Notes string
115125
}
116126

117-
type documentFormData struct {
118-
Title string
119-
SourcePath string // filesystem path to import from (empty on metadata-only edits)
120-
Notes string
121-
}
122-
123127
func (m *Model) startHouseForm() {
124128
values := &houseFormData{}
125129
if m.hasHouse {
@@ -644,78 +648,6 @@ func (m *Model) parseVendorFormData() (data.Vendor, error) {
644648
}, nil
645649
}
646650

647-
func (m *Model) startDocumentForm() {
648-
values := &documentFormData{}
649-
m.openDocumentForm(values)
650-
}
651-
652-
func (m *Model) startEditDocumentForm(id uint) error {
653-
doc, err := m.store.GetDocument(id)
654-
if err != nil {
655-
return fmt.Errorf("load document: %w", err)
656-
}
657-
values := documentFormValues(doc)
658-
m.editID = &id
659-
m.openDocumentForm(values)
660-
return nil
661-
}
662-
663-
func (m *Model) openDocumentForm(values *documentFormData) {
664-
filePickerValidation := requiredText("document file")
665-
if m.editID != nil {
666-
// Editing: file picker is optional (keep existing content if blank).
667-
filePickerValidation = nil
668-
}
669-
form := huh.NewForm(
670-
huh.NewGroup(
671-
huh.NewInput().
672-
Title("Title").
673-
Placeholder("auto-filled from filename if blank").
674-
Value(&values.Title),
675-
huh.NewFilePicker().
676-
Title("Document file").
677-
Description("Pick a file to import (leave blank to keep current)").
678-
FileAllowed(true).
679-
DirAllowed(false).
680-
ShowHidden(false).
681-
Value(&values.SourcePath).
682-
Validate(filePickerValidation),
683-
huh.NewText().Title("Notes").Value(&values.Notes),
684-
).Title("File"),
685-
)
686-
m.activateForm(formDocument, form, values)
687-
}
688-
689-
func (m *Model) submitDocumentForm() error {
690-
doc, sourcePath, err := m.parseDocumentFormData()
691-
if err != nil {
692-
return err
693-
}
694-
if m.editID != nil {
695-
doc.ID = *m.editID
696-
return m.store.UpdateDocument(doc, sourcePath)
697-
}
698-
return m.store.CreateDocument(doc, sourcePath)
699-
}
700-
701-
func (m *Model) parseDocumentFormData() (data.Document, string, error) {
702-
values, ok := m.formData.(*documentFormData)
703-
if !ok {
704-
return data.Document{}, "", fmt.Errorf("unexpected document form data")
705-
}
706-
return data.Document{
707-
Title: strings.TrimSpace(values.Title),
708-
Notes: strings.TrimSpace(values.Notes),
709-
}, strings.TrimSpace(values.SourcePath), nil
710-
}
711-
712-
func documentFormValues(doc data.Document) *documentFormData {
713-
return &documentFormData{
714-
Title: doc.Title,
715-
Notes: doc.Notes,
716-
}
717-
}
718-
719651
func (m *Model) inlineEditVendor(id uint, col int) error {
720652
vendor, err := m.store.GetVendor(id)
721653
if err != nil {
@@ -1732,3 +1664,162 @@ func intToString(value int) string {
17321664
}
17331665
return strconv.Itoa(value)
17341666
}
1667+
1668+
// ---------------------------------------------------------------------------
1669+
// Document forms
1670+
// ---------------------------------------------------------------------------
1671+
1672+
// startDocumentForm opens a new-document form. entityKind is set by scoped
1673+
// handlers (e.g. "project") or empty for the top-level Documents tab.
1674+
func (m *Model) startDocumentForm(entityKind string) error {
1675+
values := &documentFormData{EntityKind: entityKind}
1676+
form := huh.NewForm(
1677+
huh.NewGroup(
1678+
huh.NewInput().
1679+
Title("Title").
1680+
Value(&values.Title).
1681+
Validate(requiredText("title")),
1682+
huh.NewInput().
1683+
Title("File path").
1684+
Description("Local path to the file to attach").
1685+
Value(&values.FilePath).
1686+
Validate(optionalFilePath()),
1687+
huh.NewText().Title("Notes").Value(&values.Notes),
1688+
),
1689+
)
1690+
m.activateForm(formDocument, form, values)
1691+
return nil
1692+
}
1693+
1694+
func (m *Model) startEditDocumentForm(id uint) error {
1695+
doc, err := m.store.GetDocument(id)
1696+
if err != nil {
1697+
return fmt.Errorf("load document: %w", err)
1698+
}
1699+
values := documentFormValues(doc)
1700+
m.editID = &id
1701+
m.openEditDocumentForm(values)
1702+
return nil
1703+
}
1704+
1705+
func (m *Model) openEditDocumentForm(values *documentFormData) {
1706+
form := huh.NewForm(
1707+
huh.NewGroup(
1708+
huh.NewInput().
1709+
Title("Title").
1710+
Value(&values.Title).
1711+
Validate(requiredText("title")),
1712+
huh.NewText().Title("Notes").Value(&values.Notes),
1713+
),
1714+
)
1715+
m.activateForm(formDocument, form, values)
1716+
}
1717+
1718+
func (m *Model) submitDocumentForm() error {
1719+
doc, err := m.parseDocumentFormData()
1720+
if err != nil {
1721+
return err
1722+
}
1723+
if m.editID != nil {
1724+
doc.ID = *m.editID
1725+
return m.store.UpdateDocument(doc)
1726+
}
1727+
return m.store.CreateDocument(doc)
1728+
}
1729+
1730+
func (m *Model) parseDocumentFormData() (data.Document, error) {
1731+
values, ok := m.formData.(*documentFormData)
1732+
if !ok {
1733+
return data.Document{}, fmt.Errorf("unexpected document form data")
1734+
}
1735+
doc := data.Document{
1736+
Title: strings.TrimSpace(values.Title),
1737+
EntityKind: values.EntityKind,
1738+
Notes: strings.TrimSpace(values.Notes),
1739+
}
1740+
// Read file from path if provided (new document creation).
1741+
path := filepath.Clean(strings.TrimSpace(values.FilePath))
1742+
if path != "" && path != "." {
1743+
fileData, err := os.ReadFile(path)
1744+
if err != nil {
1745+
return data.Document{}, fmt.Errorf("read file: %w", err)
1746+
}
1747+
doc.Data = fileData
1748+
doc.SizeBytes = int64(len(fileData))
1749+
doc.MIMEType = detectMIMEType(path, fileData)
1750+
}
1751+
return doc, nil
1752+
}
1753+
1754+
func (m *Model) inlineEditDocument(id uint, col int) error {
1755+
doc, err := m.store.GetDocument(id)
1756+
if err != nil {
1757+
return fmt.Errorf("load document: %w", err)
1758+
}
1759+
values := documentFormValues(doc)
1760+
// Column mapping: 0=ID, 1=Title, 2=Entity(ro), 3=Type(ro), 4=Size(ro), 5=Notes, 6=Updated(ro)
1761+
switch col {
1762+
case 1:
1763+
m.openInlineInput(
1764+
id,
1765+
formDocument,
1766+
"Title",
1767+
"",
1768+
&values.Title,
1769+
requiredText("title"),
1770+
values,
1771+
)
1772+
case 5:
1773+
m.openInlineInput(id, formDocument, "Notes", "", &values.Notes, nil, values)
1774+
default:
1775+
return m.startEditDocumentForm(id)
1776+
}
1777+
return nil
1778+
}
1779+
1780+
func documentFormValues(doc data.Document) *documentFormData {
1781+
return &documentFormData{
1782+
Title: doc.Title,
1783+
EntityKind: doc.EntityKind,
1784+
Notes: doc.Notes,
1785+
}
1786+
}
1787+
1788+
// detectMIMEType uses http.DetectContentType with a file extension fallback.
1789+
func detectMIMEType(path string, fileData []byte) string {
1790+
mime := http.DetectContentType(fileData)
1791+
// DetectContentType returns application/octet-stream for unknown types;
1792+
// try extension-based detection as a fallback.
1793+
if mime == "application/octet-stream" {
1794+
switch strings.ToLower(filepath.Ext(path)) {
1795+
case ".pdf":
1796+
return "application/pdf"
1797+
case ".txt":
1798+
return "text/plain"
1799+
case ".csv":
1800+
return "text/csv"
1801+
case ".json":
1802+
return "application/json"
1803+
case ".md":
1804+
return "text/markdown"
1805+
}
1806+
}
1807+
return mime
1808+
}
1809+
1810+
func optionalFilePath() func(string) error {
1811+
return func(input string) error {
1812+
path := strings.TrimSpace(input)
1813+
if path == "" {
1814+
return nil
1815+
}
1816+
info, err := os.Stat(path)
1817+
if err != nil {
1818+
return fmt.Errorf("file not found: %s", path)
1819+
}
1820+
if info.IsDir() {
1821+
return fmt.Errorf("path is a directory, not a file")
1822+
}
1823+
return nil
1824+
}
1825+
}

internal/app/handlers.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ func (projectQuoteHandler) Snapshot(store *data.Store, id uint) (undoEntry, bool
744744
func (projectQuoteHandler) SyncFixedValues(_ *Model, _ []columnSpec) {}
745745

746746
// ---------------------------------------------------------------------------
747-
// documentHandler
747+
// documentHandler -- top-level handler for the Documents tab.
748748
// ---------------------------------------------------------------------------
749749

750750
type documentHandler struct{}
@@ -772,16 +772,15 @@ func (documentHandler) Restore(store *data.Store, id uint) error {
772772
}
773773

774774
func (documentHandler) StartAddForm(m *Model) error {
775-
m.startDocumentForm()
776-
return nil
775+
return m.startDocumentForm("")
777776
}
778777

779778
func (documentHandler) StartEditForm(m *Model, id uint) error {
780779
return m.startEditDocumentForm(id)
781780
}
782781

783-
func (documentHandler) InlineEdit(m *Model, id uint, _ int) error {
784-
return m.startEditDocumentForm(id)
782+
func (documentHandler) InlineEdit(m *Model, id uint, col int) error {
783+
return m.inlineEditDocument(id, col)
785784
}
786785

787786
func (documentHandler) SubmitForm(m *Model) error {
@@ -798,8 +797,7 @@ func (documentHandler) Snapshot(store *data.Store, id uint) (undoEntry, bool) {
798797
FormKind: formDocument,
799798
EntityID: id,
800799
Restore: func() error {
801-
// Metadata-only restore: no new file to import.
802-
return store.UpdateDocument(doc, "")
800+
return store.UpdateDocument(doc)
803801
},
804802
}, true
805803
}

0 commit comments

Comments
 (0)