188 lines
7.1 KiB
Go
188 lines
7.1 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,
|
|
// Always sent, both of them, and for opposite reasons: a boolean whose absence would mean
|
|
// `false` a second way, and a sum whose absence is the only honest way to say «no consent
|
|
// stands» (a zero there would read as a consent of nothing, which is a different statement).
|
|
ProgressLagging: r.ProgressLagging,
|
|
}
|
|
if r.RebillConsent > 0 {
|
|
consent := int64(r.RebillConsent)
|
|
out.RebillConsentMicroUSD = &consent
|
|
}
|
|
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
|
|
}
|