Skip to content

Commit e4c474c

Browse files
cpcloudcursoragent
andcommitted
feat(documents): add attachment records and file picker workflow
Add document persistence with typed links, metadata hashing, and a new Documents tab/form that uses a built-in file picker for interactive file selection in the TUI. closes #94 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 811e9ff commit e4c474c

File tree

8 files changed

+599
-0
lines changed

8 files changed

+599
-0
lines changed

docs/content/docs/reference/data-storage.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ run migrations manually.
4242
| `maintenance_categories` | Pre-seeded maintenance categories |
4343
| `appliances` | Physical equipment |
4444
| `service_log_entries` | Service history per maintenance item |
45+
| `documents` | File metadata + attachments linked to records |
4546
| `deletion_records` | Audit trail for soft deletes/restores |
4647

4748
### Pre-seeded data

internal/app/forms.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ type applianceFormData struct {
114114
Notes string
115115
}
116116

117+
type documentFormData struct {
118+
Title string
119+
FilePath string
120+
EntityKind string
121+
EntityID string
122+
Notes string
123+
}
124+
117125
func (m *Model) startHouseForm() {
118126
values := &houseFormData{}
119127
if m.hasHouse {
@@ -638,6 +646,114 @@ func (m *Model) parseVendorFormData() (data.Vendor, error) {
638646
}, nil
639647
}
640648

649+
func (m *Model) startDocumentForm() {
650+
values := &documentFormData{}
651+
m.openDocumentForm(values)
652+
}
653+
654+
func (m *Model) startEditDocumentForm(id uint) error {
655+
doc, err := m.store.GetDocument(id)
656+
if err != nil {
657+
return fmt.Errorf("load document: %w", err)
658+
}
659+
values := documentFormValues(doc)
660+
m.editID = &id
661+
m.openDocumentForm(values)
662+
return nil
663+
}
664+
665+
func (m *Model) openDocumentForm(values *documentFormData) {
666+
form := huh.NewForm(
667+
huh.NewGroup(
668+
huh.NewInput().
669+
Title("Title").
670+
Placeholder("Final quote PDF").
671+
Value(&values.Title).
672+
Validate(requiredText("title")),
673+
huh.NewFilePicker().
674+
Title("Document file").
675+
Description("Use picker search to quickly find the file").
676+
FileAllowed(true).
677+
DirAllowed(false).
678+
ShowHidden(false).
679+
Value(&values.FilePath).
680+
Validate(requiredText("document file")),
681+
).Title("File"),
682+
huh.NewGroup(
683+
huh.NewSelect[string]().
684+
Title("Linked record type").
685+
Options(documentEntityOptions()...).
686+
Value(&values.EntityKind),
687+
huh.NewInput().
688+
Title("Linked record ID").
689+
Placeholder("e.g. 42 (optional for none)").
690+
Value(&values.EntityID).
691+
Validate(optionalEntityIDForKind(&values.EntityKind)),
692+
huh.NewText().Title("Notes").Value(&values.Notes),
693+
).Title("Link"),
694+
)
695+
m.activateForm(formDocument, form, values)
696+
}
697+
698+
func (m *Model) submitDocumentForm() error {
699+
doc, err := m.parseDocumentFormData()
700+
if err != nil {
701+
return err
702+
}
703+
if m.editID != nil {
704+
doc.ID = *m.editID
705+
return m.store.UpdateDocument(doc)
706+
}
707+
return m.store.CreateDocument(doc)
708+
}
709+
710+
func (m *Model) parseDocumentFormData() (data.Document, error) {
711+
values, ok := m.formData.(*documentFormData)
712+
if !ok {
713+
return data.Document{}, fmt.Errorf("unexpected document form data")
714+
}
715+
entityKind := strings.TrimSpace(values.EntityKind)
716+
if entityKind == "none" {
717+
entityKind = ""
718+
}
719+
720+
var entityID *uint
721+
entityIDText := strings.TrimSpace(values.EntityID)
722+
if entityKind != "" {
723+
if entityIDText == "" {
724+
return data.Document{}, fmt.Errorf("linked record id is required")
725+
}
726+
parsed, err := data.ParseRequiredInt(entityIDText)
727+
if err != nil {
728+
return data.Document{}, fmt.Errorf("linked record id should be a positive whole number")
729+
}
730+
id := uint(parsed)
731+
entityID = &id
732+
}
733+
734+
return data.Document{
735+
Title: strings.TrimSpace(values.Title),
736+
FilePath: strings.TrimSpace(values.FilePath),
737+
EntityKind: entityKind,
738+
EntityID: entityID,
739+
Notes: strings.TrimSpace(values.Notes),
740+
}, nil
741+
}
742+
743+
func documentFormValues(doc data.Document) *documentFormData {
744+
entityID := ""
745+
if doc.EntityID != nil {
746+
entityID = strconv.FormatUint(uint64(*doc.EntityID), 10)
747+
}
748+
return &documentFormData{
749+
Title: doc.Title,
750+
FilePath: doc.FilePath,
751+
EntityKind: displayDocumentKind(doc.EntityKind),
752+
EntityID: entityID,
753+
Notes: doc.Notes,
754+
}
755+
}
756+
641757
func (m *Model) inlineEditVendor(id uint, col int) error {
642758
vendor, err := m.store.GetVendor(id)
643759
if err != nil {
@@ -1064,6 +1180,37 @@ func vendorOptions(vendors []data.Vendor) []huh.Option[uint] {
10641180
return withOrdinals(options)
10651181
}
10661182

1183+
func documentEntityOptions() []huh.Option[string] {
1184+
kinds := data.DocumentEntityKinds()
1185+
options := make([]huh.Option[string], 0, len(kinds))
1186+
for _, kind := range kinds {
1187+
label := kind
1188+
if kind == "" {
1189+
label = "none"
1190+
}
1191+
options = append(options, huh.NewOption(label, label))
1192+
}
1193+
return withOrdinals(options)
1194+
}
1195+
1196+
func optionalEntityIDForKind(kind *string) func(string) error {
1197+
return func(input string) error {
1198+
value := strings.TrimSpace(input)
1199+
selectedKind := strings.TrimSpace(*kind)
1200+
if selectedKind == "none" || selectedKind == "" {
1201+
if value != "" {
1202+
return fmt.Errorf("clear linked record id when linked record type is none")
1203+
}
1204+
return nil
1205+
}
1206+
parsed, err := data.ParseRequiredInt(value)
1207+
if err != nil || parsed <= 0 {
1208+
return fmt.Errorf("linked record id should be a positive whole number")
1209+
}
1210+
return nil
1211+
}
1212+
}
1213+
10671214
func requiredDate(label string) func(string) error {
10681215
return func(input string) error {
10691216
if strings.TrimSpace(input) == "" {

internal/app/handlers.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,3 +742,75 @@ func (projectQuoteHandler) Snapshot(store *data.Store, id uint) (undoEntry, bool
742742
}
743743

744744
func (projectQuoteHandler) SyncFixedValues(_ *Model, _ []columnSpec) {}
745+
746+
// ---------------------------------------------------------------------------
747+
// documentHandler
748+
// ---------------------------------------------------------------------------
749+
750+
type documentHandler struct{}
751+
752+
func (documentHandler) FormKind() FormKind { return formDocument }
753+
754+
func (documentHandler) Load(
755+
store *data.Store,
756+
showDeleted bool,
757+
) ([]table.Row, []rowMeta, [][]cell, error) {
758+
docs, err := store.ListDocuments(showDeleted)
759+
if err != nil {
760+
return nil, nil, nil, err
761+
}
762+
rows, meta, cellRows := documentRows(docs)
763+
return rows, meta, cellRows, nil
764+
}
765+
766+
func (documentHandler) Delete(store *data.Store, id uint) error {
767+
return store.DeleteDocument(id)
768+
}
769+
770+
func (documentHandler) Restore(store *data.Store, id uint) error {
771+
return store.RestoreDocument(id)
772+
}
773+
774+
func (documentHandler) StartAddForm(m *Model) error {
775+
m.startDocumentForm()
776+
return nil
777+
}
778+
779+
func (documentHandler) StartEditForm(m *Model, id uint) error {
780+
return m.startEditDocumentForm(id)
781+
}
782+
783+
func (documentHandler) InlineEdit(m *Model, id uint, _ int) error {
784+
return m.startEditDocumentForm(id)
785+
}
786+
787+
func (documentHandler) SubmitForm(m *Model) error {
788+
return m.submitDocumentForm()
789+
}
790+
791+
func (documentHandler) Snapshot(store *data.Store, id uint) (undoEntry, bool) {
792+
doc, err := store.GetDocument(id)
793+
if err != nil {
794+
return undoEntry{}, false
795+
}
796+
return undoEntry{
797+
Description: fmt.Sprintf("document %q", doc.Title),
798+
FormKind: formDocument,
799+
EntityID: id,
800+
Restore: func() error {
801+
return store.UpdateDocument(doc)
802+
},
803+
}, true
804+
}
805+
806+
func (documentHandler) SyncFixedValues(_ *Model, specs []columnSpec) {
807+
kinds := make([]string, 0, len(data.DocumentEntityKinds()))
808+
for _, kind := range data.DocumentEntityKinds() {
809+
if kind == "" {
810+
kinds = append(kinds, "none")
811+
continue
812+
}
813+
kinds = append(kinds, kind)
814+
}
815+
setFixedValues(specs, "Type", kinds)
816+
}

internal/app/tables.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ func NewTabs(styles Styles) []Tab {
5454
maintenanceSpecs := maintenanceColumnSpecs()
5555
applianceSpecs := applianceColumnSpecs()
5656
vendorSpecs := vendorColumnSpecs()
57+
documentSpecs := documentColumnSpecs()
5758
return []Tab{
5859
{
5960
Kind: tabProjects,
@@ -90,6 +91,13 @@ func NewTabs(styles Styles) []Tab {
9091
Specs: vendorSpecs,
9192
Table: newTable(specsToColumns(vendorSpecs), styles),
9293
},
94+
{
95+
Kind: tabDocuments,
96+
Name: "Documents",
97+
Handler: documentHandler{},
98+
Specs: documentSpecs,
99+
Table: newTable(specsToColumns(documentSpecs), styles),
100+
},
93101
}
94102
}
95103

@@ -337,6 +345,17 @@ func vendorColumnSpecs() []columnSpec {
337345
}
338346
}
339347

348+
func documentColumnSpecs() []columnSpec {
349+
return []columnSpec{
350+
{Title: "ID", Min: 4, Max: 6, Align: alignRight, Kind: cellReadonly},
351+
{Title: "Title", Min: 14, Max: 24, Flex: true},
352+
{Title: "File", Min: 12, Max: 24, Flex: true},
353+
{Title: "Type", Min: 10, Max: 14},
354+
{Title: "Ref", Min: 8, Max: 16},
355+
{Title: "Size", Min: 8, Max: 12, Align: alignRight, Kind: cellReadonly},
356+
}
357+
}
358+
340359
func vendorRows(
341360
vendors []data.Vendor,
342361
quoteCounts map[uint]int,
@@ -368,6 +387,46 @@ func vendorRows(
368387
})
369388
}
370389

390+
func documentRows(items []data.Document) ([]table.Row, []rowMeta, [][]cell) {
391+
return buildRows(items, func(d data.Document) rowSpec {
392+
ref := ""
393+
if d.EntityID != nil {
394+
ref = fmt.Sprintf("%s:%d", d.EntityKind, *d.EntityID)
395+
}
396+
return rowSpec{
397+
ID: d.ID,
398+
Deleted: d.DeletedAt.Valid,
399+
Cells: []cell{
400+
{Value: fmt.Sprintf("%d", d.ID), Kind: cellReadonly},
401+
{Value: d.Title, Kind: cellText},
402+
{Value: d.FileName, Kind: cellText},
403+
{Value: displayDocumentKind(d.EntityKind), Kind: cellText},
404+
{Value: ref, Kind: cellText},
405+
{Value: formatSize(d.SizeBytes), Kind: cellReadonly},
406+
},
407+
}
408+
})
409+
}
410+
411+
func displayDocumentKind(kind string) string {
412+
if kind == "" {
413+
return "none"
414+
}
415+
return kind
416+
}
417+
418+
func formatSize(size int64) string {
419+
if size < 1024 {
420+
return fmt.Sprintf("%d B", size)
421+
}
422+
kb := float64(size) / 1024.0
423+
if kb < 1024 {
424+
return fmt.Sprintf("%.1f KB", kb)
425+
}
426+
mb := kb / 1024.0
427+
return fmt.Sprintf("%.1f MB", mb)
428+
}
429+
371430
func specsToColumns(specs []columnSpec) []table.Column {
372431
cols := make([]table.Column, 0, len(specs))
373432
for _, spec := range specs {

internal/app/types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const (
2727
formAppliance
2828
formServiceLog
2929
formVendor
30+
formDocument
3031
)
3132

3233
type TabKind int
@@ -37,6 +38,7 @@ const (
3738
tabMaintenance
3839
tabAppliances
3940
tabVendors
41+
tabDocuments
4042
)
4143

4244
func (k TabKind) String() string {
@@ -51,6 +53,8 @@ func (k TabKind) String() string {
5153
return "Appliances"
5254
case tabVendors:
5355
return "Vendors"
56+
case tabDocuments:
57+
return "Documents"
5458
default:
5559
return "Unknown"
5660
}

0 commit comments

Comments
 (0)