summaryrefslogtreecommitdiff
path: root/internal/draft
diff options
from:
to:
context:
space:
mode:
authorGravatar Saya Andy <saya.andy@posteo.com> 2026-08-01 19:58:57 +0700
committerGravatar Saya Andy <saya.andy@posteo.com> 2026-08-01 19:58:57 +0700
commit684d0b6a57d2eb79730ade63baccdc6e59bebc29 (patch)
tree956c89c81965b8c974c8497e3719d0b16d332512 /internal/draft
parent129e48683f6fc0615b606a737f2afcd50538bb2e (diff)
downloadarticlator-684d0b6a57d2eb79730ade63baccdc6e59bebc29.tar.gz
articlator-684d0b6a57d2eb79730ade63baccdc6e59bebc29.zip
feat: repurpose metadata parser as article transcoder for saya.uz andmain
telegram
Diffstat (limited to 'internal/draft')
-rw-r--r--internal/draft/parser.go179
-rw-r--r--internal/draft/parser_test.go83
2 files changed, 262 insertions, 0 deletions
diff --git a/internal/draft/parser.go b/internal/draft/parser.go
new file mode 100644
index 0000000..2761c87
--- /dev/null
+++ b/internal/draft/parser.go
@@ -0,0 +1,179 @@
+package draft
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-article-metadata-add/internal/frontmatter"
+)
+
+// layoutTokenRe matches gallery layout hints like "2x", "3x" that are
+// meaningful only to the sayauz renderer and must be dropped elsewhere.
+var layoutTokenRe = regexp.MustCompile(`^\d+x$`)
+
+type BlockKind int
+
+const (
+ TextBlock BlockKind = iota
+ GalleryBlock
+)
+
+// Block is one ordered piece of a section: either a run of markdown text or a
+// gallery. Exactly one of Text / Gallery is meaningful depending on Kind.
+type Block struct {
+ Kind BlockKind
+ Text string
+ Gallery *Gallery
+}
+
+type Gallery struct {
+ Timezone string
+ KeyPrefix string
+ Photos []Photo
+}
+
+type Photo struct {
+ Key string // KeyPrefix + filename, e.g. "Sevsk/20241008-093833.jpg"
+ Caption string
+}
+
+// Section is the content between two "..." separator lines, split into ordered
+// text and gallery blocks.
+type Section struct {
+ Index int
+ Blocks []Block
+}
+
+type Document struct {
+ SourcePath string
+ Codename string
+ Metadata *frontmatter.Metadata
+ RawContent []byte
+ Body string
+ Sections []Section
+}
+
+// ParseDraft parses a draft file: frontmatter metadata, the markdown body, and
+// the body split into sections (on "..." lines) of ordered text/gallery blocks.
+func ParseDraft(sourcePath, codename string, content []byte) (*Document, error) {
+ metadata, body, err := frontmatter.ParseFrontmatter(content)
+ if err != nil {
+ return nil, err
+ }
+
+ doc := &Document{
+ SourcePath: sourcePath,
+ Codename: codename,
+ Metadata: metadata,
+ RawContent: content,
+ Body: string(body),
+ }
+ doc.Sections = splitSections(doc.Body)
+
+ return doc, nil
+}
+
+func splitSections(body string) []Section {
+ lines := strings.Split(body, "\n")
+
+ var sections []Section
+ var chunk []string
+
+ flush := func() {
+ blocks := parseBlocks(chunk)
+ chunk = nil
+ if len(blocks) == 0 {
+ return
+ }
+ sections = append(sections, Section{Index: len(sections), Blocks: blocks})
+ }
+
+ for _, line := range lines {
+ if strings.TrimSpace(line) == "..." {
+ flush()
+ continue
+ }
+ chunk = append(chunk, line)
+ }
+ flush()
+
+ return sections
+}
+
+func parseBlocks(lines []string) []Block {
+ var blocks []Block
+ var text []string
+
+ flushText := func() {
+ joined := strings.TrimSpace(strings.Join(text, "\n"))
+ text = nil
+ if joined != "" {
+ blocks = append(blocks, Block{Kind: TextBlock, Text: joined})
+ }
+ }
+
+ for i := 0; i < len(lines); i++ {
+ trimmed := strings.TrimSpace(lines[i])
+ if strings.HasPrefix(trimmed, "{Gallery:") {
+ flushText()
+ gallery, next := parseGallery(lines, i)
+ if gallery != nil {
+ blocks = append(blocks, Block{Kind: GalleryBlock, Gallery: gallery})
+ }
+ i = next
+ continue
+ }
+ text = append(text, lines[i])
+ }
+ flushText()
+
+ return blocks
+}
+
+// parseGallery reads a {Gallery:...} block starting at lines[start] and returns
+// the parsed gallery plus the index of the closing {/Gallery} line (or the last
+// consumed line if unterminated).
+func parseGallery(lines []string, start int) (*Gallery, int) {
+ header := strings.TrimSpace(lines[start])
+ header = strings.TrimPrefix(header, "{Gallery:")
+ header = strings.TrimSuffix(header, "}")
+ tz, keyPrefix, _ := strings.Cut(header, ":")
+
+ gallery := &Gallery{Timezone: tz, KeyPrefix: keyPrefix}
+
+ i := start + 1
+ for ; i < len(lines); i++ {
+ trimmed := strings.TrimSpace(lines[i])
+ if trimmed == "{/Gallery}" {
+ break
+ }
+ if trimmed == "" {
+ continue
+ }
+ gallery.Photos = append(gallery.Photos, parsePhoto(keyPrefix, trimmed))
+ }
+
+ return gallery, i
+}
+
+func parsePhoto(keyPrefix, line string) Photo {
+ parts := strings.Split(line, "|")
+ for i := range parts {
+ parts[i] = strings.TrimSpace(parts[i])
+ }
+
+ filename := parts[0]
+
+ var captionParts []string
+ for _, p := range parts[1:] {
+ if p == "" || layoutTokenRe.MatchString(p) {
+ continue
+ }
+ captionParts = append(captionParts, p)
+ }
+
+ return Photo{
+ Key: keyPrefix + filename,
+ Caption: strings.Join(captionParts, " "),
+ }
+}
diff --git a/internal/draft/parser_test.go b/internal/draft/parser_test.go
new file mode 100644
index 0000000..c6d454d
--- /dev/null
+++ b/internal/draft/parser_test.go
@@ -0,0 +1,83 @@
+package draft
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestParseDraftSevsk(t *testing.T) {
+ raw, err := os.ReadFile("../../.draft/sevsk.md.draft")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ doc, err := ParseDraft("../../.draft/sevsk.md.draft", "sevsk", raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.Metadata == nil || doc.Metadata.Title == "" {
+ t.Fatal("expected frontmatter metadata")
+ }
+
+ var galleries int
+ for _, section := range doc.Sections {
+ for _, block := range section.Blocks {
+ if block.Kind != GalleryBlock {
+ continue
+ }
+ galleries++
+ for _, p := range block.Gallery.Photos {
+ if !strings.HasPrefix(p.Key, "Sevsk/20241008-") {
+ t.Errorf("unexpected photo key %q", p.Key)
+ }
+ if strings.Contains(p.Caption, "2x") {
+ t.Errorf("layout token leaked into caption %q", p.Caption)
+ }
+ }
+ }
+ }
+ if galleries == 0 {
+ t.Fatal("expected at least one gallery")
+ }
+
+ // First gallery: 4 photos, captions on photos 1 and 3, none on 2; photo 4 had "2x |" => empty caption.
+ first := firstGallery(doc)
+ if first == nil || len(first.Photos) != 4 {
+ t.Fatalf("first gallery: want 4 photos, got %v", first)
+ }
+ if first.Timezone != "Europe/Moscow" {
+ t.Errorf("first gallery tz = %q", first.Timezone)
+ }
+ if first.Photos[0].Caption != "Никольская церковь. Действующая" {
+ t.Errorf("photo[0] caption = %q", first.Photos[0].Caption)
+ }
+ if first.Photos[1].Caption != "" {
+ t.Errorf("photo[1] caption = %q, want empty", first.Photos[1].Caption)
+ }
+ if first.Photos[3].Caption != "" {
+ t.Errorf("photo[3] caption = %q, want empty (only 2x token)", first.Photos[3].Caption)
+ }
+ if first.Photos[3].Key != "Sevsk/20241008-094036.jpg" {
+ t.Errorf("photo[3] key = %q", first.Photos[3].Key)
+ }
+}
+
+func firstGallery(doc *Document) *Gallery {
+ for _, section := range doc.Sections {
+ for _, block := range section.Blocks {
+ if block.Kind == GalleryBlock {
+ return block.Gallery
+ }
+ }
+ }
+ return nil
+}
+
+func TestSplitSectionsSeparator(t *testing.T) {
+ body := "intro\n...\nmiddle\n...\nend"
+ sections := splitSections(body)
+ if len(sections) != 3 {
+ t.Fatalf("want 3 sections, got %d", len(sections))
+ }
+}