feat(oo): catalog scan/match/apply for CRM clients and contacts

Add filesystem inventory (VCF, contact folders, project trees) into a
reviewable YAML catalog, match against live OnlyOffice, and upsert only
rows with approve:true. Includes FindPersonByEmail for email-keyed persons.
This commit is contained in:
2026-07-26 11:10:02 +01:00
parent 36800e319c
commit c32e4dd611
16 changed files with 1379 additions and 3 deletions
+218
View File
@@ -0,0 +1,218 @@
package catalog
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/emersion/go-vcard"
)
// ParseVCFFile extracts person candidates from a vCard file (may contain many cards).
func ParseVCFFile(path string) ([]Entry, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
dec := vcard.NewDecoder(f)
var out []Entry
for {
card, err := dec.Decode()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return out, fmt.Errorf("%s: %w", path, err)
}
e := entryFromCard(card, path)
if e.Name == "" && e.First == "" && len(e.Emails) == 0 {
continue
}
out = append(out, e)
}
return out, nil
}
func entryFromCard(card vcard.Card, source string) Entry {
fn := strings.TrimSpace(card.PreferredValue(vcard.FieldFormattedName))
n := card.Name()
first, last := "", ""
if n != nil {
first = strings.TrimSpace(n.GivenName)
last = strings.TrimSpace(n.FamilyName)
}
if fn == "" {
fn = strings.TrimSpace(first + " " + last)
}
if first == "" && last == "" && fn != "" {
first, last = SplitDisplayName(fn)
}
var emails, phones []string
for _, v := range card.Values(vcard.FieldEmail) {
if e := NormalizeEmail(v); e != "" {
emails = append(emails, e)
}
}
for _, v := range card.Values(vcard.FieldTelephone) {
p := strings.TrimSpace(v)
if p != "" {
phones = append(phones, p)
}
}
org := strings.TrimSpace(card.PreferredValue(vcard.FieldOrganization))
primary := ""
if len(emails) > 0 {
primary = emails[0]
}
return Entry{
ID: EntryID("person", primary, fn),
Kind: "person",
Name: fn,
First: first,
Last: last,
Emails: uniqueEmails(emails),
Phones: uniqueStrings(phones),
Org: org,
Sources: []string{source},
Zone: "private",
Role: "unknown",
Approve: false,
Status: "new",
}
}
// ScanContactsRoot walks a contacts directory: *.vcf anywhere, person-named
// subdirs, and *@*.txt email hint files.
func ScanContactsRoot(root string) (*Document, error) {
root = filepath.Clean(root)
st, err := os.Stat(root)
if err != nil {
return nil, err
}
if !st.IsDir() {
return nil, fmt.Errorf("not a directory: %s", root)
}
var entries []Entry
seenVCF := map[string]struct{}{}
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable
}
if d.IsDir() {
base := d.Name()
if base == ".git" || base == "node_modules" {
return filepath.SkipDir
}
return nil
}
name := d.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".vcf") {
if _, ok := seenVCF[path]; ok {
return nil
}
seenVCF[path] = struct{}{}
parsed, perr := ParseVCFFile(path)
if perr != nil {
entries = append(entries, Entry{
ID: EntryID("person", "", filepath.Base(path)),
Kind: "person",
Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)),
Sources: []string{path},
Zone: "private",
Role: "unknown",
Notes: "vcf_parse_error: " + perr.Error(),
Status: "new",
})
return nil
}
entries = append(entries, parsed...)
return nil
}
// email hint files: name contains @ and ends with .txt
if strings.Contains(name, "@") && strings.HasSuffix(lower, ".txt") {
email := NormalizeEmail(strings.TrimSuffix(name, filepath.Ext(name)))
if !strings.Contains(email, "@") {
return nil
}
parent := filepath.Base(filepath.Dir(path))
first, last := SplitDisplayName(parent)
entries = append(entries, Entry{
ID: EntryID("person", email, parent),
Kind: "person",
Name: parent,
First: first,
Last: last,
Emails: []string{email},
Sources: []string{path},
Zone: "private",
Role: "unknown",
Status: "new",
})
}
return nil
})
if err != nil {
return nil, err
}
// Top-level person folders without VCF still become stub persons.
dents, _ := os.ReadDir(root)
for _, d := range dents {
if !d.IsDir() {
continue
}
name := d.Name()
if name == "Contacts VCF's" || strings.HasPrefix(name, ".") {
continue
}
// skip obvious non-person dumps
lower := strings.ToLower(name)
if strings.Contains(lower, "vcf") {
continue
}
first, last := SplitDisplayName(name)
id := EntryID("person", "", name)
entries = append(entries, Entry{
ID: id,
Kind: "person",
Name: name,
First: first,
Last: last,
Sources: []string{filepath.Join(root, name)},
Zone: "private",
Role: "unknown",
Status: "new",
Notes: "folder_stub",
})
}
doc := MergeDocs(&Document{Entries: entries})
return doc, nil
}
// ReadEmailsFromTxt reads loose emails from a text file (one per line or free text).
func ReadEmailsFromTxt(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if strings.Contains(line, "@") && !strings.Contains(line, " ") {
out = append(out, NormalizeEmail(line))
}
}
return uniqueEmails(out), sc.Err()
}