textmachine/platform/internal/httpapi/project.go

180 lines
6.7 KiB
Go

package httpapi
import (
"encoding/json"
"textmachine/platform/internal/ingest"
"textmachine/platform/internal/pgstore"
)
// project.go: the read model as the contract's shapes.
//
// Everything here is a TRANSLATION and not a forwarding. Three vocabularies meet at this line — the
// engine's, the platform's own and the contract's — and the rule is the same for all three: what
// leaves is the contract's, and a value this build cannot map leaves as `null` rather than as a word
// a generated client's union does not contain.
func projectBook(b pgstore.Book) wireBook {
out := wireBook{
ID: b.ID, Revision: b.Revision, Title: b.Title,
SourceLang: b.SourceLang, TargetLang: b.TargetLang,
Status: b.Status,
StructureVersion: b.StructureVersion,
ShapeEpoch: b.ShapeEpoch,
ChapterCount: b.ChapterCount,
ChaptersDone: b.ChaptersDone,
AddedAt: b.AddedAt,
NoteCount: b.NoteCount,
}
// `null` while the book is still arriving: the size is counted as the bytes go past, so a book
// that is still on the wire has no size yet — which is a different fact from "zero characters".
//
// The ENGINE's figure wins whenever there is one, and the flag beside it says which was used. The
// two are not interchangeable and the difference is not a rounding: for an EPUB the intake's
// number counts the runes of a ZIP archive (books.counter). Until a manifest has been read there
// is nothing better to say, so the approximation is served and honestly labelled rather than
// withheld — a screen with no size at all is worse than one that says its size is approximate.
if b.Status != "uploading" {
count := b.CharacterCount
if b.SourceChars != nil {
count, out.CharacterCountExact = *b.SourceChars, true
}
out.CharacterCount = &count
}
if b.Structure != "" {
structure := b.Structure
out.Structure = &structure
}
if reason := ingest.ContractRejectReason(b.RejectReason); reason != "" {
out.RejectReason = &reason
}
return out
}
func projectRun(r pgstore.Run) wireRun {
out := wireRun{
ID: r.ID, BookID: r.BookID, Revision: r.Revision, Status: r.Status,
// ⚠ The wire name is the product's and the column's is the engine flag's (`--verify-bank`).
// They are deliberately different words for the same bit: what the user chose is "stop so I
// can look at the terms", and the flag is how that reaches the engine.
StopForSigning: r.VerifyBank,
OrderedChapters: r.OrderedChapters,
OrderedUnits: r.OrderedUnits,
DeliveredChapters: r.DeliveredChapters,
TermConsistencyFunded: r.BondFunded,
// The user's own click, which no `status` answers: a stop asked for mid-translation can meet
// the run reaching the bank signature, and `awaiting_bank` then offers to continue on a click
// that meant "stop". REQUIRED and never omitted — no stop is `false`, and an absent field
// would be a second way of writing the same fact.
StopRequested: r.StopRequested,
Progress: wireProgress{
Done: r.Progress.Done, Total: r.Progress.Total, Stage: r.Progress.Stage,
ETASeconds: r.Progress.ETASeconds,
},
StartedAt: r.StartedAt, FinishedAt: r.FinishedAt,
}
if reason := ingest.ContractPausedReason(r.PausedReason); reason != "" {
out.PausedReason = &reason
}
if r.FailureReason != "" {
out.FailureReason = &r.FailureReason
}
return out
}
func projectChapter(c pgstore.Chapter) wireChapter {
return wireChapter{
ID: c.ID, Number: c.Number,
// ⚠ ALWAYS `null` today, and that is the contract being obeyed rather than a hole. The engine's
// manifest carries a `heading`, and it is a deterministic render of the pair's heading rule
// («Глава N») — the engine's own comment forbids presenting it as a label carried by the book,
// and the canon forbids a deployment to put a rendered ordinal in this field. A client with no
// label renders its own ordinal, in the language of ITS interface, which the server does not
// know. Real labels need a producer that does not exist yet (engine backlog row 160).
Heading: nil,
UnitsTotal: c.UnitsTotal,
UnitsDone: c.UnitsDone,
NoteCount: c.NoteCount,
}
}
func projectUnit(u pgstore.Unit) wireUnit {
out := wireUnit{
ID: u.ID, Source: u.Source, Target: u.Target, State: u.State,
Notes: make([]wireNote, 0, len(u.Notes)),
}
for _, n := range u.Notes {
out.Notes = append(out.Notes, projectNote(n))
}
return out
}
func projectNote(n pgstore.Note) wireNote {
code, severity := ingest.NoteCode(n.Reason)
out := wireNote{
ID: n.ID, CreatedAt: n.CreatedAt, Severity: severity, Code: code, ChapterID: n.ChapterID,
}
if n.UnitID != "" {
out.UnitID = &n.UnitID
}
return out
}
// projectOfferedTerm carries the read model's absences out unchanged. Every `null` it emits is one the
// store held, and none is manufactured here: the numbers are the service's own measurements, and a
// zero substituted for a missing one would be a figure nobody took.
func projectOfferedTerm(t pgstore.BankOfferedTerm) wireOffered {
return wireOffered{
Src: t.Src, Dst: t.Dst, Kind: nilIfEmpty(t.Kind), Channel: nilIfEmpty(t.Channel),
Freq: t.Freq, Spread: t.Spread, Conventions: t.Conventions, Confidence: t.Confidence,
Invented: t.Invented,
// The three lists are `required` and an empty collection is an empty array, never null.
Contradicts: emptyIfNil(t.Contradicts), BankHolds: emptyIfNil(t.BankHolds),
Variants: emptyIfNil(t.Variants),
}
}
// projectConsolidation renders the member the first page always carries: the section, or the literal
// `null` that says nothing measured it. Never an empty object — "consolidated 0, unanswered 0" reads
// as "nothing is missing", which is the one thing this member must not say by accident.
func projectConsolidation(c *pgstore.BankConsolidation) json.RawMessage {
if c == nil {
return json.RawMessage("null")
}
body, err := json.Marshal(wireConsolidation{
Complete: c.Complete, RenderBatchesDropped: c.RenderBatchesDropped,
ClassifyBatchesDropped: c.ClassifyBatchesDropped, Consolidated: c.Consolidated,
Declined: c.Declined, Unanswered: c.Unanswered, NeverAsked: c.NeverAsked,
})
if err != nil {
// Seven integers and a bool cannot fail to marshal; `null` is still the safe answer, because
// the alternative is a page that says the bank is whole when nobody asked.
return json.RawMessage("null")
}
return body
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
func emptyIfNil(v []string) []string {
if v == nil {
return []string{}
}
return v
}
func projectTerm(t pgstore.BankTerm) wireTerm {
out := wireTerm{
ID: t.ID, Src: t.Src, Dst: t.Dst, Status: t.Status, Origin: t.Origin, Sense: t.Sense,
SinceChapter: t.Since, UntilChapter: t.Until,
}
if t.Kind != "" {
kind := t.Kind
out.Kind = &kind
}
return out
}