// Package transcript writes finalized captions to timestamped session logs. package transcript import ( "bufio" "errors" "fmt" "os" "path/filepath" "strings" "sync" "time" "tea.chunkbyte.com/kato/captioneer/src/captions" ) type Sink struct { mu sync.Mutex file *os.File writer *bufio.Writer now func() time.Time closed bool path string } func New(directory string) (*Sink, error) { return newWithClock(directory, time.Now) } func newWithClock(directory string, now func() time.Time) (*Sink, error) { if strings.TrimSpace(directory) == "" { return nil, errors.New("transcript directory cannot be empty") } if err := os.MkdirAll(directory, 0o700); err != nil { return nil, fmt.Errorf("create transcript directory: %w", err) } startedAt := now() base := "captioneer-" + startedAt.Format("2006-01-02_15-04-05.000") var file *os.File var path string for suffix := 0; suffix < 1000; suffix++ { name := base + ".log" if suffix > 0 { name = fmt.Sprintf("%s-%d.log", base, suffix) } path = filepath.Join(directory, name) candidate, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if errors.Is(err, os.ErrExist) { continue } if err != nil { return nil, fmt.Errorf("create transcript: %w", err) } file = candidate break } if file == nil { return nil, errors.New("create transcript: too many files share this timestamp") } return &Sink{file: file, writer: bufio.NewWriter(file), now: now, path: path}, nil } func (s *Sink) Path() string { return s.path } func (s *Sink) Publish(event captions.Event) error { if event.Kind != captions.Final { return nil } s.mu.Lock() defer s.mu.Unlock() if s.closed { return errors.New("transcript is closed") } text := strings.Join(strings.Fields(event.Text), " ") if text == "" { return nil } _, err := fmt.Fprintf( s.writer, "%s [%.2fs-%.2fs] %s\n", s.now().Format(time.RFC3339Nano), event.StartedAt.Seconds(), event.EndedAt.Seconds(), text, ) if err != nil { return err } return s.writer.Flush() } func (s *Sink) Close() error { s.mu.Lock() defer s.mu.Unlock() if s.closed { return nil } s.closed = true return errors.Join(s.writer.Flush(), s.file.Close()) }