- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
156 lines
7.8 KiB
Go
156 lines
7.8 KiB
Go
package pocketbase
|
||
|
||
import (
|
||
"time"
|
||
|
||
"github.com/pocketbase/pocketbase/core"
|
||
"github.com/pocketbase/pocketbase/tools/types"
|
||
|
||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||
)
|
||
|
||
// Отображение аудиозаписи в запись коллекции и обратно живёт одним местом.
|
||
//
|
||
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
|
||
// колонки поимённо, а возвращает идентификатор и признак своего захвата.
|
||
// Инвариант проекта о колонках очереди этим съёживается и перестаёт расти с
|
||
// моделью — иначе каждая новая колонка записи попадала бы под него.
|
||
|
||
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
|
||
// конвейер. Поля, которые он не меняет никогда — владелец, вход, заголовок,
|
||
// краткое описание, темы и адресат ответа, — не трогаются вовсе.
|
||
//
|
||
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
|
||
// своего сохранения, а это часы. Всё, что владелец правил в панели за это время,
|
||
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа в
|
||
// панели — владелец видел бы успешное сохранение и был бы уверен, что правка на
|
||
// месте.
|
||
func applyOwnedByPipeline(record *core.Record, r *entity.AudioRecord) {
|
||
record.Set("state", r.State)
|
||
record.Set("state_entered_at", dateOrEmpty(&r.StateEnteredAt))
|
||
record.Set("halted_at", dateOrEmpty(r.HaltedAt))
|
||
record.Set("halt_reason", derefString(r.HaltReason))
|
||
record.Set("error_text", derefString(r.ErrorText))
|
||
record.Set("acquisition_id", derefString(r.AcquisitionID))
|
||
record.Set("acquire_expires_at", dateOrEmpty(r.AcquireExpiresAt))
|
||
record.Set("delay_time", dateOrEmpty(r.DelayTime))
|
||
record.Set("attempts", r.Attempts)
|
||
record.Set("original_file", derefString(r.OriginalFileID))
|
||
record.Set("normalized_file", derefString(r.NormalizedFileID))
|
||
record.Set("transcript_text", derefString(r.TranscriptTextID))
|
||
record.Set("literary_text", derefString(r.LiteraryTextID))
|
||
record.Set("structure", derefString(r.StructureID))
|
||
record.Set("recognition", derefString(r.RecognitionID))
|
||
}
|
||
|
||
// applyToRecord кладёт запись целиком — это заведение, и спорить за поля здесь
|
||
// не с кем.
|
||
func applyToRecord(record *core.Record, r *entity.AudioRecord) {
|
||
applyOwnedByPipeline(record, r)
|
||
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
|
||
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
|
||
// записанный поверх, стёр бы его молча.
|
||
record.Set("owner", r.OwnerID)
|
||
record.Set("source", r.Source)
|
||
record.Set("title", derefString(r.Title))
|
||
record.Set("brief", derefString(r.Brief))
|
||
// Имя файла отправителя, длительность и размер кладёт приём и только он: это
|
||
// снимок принятого, и конвейер его не пересчитывает. В applyOwnedByPipeline их
|
||
// нет намеренно — снимок шага, записанный поверх, стёр бы их молча.
|
||
record.Set("original_filename", derefString(r.OriginalFilename))
|
||
record.Set("duration_ms", numberOrZero(r.DurationMs))
|
||
record.Set("size_bytes", numberOrZero(r.SizeBytes))
|
||
// Темы кладутся при заведении пустыми и конвейером не трогаются: считает их
|
||
// языковая модель отдельной задачей. Пишутся здесь ради симметрии с чтением —
|
||
// колонка, которую читают и не пишут, ничем не отличима от забытой.
|
||
record.Set("topics", r.TopicIDs)
|
||
}
|
||
|
||
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
|
||
return &entity.AudioRecord{
|
||
Id: record.Id,
|
||
OwnerID: record.GetString("owner"),
|
||
Source: record.GetString("source"),
|
||
Title: nilIfEmpty(record.GetString("title")),
|
||
Brief: nilIfEmpty(record.GetString("brief")),
|
||
State: record.GetString("state"),
|
||
StateEnteredAt: record.GetDateTime("state_entered_at").Time(),
|
||
HaltedAt: timeOrNil(record.GetDateTime("halted_at")),
|
||
HaltReason: nilIfEmpty(record.GetString("halt_reason")),
|
||
ErrorText: nilIfEmpty(record.GetString("error_text")),
|
||
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
|
||
AcquireExpiresAt: timeOrNil(record.GetDateTime("acquire_expires_at")),
|
||
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
|
||
Attempts: record.GetInt("attempts"),
|
||
OriginalFileID: nilIfEmpty(record.GetString("original_file")),
|
||
NormalizedFileID: nilIfEmpty(record.GetString("normalized_file")),
|
||
TranscriptTextID: nilIfEmpty(record.GetString("transcript_text")),
|
||
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
|
||
StructureID: nilIfEmpty(record.GetString("structure")),
|
||
RecognitionID: nilIfEmpty(record.GetString("recognition")),
|
||
OriginalFilename: nilIfEmpty(record.GetString("original_filename")),
|
||
// Имя колонки стоит литералом рядом с `.Get…`, а не уезжает в аргумент
|
||
// помощника: сверка колонок в `internal/archrules` ищет именно эту форму, а
|
||
// инвариант о колонках компилятор не проверяет.
|
||
DurationMs: numberValue(record.GetInt("duration_ms")),
|
||
SizeBytes: numberValue(record.GetInt("size_bytes")),
|
||
TopicIDs: record.GetStringSlice("topics"),
|
||
CreatedAt: record.GetDateTime("created").Time(),
|
||
UpdatedAt: record.GetDateTime("updated").Time(),
|
||
}
|
||
}
|
||
|
||
func derefString(v *string) string {
|
||
if v == nil {
|
||
return ""
|
||
}
|
||
return *v
|
||
}
|
||
|
||
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
|
||
// хранилище это пустая строка, и она же значит «времени нет».
|
||
func dateOrEmpty(v *time.Time) any {
|
||
if v == nil || v.IsZero() {
|
||
return ""
|
||
}
|
||
date, err := types.ParseDateTime(*v)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return date
|
||
}
|
||
|
||
// numberOrZero отдаёт ноль вместо отсутствующего числа.
|
||
//
|
||
// «Неизвестно» числовая колонка хранилища не выражает вовсе: пустое значение она
|
||
// не держит и кладёт нулём. Отличимость потребовала бы четвёртой колонки-признака
|
||
// либо текстового типа у чисел, и платить за это нечем — обе величины ставит
|
||
// приём и ставит всегда. Решение владельца 2026-08-15.
|
||
func numberOrZero(v *int64) int64 {
|
||
if v == nil {
|
||
return 0
|
||
}
|
||
return *v
|
||
}
|
||
|
||
// numberValue читает колонку числом. Ноль здесь означает ноль — см. numberOrZero.
|
||
func numberValue(value int) *int64 {
|
||
v := int64(value)
|
||
return &v
|
||
}
|
||
|
||
func nilIfEmpty(v string) *string {
|
||
if v == "" {
|
||
return nil
|
||
}
|
||
return &v
|
||
}
|
||
|
||
func timeOrNil(v types.DateTime) *time.Time {
|
||
if v.IsZero() {
|
||
return nil
|
||
}
|
||
t := v.Time()
|
||
return &t
|
||
}
|