cmd/server/tests/podcast_test.go — six suites, the shared-dev-DB doctrine (test*-named libraries, cascading cleanup, no binary fixtures): - TestPodcastRLSSurfaces: THE AUDITOR (invariant 13) — non-subscriber 404 on feed detail + SSR page (existence leak), episodes absent from generic media reads, admin escape, subscriber visibility, then the hidden-library flip hiding an already-subscribed feed (the feed policy's library-visibility join). Seeding runs as the FIRST ADMIN — the seed identity has neither subscription nor users row, so podcast INSERT..RETURNING (SELECT policy under FORCE) 42501s for it; production pods run as exactly that admin (invariant 12). All direct row probes read through the admin scope for the same reason. - TestPodcastAddFeedFlow: member add + subscribe + metadata-only upsert; duplicate-URL dedupe (no second row); allow_member_feeds gate (member 403 / admin 201 / subscribe-still-allowed). - TestPodcastShrunkenFeedRetention: feed purges an episode from the XML, both rows persist (upsert-without-delete pin, D14). - TestPodcastEditSegregation: generic edit 400 + write-to-file refusal for episodes; control book edit unaffected. - TestPodcastDownloadWorker (ffmpeg-generated tone at runtime): file lands at <download-root>/<feed-slug>/NNNN-…, audio_metadata probed; re-download of a downloaded episode never re-fetches (idempotency pin, enclosure-hit counted); dead enclosure → failed + persisted reason + metadata intact. Fetch/SSRF tests swap the client via SetPodcastHTTPClientForTests (the production client refuses loopback httptest servers by design — the real client gets its own negative test in services). - TestPodcastScannerExclusion: SetFolders on a podcasts library root yields zero scanner folders (invariant 4 pin) — MediaScanner .FolderCount() accessor added. Unit: internal/services/podcast_rss_test.go — namespace-aware parsing fixture (three XML namespaces, fallback chains, hosts/guests), duration spellings, RFC-822 variants, explicit variants, slug/path layout, enclosure-extension selection, stripHTML, and the SSRF matrix (private/loopback/link-local/multicast/mapped-v6 blocked; a loopback httptest server PROVES the production client refuses it). suites green: full integration run 161s / 0 failures; vitest 12 files 163 tests. Contract widening: TestContractPin_MediaListBothBranches' raw-row allowlist gains the 15 podcast episode columns (additive; documented in docs/contract_pins.md — inert defaults on scan rows). Bruno: bruno/ podcasts/ mirrors the §8 endpoints (12 requests) + Bookhoard env vars. tests/ui/COVERAGE.md rows 18-23: podcasts pages/lifecycle/RLS/chain — Playwright sweep pending.
561 lines
17 KiB
Go
561 lines
17 KiB
Go
// RSS 2.0 + iTunes + Podcasting-2.0 feed parsing for the podcast
|
|
// fetcher (plan podcast-integration.md §5/§7). Stdlib encoding/xml only
|
|
// — no new deps.
|
|
//
|
|
// Namespace strategy: iTunes fields MUST be namespace-qualified in the
|
|
// struct tags (the itunes URI) because their local names collide with
|
|
// core RSS ("title", "subtitle", "author", "summary", "explicit",
|
|
// "image", "duration"). Podcasting-2.0 fields (podcast:guid,
|
|
// podcast:transcript, ...) have collision-free local names, so they
|
|
// match on local name — encoding/xml matches Name.Local when the tag
|
|
// carries no namespace, which also tolerates both declared namespace
|
|
// URIs the ecosystem uses.
|
|
//
|
|
// Out of scope at parse time (plan §5): podcast:value, liveItem,
|
|
// soundbite, alternateEnclosure, socialInteract, remoteItem/podroll,
|
|
// locked/block, txt, chat. lastBuildDate/ttl are poll hints only.
|
|
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// itunesNS is the canonical iTunes podcast namespace URI.
|
|
const itunesNS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
|
|
|
|
// PodcastFeedXML is one parsed RSS document.
|
|
type PodcastFeedXML struct {
|
|
XMLName xml.Name `xml:"rss"`
|
|
Channel PodcastChannelXML `xml:"channel"`
|
|
}
|
|
|
|
type PodcastChannelXML struct {
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
Language string `xml:"language"`
|
|
Description string `xml:"description"`
|
|
|
|
// iTunes channel namespace.
|
|
ITunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
|
|
ITunesOwner struct {
|
|
Name string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd name"`
|
|
Email string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd email"`
|
|
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd owner"`
|
|
ITunesImage struct {
|
|
Href string `xml:"href,attr"`
|
|
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
|
|
ITunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
|
|
ITunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
|
|
ITunesCategory struct {
|
|
Text string `xml:"text,attr"`
|
|
Subcategory struct {
|
|
Text string `xml:"text,attr"`
|
|
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
|
|
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
|
|
ITunesNewFeedURL string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd new-feed-url"`
|
|
ITunesComplete string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd complete"`
|
|
|
|
// Podcasting 2.0 (matched on local name; see file comment).
|
|
PodcastGUID string `xml:"guid"`
|
|
PodcastMedium string `xml:"medium"`
|
|
|
|
LastBuildDate string `xml:"lastBuildDate"`
|
|
|
|
Items []PodcastItemXML `xml:"item"`
|
|
}
|
|
|
|
type PodcastItemXML struct {
|
|
GUID string `xml:"guid"`
|
|
Title string `xml:"title"`
|
|
ITunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
|
|
Subtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"`
|
|
// Description resolution order (plan §5): description >
|
|
// itunes:summary > content:encoded.
|
|
Description string `xml:"description"`
|
|
ITunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
|
|
ContentEncoded string `xml:"encoded"`
|
|
|
|
// Item author: itunes:author preferred, then RSS author, then dc:creator.
|
|
ITunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
|
|
Author string `xml:"author"`
|
|
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
|
|
|
ITunesImage struct {
|
|
Href string `xml:"href,attr"`
|
|
} `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
|
|
ITunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
|
|
ITunesDuration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
|
|
ITunesEpisode string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episode"`
|
|
ITunesSeason string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd season"`
|
|
|
|
PubDate string `xml:"pubDate"`
|
|
|
|
Enclosure struct {
|
|
URL string `xml:"url,attr"`
|
|
Length int64 `xml:"length,attr"`
|
|
Type string `xml:"type,attr"`
|
|
} `xml:"enclosure"`
|
|
|
|
// Podcasting 2.0: store-now, feature-later URLs.
|
|
Transcript struct {
|
|
URL string `xml:"url,attr"`
|
|
} `xml:"transcript"`
|
|
Chapters struct {
|
|
URL string `xml:"url,attr"`
|
|
} `xml:"chapters"`
|
|
Persons []struct {
|
|
Href string `xml:"href,attr"`
|
|
Role string `xml:"role,attr"`
|
|
} `xml:"person"`
|
|
}
|
|
|
|
// ParsePodcastFeed decodes a feed document via a namespace-aware token
|
|
// decoder (see decodePodcastFeed). Returns an error when the XML is
|
|
// unparseable or the document is not an RSS channel.
|
|
func ParsePodcastFeed(data []byte) (*PodcastFeedXML, error) {
|
|
feed, err := decodePodcastFeed(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("feed parse: %w", err)
|
|
}
|
|
if feed.Channel.Title == "" && len(feed.Channel.Items) == 0 {
|
|
return nil, fmt.Errorf("feed parse: not an RSS channel (no title, no items)")
|
|
}
|
|
return feed, nil
|
|
}
|
|
|
|
// EpisodeTitle prefers itunes:title over the raw RSS title (plan §5).
|
|
func (it *PodcastItemXML) EpisodeTitle() string {
|
|
if t := strings.TrimSpace(it.ITunesTitle); t != "" {
|
|
return t
|
|
}
|
|
return strings.TrimSpace(it.Title)
|
|
}
|
|
|
|
// EpisodeDescription resolves the description fallback chain.
|
|
func (it *PodcastItemXML) EpisodeDescription() string {
|
|
for _, c := range []string{it.Description, it.ITunesSummary, it.ContentEncoded} {
|
|
if t := strings.TrimSpace(c); t != "" {
|
|
return t
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// EpisodeAuthor resolves the author fallback chain.
|
|
func (it *PodcastItemXML) EpisodeAuthor() string {
|
|
for _, c := range []string{it.ITunesAuthor, it.Author, it.Creator} {
|
|
if t := strings.TrimSpace(c); t != "" {
|
|
return t
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// EpisodeExplicit maps the itunes:explicit spelling variants to bool.
|
|
func (it *PodcastItemXML) EpisodeExplicit() bool {
|
|
return parseExplicit(it.ITunesExplicit)
|
|
}
|
|
|
|
func parseExplicit(v string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "true", "yes", "explicit", "1":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HostsAndGuests splits podcast:person entries by role (plan §5).
|
|
func (it *PodcastItemXML) HostsAndGuests() (hosts []string, guests []string) {
|
|
for _, p := range it.Persons {
|
|
name := p.Href
|
|
if name == "" {
|
|
continue
|
|
}
|
|
switch strings.ToLower(p.Role) {
|
|
case "guest":
|
|
guests = append(guests, name)
|
|
default: // host and unlabeled roles surface as hosts
|
|
hosts = append(hosts, name)
|
|
}
|
|
}
|
|
return hosts, guests
|
|
}
|
|
|
|
// parseITunesDuration accepts the three spellings the spec allows —
|
|
// plain seconds, HH:MM:SS, MM:SS — plus a best-effort prose fallback
|
|
// ("58 minutes"). Returns 0 for anything unparseable (the column stays
|
|
// NULL; ffprobe reconciles after download anyway).
|
|
func parseITunesDuration(v string) int {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return 0
|
|
}
|
|
if secs, err := strconv.Atoi(v); err == nil {
|
|
return secs
|
|
}
|
|
parts := strings.Split(v, ":")
|
|
if len(parts) <= 3 {
|
|
total := 0
|
|
ok := true
|
|
for _, p := range parts {
|
|
n, err := strconv.Atoi(strings.TrimSpace(p))
|
|
if err != nil {
|
|
ok = false
|
|
break
|
|
}
|
|
total = total*60 + n
|
|
}
|
|
if ok {
|
|
return total
|
|
}
|
|
}
|
|
// Prose best-effort: "<N> minutes/hours/seconds".
|
|
lower := strings.ToLower(v)
|
|
fields := strings.Fields(lower)
|
|
for i, f := range fields {
|
|
if n, err := strconv.Atoi(f); err == nil && i+1 < len(fields) {
|
|
switch {
|
|
case strings.HasPrefix(fields[i+1], "hour"):
|
|
return n * 3600
|
|
case strings.HasPrefix(fields[i+1], "minute"):
|
|
return n * 60
|
|
case strings.HasPrefix(fields[i+1], "second"):
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// parseFeedTime parses the RFC-822/1123 variants feeds actually emit.
|
|
func parseFeedTime(v string) (t time.Time, ok bool) {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return time.Time{}, false
|
|
}
|
|
layouts := []string{
|
|
time.RFC1123Z,
|
|
time.RFC1123,
|
|
time.RFC822Z,
|
|
time.RFC822,
|
|
"Mon, 2 Jan 2006 15:04:05 -0700",
|
|
"Mon, 2 Jan 2006 15:04:05 MST",
|
|
"2 Jan 2006 15:04:05 -0700",
|
|
"2 Jan 2006 15:04:05 MST",
|
|
time.ANSIC,
|
|
time.UnixDate,
|
|
}
|
|
for _, layout := range layouts {
|
|
if t, err := time.Parse(layout, v); err == nil {
|
|
return t, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// ============================================================================
|
|
// Namespace-aware decoding.
|
|
//
|
|
// WHY NOT encoding/xml: its field matching for UNQUALIFIED tags ignores
|
|
// namespaces — a field tagged `xml:"title"` also matches
|
|
// `<itunes:title>` (first field in declaration order wins, document
|
|
// order clobbers), so the two collide unpredictably. RSS feeds stack
|
|
// core RSS, iTunes and Podcasting-2.0 namespaces over the SAME local
|
|
// names (title, author, description, guid), so a token-loop decoder
|
|
// dispatching on (namespace, local) is the only deterministic shape.
|
|
// It also tolerates BOTH podcast-namespace URIs the ecosystem uses and
|
|
// arbitrary prefix spellings, because matching is on resolved
|
|
// namespace, never on the prefix string.
|
|
// ============================================================================
|
|
|
|
// decodePodcastFeed runs the token loop and fills the document struct.
|
|
func decodePodcastFeed(data []byte) (*PodcastFeedXML, error) {
|
|
dec := xml.NewDecoder(bytes.NewReader(data))
|
|
dec.Strict = false // feeds in the wild carry stray entities; be liberal
|
|
|
|
feed := &PodcastFeedXML{}
|
|
// Find the <rss> start element (tolerate comments/PIs/doctype).
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if se, ok := tok.(xml.StartElement); ok {
|
|
if se.Name.Local == "rss" {
|
|
break
|
|
}
|
|
// Some feeds wrap in nothing else; only rss is accepted.
|
|
return nil, fmt.Errorf("unexpected root element %q", se.Name.Local)
|
|
}
|
|
}
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch t := tok.(type) {
|
|
case xml.StartElement:
|
|
if t.Name.Local == "channel" {
|
|
if err := decodeChannel(dec, &feed.Channel); err != nil {
|
|
return nil, err
|
|
}
|
|
continue
|
|
}
|
|
if err := skipElement(dec); err != nil {
|
|
return nil, err
|
|
}
|
|
case xml.EndElement:
|
|
// </rss> — done.
|
|
return feed, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// decodeChannel walks the channel's children.
|
|
func decodeChannel(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch t := tok.(type) {
|
|
case xml.StartElement:
|
|
ns, local := t.Name.Space, t.Name.Local
|
|
switch {
|
|
case ns == "" && local == "title":
|
|
ch.Title, err = elementText(dec)
|
|
case ns == "" && local == "description":
|
|
ch.Description, err = elementText(dec)
|
|
case ns == "" && local == "language":
|
|
ch.Language, err = elementText(dec)
|
|
case ns == "" && local == "link":
|
|
ch.Link, err = elementText(dec)
|
|
case ns == "" && local == "lastBuildDate":
|
|
ch.LastBuildDate, err = elementText(dec)
|
|
case ns == itunesNS && local == "author":
|
|
ch.ITunesAuthor, err = elementText(dec)
|
|
case ns == itunesNS && local == "owner":
|
|
err = decodeITunesOwner(dec, ch)
|
|
case ns == itunesNS && local == "image":
|
|
ch.ITunesImage.Href = attrValue(t.Attr, "href")
|
|
err = skipElement(dec)
|
|
case ns == itunesNS && local == "explicit":
|
|
ch.ITunesExplicit, err = elementText(dec)
|
|
case ns == itunesNS && local == "type":
|
|
ch.ITunesType, err = elementText(dec)
|
|
case ns == itunesNS && local == "new-feed-url":
|
|
ch.ITunesNewFeedURL, err = elementText(dec)
|
|
case ns == itunesNS && local == "complete":
|
|
ch.ITunesComplete, err = elementText(dec)
|
|
case ns == itunesNS && local == "category":
|
|
ch.ITunesCategory.Text = attrValue(t.Attr, "text")
|
|
err = decodeITunesCategory(dec, ch)
|
|
case ns != "" && local == "guid":
|
|
// podcast:guid (either declared URI); core RSS has no
|
|
// channel-level guid, so any namespaced guid is it.
|
|
ch.PodcastGUID, err = elementText(dec)
|
|
case ns != "" && local == "medium":
|
|
ch.PodcastMedium, err = elementText(dec)
|
|
case ns == "" && local == "item":
|
|
var item PodcastItemXML
|
|
if err := decodeItem(dec, &item); err != nil {
|
|
return err
|
|
}
|
|
ch.Items = append(ch.Items, item)
|
|
continue
|
|
default:
|
|
err = skipElement(dec)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case xml.EndElement:
|
|
return nil // </channel>
|
|
}
|
|
}
|
|
}
|
|
|
|
func decodeITunesOwner(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch t := tok.(type) {
|
|
case xml.StartElement:
|
|
if t.Name.Space == itunesNS && t.Name.Local == "name" {
|
|
if ch.ITunesOwner.Name, err = elementText(dec); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if t.Name.Space == itunesNS && t.Name.Local == "email" {
|
|
if ch.ITunesOwner.Email, err = elementText(dec); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err := skipElement(dec); err != nil {
|
|
return err
|
|
}
|
|
case xml.EndElement:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func decodeITunesCategory(dec *xml.Decoder, ch *PodcastChannelXML) error {
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch t := tok.(type) {
|
|
case xml.StartElement:
|
|
if t.Name.Space == itunesNS && t.Name.Local == "category" {
|
|
ch.ITunesCategory.Subcategory.Text = attrValue(t.Attr, "text")
|
|
}
|
|
if err := skipElement(dec); err != nil {
|
|
return err
|
|
}
|
|
case xml.EndElement:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// decodeItem walks one <item>.
|
|
func decodeItem(dec *xml.Decoder, it *PodcastItemXML) error {
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch t := tok.(type) {
|
|
case xml.StartElement:
|
|
ns, local := t.Name.Space, t.Name.Local
|
|
switch {
|
|
case ns == "" && local == "guid":
|
|
it.GUID, err = elementText(dec)
|
|
case ns == "" && local == "title":
|
|
it.Title, err = elementText(dec)
|
|
case ns == "" && local == "description":
|
|
it.Description, err = elementText(dec)
|
|
case ns == "" && local == "pubDate":
|
|
it.PubDate, err = elementText(dec)
|
|
case ns == "" && local == "author":
|
|
it.Author, err = elementText(dec)
|
|
case ns == "" && local == "enclosure":
|
|
it.Enclosure.URL = attrValue(t.Attr, "url")
|
|
it.Enclosure.Type = attrValue(t.Attr, "type")
|
|
if raw := attrValue(t.Attr, "length"); raw != "" {
|
|
it.Enclosure.Length, _ = strconv.ParseInt(raw, 10, 64)
|
|
}
|
|
err = skipElement(dec)
|
|
case ns == itunesNS && local == "title":
|
|
it.ITunesTitle, err = elementText(dec)
|
|
case ns == itunesNS && local == "subtitle":
|
|
it.Subtitle, err = elementText(dec)
|
|
case ns == itunesNS && local == "summary":
|
|
it.ITunesSummary, err = elementText(dec)
|
|
case ns == itunesNS && local == "author":
|
|
it.ITunesAuthor, err = elementText(dec)
|
|
case ns == itunesNS && local == "explicit":
|
|
it.ITunesExplicit, err = elementText(dec)
|
|
case ns == itunesNS && local == "duration":
|
|
it.ITunesDuration, err = elementText(dec)
|
|
case ns == itunesNS && local == "episode":
|
|
it.ITunesEpisode, err = elementText(dec)
|
|
case ns == itunesNS && local == "season":
|
|
it.ITunesSeason, err = elementText(dec)
|
|
case ns == itunesNS && local == "image":
|
|
it.ITunesImage.Href = attrValue(t.Attr, "href")
|
|
err = skipElement(dec)
|
|
case ns == "http://purl.org/dc/elements/1.1/" && local == "creator":
|
|
it.Creator, err = elementText(dec)
|
|
case ns == "http://purl.org/rss/1.0/modules/content/" && local == "encoded":
|
|
it.ContentEncoded, err = elementText(dec)
|
|
case ns != "" && local == "transcript":
|
|
it.Transcript.URL = attrValue(t.Attr, "url")
|
|
err = skipElement(dec)
|
|
case ns != "" && local == "chapters":
|
|
it.Chapters.URL = attrValue(t.Attr, "url")
|
|
err = skipElement(dec)
|
|
case ns != "" && local == "person":
|
|
href, role := attrValue(t.Attr, "href"), attrValue(t.Attr, "role")
|
|
it.Persons = append(it.Persons, struct {
|
|
Href string `xml:"href,attr"`
|
|
Role string `xml:"role,attr"`
|
|
}{Href: href, Role: role})
|
|
err = skipElement(dec)
|
|
default:
|
|
err = skipElement(dec)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case xml.EndElement:
|
|
return nil // </item>
|
|
}
|
|
}
|
|
}
|
|
|
|
// elementText accumulates chardata until this element closes (leaf-ish
|
|
// elements: CDATA sections come through as xml.CharData and are
|
|
// included; child elements are skipped).
|
|
func elementText(dec *xml.Decoder) (string, error) {
|
|
var sb strings.Builder
|
|
depth := 1
|
|
for depth > 0 {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch t := tok.(type) {
|
|
case xml.StartElement:
|
|
depth++
|
|
case xml.EndElement:
|
|
depth--
|
|
case xml.CharData:
|
|
if depth == 1 {
|
|
sb.Write(t)
|
|
}
|
|
}
|
|
}
|
|
return strings.TrimSpace(sb.String()), nil
|
|
}
|
|
|
|
// skipElement consumes an element we don't model (depth-safe).
|
|
func skipElement(dec *xml.Decoder) error {
|
|
depth := 1
|
|
for depth > 0 {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch tok.(type) {
|
|
case xml.StartElement:
|
|
depth++
|
|
case xml.EndElement:
|
|
depth--
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// attrValue returns the named attribute (local-name match; the
|
|
// attributes we read carry no namespace).
|
|
func attrValue(attrs []xml.Attr, local string) string {
|
|
for _, a := range attrs {
|
|
if a.Name.Local == local {
|
|
return strings.TrimSpace(a.Value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|