]> git.giacomolli.uk - onechat/blob - log.go
Use both logs for WhatsApp import cutoffs
[onechat] / log.go
1 package main
2
3 import (
4         "context"
5         "encoding/json"
6         "errors"
7         "fmt"
8         "os"
9         "path/filepath"
10         "time"
11         "unicode/utf8"
12 )
13
14 // logKinds holds the logKinds that a message can have.
15 var logKinds = map[string]bool{
16         "user":  true,
17         "reply": true,
18         "tool":  true,
19         "echo":  true,
20         "work":  true,
21         "note":  true,
22 }
23
24 // openLog locks the chat in root and indexes its new lines. The clock gives the
25 // date of each new message.
26 func openLog(ctx context.Context, root string, clock func() time.Time) (*Log, error) {
27         for _, dir := range []string{"main", "index"} {
28                 err := os.MkdirAll(filepath.Join(root, dir), 0o700)
29                 if err != nil {
30                         return nil, fmt.Errorf("create %s directory: %w", dir, err)
31                 }
32         }
33
34         lock, err := lockFile(filepath.Join(root, "lock"))
35         if err != nil {
36                 return nil, err
37         }
38
39         l := &Log{
40                 clock: clock,
41                 lock:  lock,
42                 journal: Journal{
43                         dir: filepath.Join(root, "main"),
44                 },
45         }
46
47         l.index, err = openRecords(filepath.Join(root, "index", "main.idx"), positionBytes)
48         if err != nil {
49                 return nil, errors.Join(err, l.Close())
50         }
51
52         err = l.catchUp(ctx)
53         if err != nil {
54                 return nil, errors.Join(fmt.Errorf("index message log: %w", err), l.Close())
55         }
56
57         return l, nil
58 }
59
60 // Log is the append-only message log of one chat. It writes one JSON line for
61 // each message to main/YYYY-MM-DD.jsonl, named for the local day of the
62 // message. It never edits or deletes a line. Each line goes to the operating
63 // system before Append returns, so a crash of the process loses no message. An
64 // exclusive lock on the lock file makes sure that only one process writes the
65 // log. After a failed write, the end of the file is not known, so the log
66 // refuses all writes until it is opened again. The caller serializes all calls.
67 //
68 // The log keeps no message in memory. The index index/main.idx holds the
69 // position of message i in record i, so a read of a message is one read of the
70 // index and one of its day file. At open the log indexes the lines after the
71 // last indexed one, so a missing index is built again from the day files.
72 type Log struct {
73         clock   func() time.Time
74         lock    *os.File
75         journal Journal
76         index   *Records
77         failure error
78 }
79
80 // Close releases the lock. The log refuses all calls after Close.
81 func (l *Log) Close() error {
82         err := l.journal.Close()
83         if l.index != nil {
84                 err = errors.Join(err, l.index.Close())
85                 l.index = nil
86         }
87
88         if l.lock != nil {
89                 err = errors.Join(err, l.lock.Close())
90                 l.lock = nil
91         }
92
93         return err
94 }
95
96 // Append writes a new message to the log and returns it with its ID, size, and
97 // date.
98 func (l *Log) Append(kind, text string) (Message, error) {
99         return l.AppendDated(kind, text, "")
100 }
101
102 // AppendDated writes a message with date in RFC 3339 format. An empty date uses
103 // the clock. The day file uses the local day of the given date.
104 func (l *Log) AppendDated(kind, text, date string) (Message, error) {
105         if l.lock == nil {
106                 return Message{}, fmt.Errorf("append dated message: %w", os.ErrClosed)
107         }
108
109         if l.failure != nil {
110                 return Message{}, fmt.Errorf("log needs a reopen after a failed write: %w", l.failure)
111         }
112
113         var err error
114
115         now := l.clock()
116         if date == "" {
117                 date = now.Format(time.RFC3339Nano)
118         } else {
119                 now, err = time.Parse(time.RFC3339Nano, date)
120                 if err != nil {
121                         return Message{}, fmt.Errorf("parse message date: %w", err)
122                 }
123
124                 now = now.In(time.Local)
125         }
126
127         message := Message{
128                 ID:   l.index.count,
129                 Kind: kind,
130                 Text: text,
131                 Size: len(text),
132                 Date: date,
133         }
134
135         err = logValidateMessage(message)
136         if err != nil {
137                 return Message{}, fmt.Errorf("validate dated message: %w", err)
138         }
139
140         line, err := json.Marshal(message)
141         if err != nil {
142                 return Message{}, fmt.Errorf("encode message: %w", err)
143         }
144
145         place, err := l.journal.Write(now.Format(time.DateOnly), append(line, '\n'))
146         if err == nil {
147                 err = l.addIndex(place)
148         }
149
150         if err != nil {
151                 l.failure = err
152                 return Message{}, fmt.Errorf("write message %d: %w", message.ID, err)
153         }
154
155         return message, nil
156 }
157
158 // Len returns the number of messages in the log.
159 func (l *Log) Len() int {
160         return l.index.count
161 }
162
163 // Message reads the message with the given ID from its day file.
164 func (l *Log) Message(id int) (Message, error) {
165         record, found, err := l.index.Read(id)
166         if err != nil {
167                 return Message{}, err
168         }
169
170         if !found {
171                 return Message{}, fmt.Errorf("no message %d", id)
172         }
173
174         line, err := l.journal.Read(decodePosition(record))
175         if err != nil {
176                 return Message{}, fmt.Errorf("read message %d: %w", id, err)
177         }
178
179         var message Message
180
181         err = json.Unmarshal(line, &message)
182         if err != nil {
183                 return Message{}, fmt.Errorf("decode message %d: %w", id, err)
184         }
185
186         if message.ID != id {
187                 return Message{}, fmt.Errorf("index of message %d points to message %d", id, message.ID)
188         }
189
190         return message, nil
191 }
192
193 // catchUp indexes the lines after the last indexed line.
194 func (l *Log) catchUp(ctx context.Context) error {
195         start := Position{}
196         if l.index.count > 0 {
197                 record, _, err := l.index.Read(l.index.count - 1)
198                 if err != nil {
199                         return err
200                 }
201
202                 last := decodePosition(record)
203                 start = Position{
204                         day:    last.day,
205                         offset: last.offset + uint64(last.length),
206                 }
207         }
208
209         return l.journal.Scan(ctx, start, func(line []byte, place Position) error {
210                 var message Message
211
212                 err := json.Unmarshal(line, &message)
213                 if err != nil {
214                         return fmt.Errorf("decode log message: %w", err)
215                 }
216
217                 if message.ID != l.index.count {
218                         return fmt.Errorf("expected message %d, found %d", l.index.count, message.ID)
219                 }
220
221                 err = logValidateMessage(message)
222                 if err != nil {
223                         return fmt.Errorf("message %d: %w", message.ID, err)
224                 }
225
226                 return l.addIndex(place)
227         })
228 }
229
230 func (l *Log) addIndex(place Position) error {
231         record := make([]byte, positionBytes)
232         encodePosition(record, place)
233
234         return l.index.Append(record)
235 }
236
237 // Message is one entry of the chat log. ID is its position in the log, from 0.
238 // A rebuild assigns positions in the merged log. Size is the UTF-8 byte length
239 // of Text. Date is the message time in RFC 3339 format, supplied at append or
240 // taken from the clock.
241 type Message struct {
242         ID   int    `json:"i"`
243         Kind string `json:"kind"`
244         Text string `json:"text"`
245         Size int    `json:"size"`
246         Date string `json:"date"`
247 }
248
249 // logValidateMessage makes sure that a message has a known kind, valid UTF-8
250 // text, the size of its text, and a valid date.
251 func logValidateMessage(message Message) error {
252         if !logKinds[message.Kind] {
253                 return fmt.Errorf("unknown message kind %q", message.Kind)
254         }
255
256         if !utf8.ValidString(message.Text) {
257                 return errors.New("message text is not valid UTF-8")
258         }
259
260         if message.Size != len(message.Text) {
261                 return fmt.Errorf("message size %d is not the text size %d", message.Size, len(message.Text))
262         }
263
264         _, err := time.Parse(time.RFC3339Nano, message.Date)
265         if err != nil {
266                 return fmt.Errorf("parse message date: %w", err)
267         }
268
269         return nil
270 }