summaryrefslogtreecommitdiff
path: root/pim/lib/cal/query.go
blob: 325c9a048e3ce1d80c80ffdec2ddb79f2aaeead9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
package cal

import (
	"fmt"
	"strings"
	"sync"
	"time"

	"github.com/knusbaum/go9p/fs"
	"github.com/knusbaum/go9p/proto"
)

// The query file works like /net/cs: open it, write a query, read the
// answer back on the same fd.
//
//	% echo 'attendee=joe@example.com' >/mnt/pim/query
//	% cat /mnt/pim/query
//
// A query is ndb-style attr=value pairs, all of which must match:
//
//	summary=	substring of the summary, case-insensitive
//	attendee=	substring of any attendee's name or address
//	organizer=	substring of the organizer
//	location=	substring of the location
//	uid=		substring of the uid
//	from=		YYYY-MM-DD, occurrences on or after this day
//	to=		YYYY-MM-DD, occurrences before this day
//
// It answers with one path per line, which is what pim/show takes.
// Holding the fd across the write and the read is the correct way to
// use it, as with cs. But "echo ... >query; cat query" opens twice, and
// that is how people will actually use it from rc, so the last answer is
// also kept and served to a fid that has none of its own.
type queryFile struct {
	mu   sync.Mutex
	res  map[uint64][]byte
	last []byte
}

// index is one occurrence and the path it was published at.
type index struct {
	path string
	in   Instance
}

func (s *Server) addQuery() {
	q := &queryFile{res: make(map[uint64][]byte)}
	st := s.fsys.NewStat("query", s.user, s.user, 0666)
	base := fs.NewStaticFile(st, []byte(""))
	s.root.AddChild(&fs.WrappedFile{
		File: base,
		WriteF: func(fid uint64, off uint64, data []byte) (uint32, error) {
			out, err := s.query(string(data))
			if err != nil {
				return 0, err
			}
			q.mu.Lock()
			q.res[fid] = []byte(out)
			q.last = []byte(out)
			q.mu.Unlock()
			return uint32(len(data)), nil
		},
		ReadF: func(fid uint64, off uint64, count uint64) ([]byte, error) {
			q.mu.Lock()
			b, ok := q.res[fid]
			if !ok {
				b = q.last
			}
			q.mu.Unlock()
			if off >= uint64(len(b)) {
				return []byte{}, nil
			}
			end := off + count
			if end > uint64(len(b)) {
				end = uint64(len(b))
			}
			return b[off:end], nil
		},
		CloseF: func(fid uint64) error {
			q.mu.Lock()
			delete(q.res, fid)
			q.mu.Unlock()
			return nil
		},
	})
}

func (s *Server) query(q string) (string, error) {
	var from, to time.Time
	terms := map[string]string{}

	for _, f := range strings.Fields(strings.TrimSpace(q)) {
		k, v, ok := strings.Cut(f, "=")
		if !ok {
			return "", fmt.Errorf("query: %q is not attr=value", f)
		}
		switch k {
		case "summary", "attendee", "organizer", "location", "uid":
			terms[k] = strings.ToLower(v)
		case "from", "to":
			t, err := time.ParseInLocation("2006-01-02", v, time.Local)
			if err != nil {
				return "", fmt.Errorf("query: bad date %q", v)
			}
			if k == "from" {
				from = t
			} else {
				to = t
			}
		default:
			return "", fmt.Errorf("query: unknown attribute %q", k)
		}
	}
	if len(terms) == 0 && from.IsZero() && to.IsZero() {
		return "", fmt.Errorf("query: nothing to match")
	}

	s.mu.Lock()
	idx := s.index
	s.mu.Unlock()

	var b strings.Builder
	for _, e := range idx {
		if !from.IsZero() && e.in.Start.Before(from) {
			continue
		}
		if !to.IsZero() && !e.in.Start.Before(to) {
			continue
		}
		if match(e.in.Ev, terms) {
			fmt.Fprintf(&b, "%s\n", e.path)
		}
	}
	return b.String(), nil
}

func match(ev *Event, terms map[string]string) bool {
	has := func(hay, needle string) bool {
		return strings.Contains(strings.ToLower(hay), needle)
	}
	for k, v := range terms {
		switch k {
		case "summary":
			if !has(ev.Summary, v) {
				return false
			}
		case "location":
			if !has(ev.Location, v) {
				return false
			}
		case "organizer":
			if !has(ev.Organizer, v) {
				return false
			}
		case "uid":
			if !has(ev.UID, v) {
				return false
			}
		case "attendee":
			found := false
			for _, a := range ev.Attendees {
				if has(a.Name, v) || has(a.Email, v) {
					found = true
					break
				}
			}
			if !found {
				return false
			}
		}
	}
	return true
}

var _ = proto.DMDIR