FT-WP-0001-T06: add the temporary quoted-remembrance composer.
Assistant: grok Assistant-Session: 01a1097f-0521-7fd1-8343-23631f633e2e
This commit is contained in:
parent
43fd92f375
commit
94ee3f4f9c
7 changed files with 1438 additions and 4 deletions
148
internal/compose/queue.go
Normal file
148
internal/compose/queue.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package compose
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Queue is the review queue. Drafts are files under a directory the caller
|
||||
// passes; this package never chooses a location of its own.
|
||||
type Queue struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// Open opens a review queue at dir, creating the directory if needed.
|
||||
func Open(dir string) (*Queue, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil, errors.New("queue directory is required")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory", dir)
|
||||
}
|
||||
return &Queue{dir: dir}, nil
|
||||
}
|
||||
|
||||
// Put stores a new draft. Review fields are cleared: approval only happens
|
||||
// through Approve, and a caller cannot smuggle a consent basis in.
|
||||
// Putting the same post_id again keeps the copy already queued.
|
||||
func (q *Queue) Put(d Draft) error {
|
||||
if _, err := q.path(d.PostID); err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := q.Get(d.PostID)
|
||||
if err == nil {
|
||||
if existing.Body != d.Body || existing.SourceEntryID != d.SourceEntryID || existing.SourceEntryURL != d.SourceEntryURL || existing.Variant != d.Variant {
|
||||
return fmt.Errorf("post_id %s is already queued with different content", d.PostID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
d.ReviewedBy = ""
|
||||
d.SubjectConsent = ""
|
||||
d.Approved = false
|
||||
d.Publication = nil
|
||||
return q.save(d)
|
||||
}
|
||||
|
||||
// Get reads one draft from the queue.
|
||||
func (q *Queue) Get(postID string) (Draft, error) {
|
||||
path, err := q.path(postID)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
var d Draft
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return Draft{}, fmt.Errorf("read draft %s: %w", postID, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Approve records the reviewer and a consent basis supplied by the caller.
|
||||
// consent is not defaulted. not-required is recorded only when the caller
|
||||
// passes that value; absence of objection is not treated as consent.
|
||||
func (q *Queue) Approve(postID, reviewer, consent string) (Draft, error) {
|
||||
reviewer = strings.TrimSpace(reviewer)
|
||||
consent = strings.TrimSpace(consent)
|
||||
if reviewer == "" {
|
||||
return Draft{}, ErrReviewer
|
||||
}
|
||||
if _, ok := consentBases[consent]; !ok {
|
||||
return Draft{}, fmt.Errorf("%w: got %q", ErrConsent, consent)
|
||||
}
|
||||
d, err := q.Get(postID)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
d.ReviewedBy = reviewer
|
||||
d.SubjectConsent = consent
|
||||
d.Approved = true
|
||||
if err := q.save(d); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (q *Queue) save(d Draft) error {
|
||||
path, err := q.path(d.PostID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := marshalDraft(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func marshalDraft(d Draft) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (q *Queue) path(postID string) (string, error) {
|
||||
if postID == "" || strings.Contains(postID, "/") || strings.Contains(postID, "\\") || strings.Contains(postID, "..") {
|
||||
return "", fmt.Errorf("invalid post_id %q", postID)
|
||||
}
|
||||
path := filepath.Join(q.dir, postID+".json")
|
||||
absDir, err := filepath.Abs(q.dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel, err := filepath.Rel(absDir, absPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("invalid post_id %q", postID)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue