diff options
Diffstat (limited to 'internal/draft')
| -rw-r--r-- | internal/draft/parser.go | 179 | ||||
| -rw-r--r-- | internal/draft/parser_test.go | 83 |
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)) + } +} |